likes
comments
collection
share

简洁而不简单-Volatile

作者站长头像
站长
· 阅读数 27

volatile总览

提到volatile,他是java并发变成不可越过的关键字,那么它有什么作用呢?

  • 保证变量的线程之间可见性
  • 禁止指令重排序

首先我们先引出这两个问题

1.为什么变量不设计成在线程间互相可见的?

2.什么是指令重排序,在多线程环境下它会带来什么意想不到的结果?

回答第一个问题,为什么变量不设计成线程间互相可见的?

可能有同学会说了,这是由JMM(Java内存模型)规定的,那么为什么JMM要这么规定呢?我们都知道现代计算机有多级缓存设计,从硬件角度来说CPU寄存器的存取速度远大于主存的存取速度,JMM规定每个线程都有一个自己的工作内存,在线程开始运行的时候获得变量会把变量的值存入自己的工作内存中,运行结束的时候再刷回主内存。这样会增加多线程环境下的性能。但是也会带来可见性问题。

可见性问题

代码演示

那么什么是可见性问题呢?我们直接用代码来展示

public class VolatileTest {
  public static boolean flag = false;
  public static void main(String[] args) {
    Thread threadA = new Thread(() -> {
      try {
        Thread.sleep(1000); // 模拟一些工作
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      flag = true; // 修改flag
      System.out.println("Thread A set flag to true");
    });

    Thread threadB = new Thread(() -> {
      while (!flag) {
        // 空循环,等待flag变为true
      }
      System.out.println("Thread B detected flag change");
    });
    threadB.start();
    threadA.start();
  }
}

结果

简洁而不简单-Volatile

我们可以看到,线程B没有输出,说明A线程修改了bool值之后,B线程是不知道的。

那么我们现在对代码就做一个改动,给我们的 flag 加上一个 volatile

public static volatile boolean flag = false;

结果

简洁而不简单-Volatile

可以看到,B线程成功输出了!

这就是可见性问题,意思是一个变量的改变对其他线程是否是可见的。

当变量加上volatile,那么每次这个变量改变,他都会刷回主内存,而对这个变量的使用,也会从主内存中获得最新的值。

实现原理

#添加volatile前
0x0000000003324cda: mov    0x74(%r8),%edx     ;*getstatic state
                                                 ; - VT::run@28 (line 27)
#添加volatile后 
   0x0000000003324cde: inc    %edx
   0x0000000003324ce0: mov    %edx,0x74(%r8)
   0x0000000003324ce4: lock addl $0x0,(%rsp)     ;*putstatic state
                                                 ; - VT::run@33 (line 27)

把上述代码转化为汇编之后取出重要部分,重点关注

 lock addl $0x0,(%rsp)

这一条命令可以理解为内存屏障,首先阻止了指令重排,其次将本处理器的缓存写入了内存,并且会导致其他处理器对应的内存失效,从而使他们都要从主存获取最新的变量值。如果是读操作的话就不会加锁。因此volatile会影响写操作的性能。

指令重排问题

什么是指令重排?

指令重排(Instruction Reordering)是编译器和处理器在不改变程序单线程语义的前提下,对指令进行重新排序,以提高程序执行效率的一种优化技术。

双锁单例模式

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;
    }
}

这是一个懒汉的双锁单例模式的实现,我们来看代码逻辑。

假设两个线程同时进入getInstance代码块,这时候这个instance实例还没被创建,那么他们就都会进入下面的代码块中

 synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }

这里使用sychronized锁住,保证这时候只有一条线程进去初始化对象了。假设是A线程抢到锁进去初始化对象,那么B对象就被关在门口等,等到A初始化完毕,B再进入下面代码,判断instance!=null,那么自然他就不用再执行初始化操作了。双锁校验保证了不会重复初始化对象。

在上述DCL代码示例中,instance = new Singleton();这行代码实际上涉及到三个操作:

1)为Singleton分配内存空间; 2)调用Singleton的构造函数,初始化成员字段; 3)将instance对象指向分配的内存空间。

正常来说,执行顺序是123,但是由于指令重排,执行顺序有可能变成132,那么就会有如下情况,执行13后这个instance已经不为null了,但是其实他还没初始化。这时候刚刚好有一个线程走到第一个null判断,认为instance不为null,直接返回,这样返回的这个instance就为null,会造成NPE现象。

因此 就需要用到我们的 volatile 来确保不会发生指令重排!

那么我们继续探究这个DCL例子,在这里说使用volatile还是为了保证内存可见性合理吗?

不!其实使用 synchronized 就会保证这个instance是线程间可见的了

因为当一个线程持有锁的时候,其他线程是进不来的,也就是其他线程访问不到这个instance,然而当这个线程释放锁的时候,就会把最新的value刷新回主内存,另一个线程拿到锁从主内存拿的时候自然就是最新的value了。所以这里使用volatile的目的并不是为了保证instance的内存可见性,而是为了避免指令重排带来的问题。

转载自:https://juejin.cn/post/7389902504010367016
评论
请登录