> 文档中心 > 第八章 Java多线程——volatile

第八章 Java多线程——volatile


8.1 几个基本概念

8.1.1 内存可见性

在之前介绍了JMM有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。
内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量,另一个线程可以读到这个修改后的值。

8.1.2 重排序

为了优化程序性能,对原有的指令执行顺序进行优化重排序。重排序可能发生多个阶段,比如编译重排序、CPU重排序。

8.1.3 happens-brfore规则

  是一个给程序员使用的规则,只要程序员在写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。

8.2 volatile 的内存语义

在Java中,volatile关键字有特殊的内存语义。volatile主要具有一下两个功能:

  • 保证变量的内存可见性
  • 禁止volatile变量与普通变量重排序(JSR133提出,Java5开始才有这个“增强的volatile内存语义”)

8.2.1 内存可见性

代码实例:

public class VolatileExample {    int a = 0;    volatile boolean flag = false;    public void writer() {  a = 1; // step 1  flag = true; // step 2    }    public void reader() {  if (flag) { // step 3  System.out.println(a); // step 4  }    }}

在这段代码里,我们使用volatile关键字修饰了一个boolean类型的变量flag。

所谓内存可见性,指的是当一个线程volatile修饰的变量进行写操作(比如step2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作(比如step3)时,JMM会立即把该线程对应的本地内存置为无效,从主内存中读取共享变量的值。

在这⼀点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。

假设在时间线上,线程A先⾃⾏⽅法 writer ⽅法,线程B后执⾏ reader ⽅法。那必然会有下图:
在这里插入图片描述
而如果 flag 变量没有用 volatile 修饰,在step2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也不会去主内存拿新的值,仍然使用线程B本地内缓存的变量值 a=0,flag=true。

8.2.1 禁止重排序

在JSR-133之前的旧的Java内存模型中,是允许volatile变量与普通变量重排序的。那上⾯的案例中,可能就会被重排序成下列时序来执⾏:

  1. 线程A写volatile变量,step 2,设置flag为true;
  2. 线程B读同⼀个volatile,step 3,读取到flag为true;
  3. 线程B读普通变量,step 4,读取到 a = 0;
  4. 线程A修改普通变量,step 1,设置 a = 1;

可见,如果volatile变量与普通变量发生重排序,虽然volatile变量能保证内存可见性,也可能导致变量读取错误。

所以在旧的内存模型中,volatile的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供⼀种⽐锁更轻量级的线程间的通信机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。

编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。

什么是内存屏障?硬件层⾯,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作⽤:

  1. 阻⽌屏障两侧的指令重排序;

  2. 强制把写缓冲区/⾼速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

    注意这⾥的缓存主要指的是CPU缓存,如L1,L2等

编译器在⽣成字节码时,会在指令序列中插⼊内存屏障来禁⽌特定类型的处理器重排序。编译器选择了⼀个⽐较保守的JMM内存屏障插⼊策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作前插⼊⼀个StoreStore屏障
  • 在每个volatile写操作后插⼊⼀个StoreLoad屏障
  • 在每个volatile写操作后插⼊⼀个StoreLoad屏障
  • 在每个volatile读操作后再插⼊⼀个LoadStore屏障

⼤概示意图是这个样⼦:

在这里插入图片描述
再逐个解释⼀下这⼏个屏障。注:下述Load代表读操作,Store代表写操作

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad;
    Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写⼊操作执⾏前,保证Store1的写⼊操作对其它处理器可⻅。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写⼊操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执⾏前,保证Store1的写⼊对所有处理器可⻅。它的开销是四种屏障中最⼤的(冲刷写缓冲器,清空⽆效化队列)。在⼤多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

对于连续多个volatile变量读或者连续多个volatile变量写,编译器做了⼀定的优化来提⾼性能,⽐如:

  • 第⼀个volatile读
  • LoadLoad屏障
  • 第⼆个volatile读
  • LoadStore屏障

再介绍⼀下volatile与普通变量的重排序规则:

1. 如果第⼀个操作是volatile读,那⽆论第⼆个操作是什么,都不能重排序
2. 如果第⼆个操作是volatile写,那⽆论第⼀个操作是什么,都不能重排序
3. 如果第⼀个操作是volatile写,第⼆个操作是volatile读,那不能重排序

举个例⼦,我们在案例中step 1,是普通变量的写,step 2是volatile变量的写,那符合第2个规则,这两个steps不能重排序。⽽step 3是volatile变量读,step 4是普通变量读,符合第1个规则,同样不能重排序。

但如果是下列情况:第⼀个操作是普通变量读,第⼆个操作是volatile变量读,那是可以重排序的:

// 声明变量int a = 0; // 声明普通变量volatile boolean flag = false; // 声明volatile变量// 以下两个变量的读操作是可以重排序的int i = a; // 普通变量读boolean j = flag; // volatile变量读

8.3 volatile的用途

从volatile的内存语义上来看,volatile可以保证内存可⻅性且禁⽌重排序。

在保证内存可⻅性这⼀点上,volatile有着与锁相同的内存语义,所以可以作为⼀个“轻量级”的锁来使⽤。但由于volatile仅仅保证对单个volatile变量的读/写具有原⼦性,⽽锁可以保证整个临界区代码的执⾏具有原⼦性。所以在功能上,锁⽐volatile更强⼤;在性能上,volatile更有优势。

在禁⽌重排序这⼀点上,volatile也是⾮常有⽤的。⽐如我们熟悉的单例模式,其中有⼀种实现⽅式是“双重锁检查”,⽐如这样的代码:

public class Singleton {    private static Singleton instance; // 不使⽤volatile关键字    // 双重锁检验    public static Singleton getInstance() { if (instance == null) { // 第7⾏     synchronized (Singleton.class) {  if (instance == null) {      instance = new Singleton(); // 第10⾏  }     } } return instance;    }}

如果这⾥的变量声明不使⽤volatile关键字,是可能会发⽣错误的。它可能会被重排序:

instance = new Singleton();    // 第10⾏// 可以分解为以下三个步骤1 memory=allocate();   // 分配内存 相当于c的malloc2 ctorInstanc(memory)    //初始化对象3 s=memory    //设置s指向刚分配的地址// 上述三个步骤可能会被重排序为 1-3-2,也就是:1 memory=allocate();    // 分配内存 相当于c的malloc3 s=memory     //设置s指向刚分配的地址2 ctorInstanc(memory)     //初始化对象

⽽⼀旦假设发⽣了这样的重排序,⽐如线程A在第10⾏执⾏了步骤1和步骤3,但是步骤2还没有执⾏完。这个时候线程A执⾏到了第7⾏,它会判定instance不为空,然后直接返回了⼀个未初始化完成的instance!

所以JSR-133对volatile做了增强后,volatile的禁⽌重排序功能还是⾮常有⽤的。