欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

知名公司面试题:谈谈你对volatile关键字的理解

程序员文章站 2024-03-01 18:33:10
...

作为一名java程序员,求职面试时,关于volatile关键字时常会遇到。张工最近到某知名互联网公司面试,面试官提出这样的一个问题:

谈谈你对volatile关键字的理解

张工一时间没有回答上来,面试官:你都工作三年了,怎么对volatile关键字都没掌握啊。

张工被面试官这么一说,都不好意思了。

对于一名java开发者,不管是在求职面试还是项目实际开发中,volatile都是一个需要掌握的知识点,是需要掌握好的。我们平时在阅读源码的过程中,时常会遇到volatile关键字,譬如Atomic类,通过源码我们会发现volatile无处不在。

为什么要用到volatile关键字?

在Java多线程的开发中有三种特性:

  • 原子性

  • 可见性

  • 有序性

volatile主要作用是保证内存可见性和防止指令重排序。

保持内存可见性

内存可见性(Memory Visibility):所有线程都能看到共享内存的最新状态。可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

知名公司面试题:谈谈你对volatile关键字的理解

每一个线程都有一份自己的本地内存,所有线程共用一份主内存。如果一个线程对主内存中的数据进行了修改,而此时另外一个线程不知道是否已经发生了修改,就说此时是不可见的。

这种不可见的状况会带来一个问题,两个线程有可能会操作同一份但是值不一样的数据。

这时该volatile闪亮出场了。

那么volatile是如何保持内存可见性的。

volatile的特殊规则就是:

  • read、load、use动作必须连续出现。

  • assign、store、write动作必须连续出现。

所以,使用volatile变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值。

  • 每次写入后必须立即同步回主内存当中。

也就是说,volatile关键字修饰的变量看到的随时是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。

防止指令重排序

在基于偏序关系的Happens-Before内存模型中,指令重排技术大大提高了程序执行效率,但同时也引入了一些问题。

一个指令重排的问题——被部分初始化的对象

懒加载单例模式和竞态条件

我们来看一个懒加载的单例模式:

class Singleton {
  private static Singleton instance;
  private Singleton(){}
  public static Singleton getInstance() {
    if ( instance == null ) { //存在竞态条件
        instance = new Singleton();
    }
    return instance;
 }
}

竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。

DCL和被部分初始化的对象

为了解决这个问题,最简单的方法是将 getInstance() 方法设为同步(synchronized),虽然解决了问题,但很容易导致阻塞,这就引出了双重检验锁DCL(Double Check Lock)机制,使得大部分请求都不会进入阻塞代码块:

  class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
    if ( instance == null ) {
        synchronized (Singleton.class) {
            if ( instance == null ) {
                instance = new Singleton();
            }

        }

    }
    return instance;
}
}

这段代码看起来很完美:不仅减少了阻塞,又避免了竞态条件。

但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个"被部分初始化的对象"。

问题出在这里:

 instance = new Singleton();

它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

  • memory = allocate();//1:分配对象的内存空间

  • ctorInstance(memory);//2:初始化对象

  • instance = memory;//3:设置instance指向刚分配的内存地址

操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,把它们重新排序:

  • memory = allocate();//1:分配对象的内存空间

  • instance = memory;//3:设置instance指向刚分配的内存地址(注意此时对象还未初始化)

  • ctorInstance(memory);//2:初始化对象

可以看到指令重排后,操作3排在了操作2前,即引用instance指向内存memory时,这段崭新的内存还没有初始化,引用instance指向了一个"被部分初始化的对象"。

此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。

解决这个该问题,只需要将instance声明为volatile变量:

private static volatile Singleton instance;

调整后一个完整的单例模式

public class Singleton {
  private static volatile Singleton instance;
  private Singleton(){}
  public static Singleton getInstance() {
   if ( instance == null ) {
    synchronized (Singleton.class) {
        if ( instance == null ) {
            instance = new Singleton();
        }
      }
   }
   return instance;
  }
}

volatile不能保证原子性

volatile关键字保证可见性和有序性,但是并意味着volatile就可以保证原子性,原子性,也就是一个操作,要么不执行,要么执行到底。

volatile:从最终汇编语言层面来看,volatile使得每次将i进行了修改之后,增加了一个内存屏障lock addl $0x0,(%rsp)保证修改的值必须刷新到主内存才能进行内存屏障后续的指令操作。但是内存屏障之前的指令并不是原子的。

对此我们使用代码来验证一下:

public class VolatileTest {
public static volatile int i = 0;
public static void increase() {
    i++;
  }

}

字节码:

知名公司面试题:谈谈你对volatile关键字的理解

volatile方式的i++,一般有四个步骤:

  1. load、

  2. Increment、

  3. store、

  4. Memory Barriers

在某一时刻线程1将i的值load取出来,放到cpu缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1。

感觉这样解释有点不好理解,我们可以这样理解:寄存器A中保存的是中间值,并没有直接修改i值,其他线程并不会获取到这个自增1的值。

此时如果有线程2也执行同样的操作,获取值i==10,自增1变为11,然后刷入主内存。

此时由于线程2修改了i的值,实时的线程1中的i==10的值缓存失效了,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。

总结:

volatile保持内存可见性和防止指令重排序的原理,其实都是依靠内存屏障。volatile可以保证可见性、有序性,但不能保证原子性。

文中关于volatile讨论基于java8。

由于笔者水平有限,文中纰漏之处在所难免,权当抛砖引玉,不妥之处,请大家批评指正。

-END-

作者:洪生鹏  技术交流、媒体合作、品牌宣传请加作者微信: hsp-88ios

猜你喜欢

我看你简历上写着熟悉kafka,如果让你自己写一个消息队列,该如何进行设计?说一下你的思路

知名公司面试题:谈谈你对volatile关键字的理解

更多惊喜,请长按二维码识别关注