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

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

谈谈你对volatile关键字的理解

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

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

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

为什么要用到volatile关键字?

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

  • 原子性

  • 可见性

  • 有序性

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

保持内存可见性

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

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

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

这时该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方式的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,如果让你自己写一个消息队列,该如何进行设计?说一下你的思路

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

展开阅读全文
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值