深入分析 volatile 的实现原理

摘要: 原创出处 http://cmsblogs.com/?p=2092 「小明哥」欢迎转载,保留摘要,谢谢!

作为「小明哥」的忠实读者,「老艿艿」略作修改,记录在理解过程中,参考的资料。

我们了解了 synchronized 是一个重量级的锁,虽然 JVM 对它做了很多优化。而下面介绍的 volatile ,则是轻量级synchronized ,它在多线程开发中保证了共享变量的“可见性”。如果一个变量使用 volatile ,则它比使用 synchronized 的成本更加低,因为它不会引起线程上下文的切换和调度

Java 语言规范对 volatile 的定义如下:

Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

上面比较绕口,通俗点讲就是说一个变量如果用 volatile 修饰了,则 Java 可以确保所有线程看到这个变量的值是一致的。如果某个线程对 volatile 修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。

volatile 虽然看起来比较简单,使用起来无非就是在一个变量前面加上 volatile 即可,但是要用好并不容易(LZ 承认我至今仍然使用不好,在使用时仍然是模棱两可)。

1. 内存模型相关概念

理解 volatile 其实还是有点儿难度的,它与 Java 的内存模型有关,所以在理解 volatile 之前我们需要先了解有关 Java 内存模型的概念。这里只做初步的介绍,后续 LZ 会详细介绍 Java 内存模型。

1.1 操作系统语义

计算机在运行程序时,每条指令都是在 CPU 中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有 CPU 中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了 CPU 高速缓存。CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。

有了 CPU 高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到 CPU 高速缓存中,在进行运算时 CPU 不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后,才会将数据刷新到主存中。举一个简单的例子:

i = i + 1;

当线程运行这段代码时,首先会从主存中读取 i 的值( 假设此时 i = 1 ),然后复制一份到 CPU 高速缓存中,然后 CPU 执行 + 1 的操作(此时 i = 2),然后将数据 i = 2 写入到告诉缓存中,最后刷新到主存中。

其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:

假如有两个线程 A、B 都执行这个操作( i++ ),按照我们正常的逻辑思维主存中的i值应该=3 。但事实是这样么?分析如下:

两个线程从主存中读取 i 的值( 假设此时 i = 1 ),到各自的高速缓存中,然后线程 A 执行 +1 操作并将结果写入高速缓存中,最后写入主存中,此时主存 i = 2 。线程B做同样的操作,主存中的 i 仍然 =2 。所以最终结果为 2 并不是 3 。这种现象就是缓存一致性问题

解决缓存一致性方案有两种

  1. 通过在总线加 LOCK# 锁的方式
  2. 通过缓存一致性协议

第一种方案, 存在一个问题,它是采用一种独占的方式来实现的,即总线加 LOCK# 锁的话,只能有一个 CPU 能够运行,其他 CPU 都得阻塞,效率较为低下。

第二种方案,缓存一致性协议(MESI 协议),它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个 CPU 在写数据时,如果发现操作的变量是共享变量,则会通知其他 CPU 告知该变量的缓存行是无效的,因此其他 CPU 在读取该变量时,发现其无效会重新从主存中加载数据。

212219343783699

老艿艿:目前新的 CPU ,增加了【缓存锁】来保证原子性。推荐阅读:《Java并发编程的艺术》的 「2.3 原子操作的实现原理」「2. 处理器如何实现原子操作」 小节。建议反复看几次,虽然我现在理解还是有点懵着。

1.2 Java内存模型

上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下 Java 内存模型,稍微研究一下它为我们提供了哪些保证,以及在 Java 中提供了哪些方法和机制,来让我们在进行多线程编程时能够保证程序执行的正确性。

在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。我们稍微看下volatile

1.2.1 原子性

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子性就像数据库里面的事务一样,他们是一个团队,同生共死。其实理解原子性非常简单,我们看下面一个简单的例子即可:

i = 0;  // <1>
j = i ;  // <2>
i++;  // <3>
i = j + 1; // <4>

上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有 1 才是原子操作,其余均不是

  • <1>:在 Java 中,对基本数据类型的变量和赋值操作都是原子性操作。
  • <2>:包含了两个操作:读取 i,将 i 值赋值给 j
  • <3>:包含了三个操作:读取 i 值、i + 1 、将 +1 结果赋值给 i
  • <4>:同 <3> 一样

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java 只保证了基本数据类型的变量和赋值操作才是原子性的(注:在 32 位的 JDK 环境下,对 64 位数据的读取不是原子性操作,例如:long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized 来确保。

那么 64 位的 JDK 环境下,对 64 位数据的读写是否是原子的呢?感兴趣的胖友可以看看 《64位 JVM 的 long 和 double读 写也不是原子操作么?》

另外,volatile无法保证复合操作的原子性

1.2.2 可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

Java提供了 volatile 来保证可见性。

当一个变量被 volatile 修饰后,表示着线程本地内存无效。当一个线程修改共享变量后他会立即被更新到主内存中;当其他线程读取共享变量时,它会直接从主内存中读取。

当然,synchronize 和锁都可以保证可见性。

1.2.3 有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

在 Java 内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。

Java 提供 volatile 来保证一定的有序性。最著名的例子就是单例模式里面的 DCL(双重检查锁)。这里 LZ 就先不阐述了,后续会有专门的文章分享。

2. 剖析 volatile 原理

JMM 比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了 volatile 做铺垫的。

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层,volatile 是采用“内存屏障”来实现的。

上面那段话,有两层语义:

  1. 保证可见性、不保证原子性
  2. 禁止指令重排序

第一层语义就不做介绍了,下面重点介绍指令重排序

在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:

  1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答。

我们先看另一个原则 happens-before该原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从 happens-before 原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:

FROM 《深入理解 Java 虚拟机》

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。
  • 锁定规则:一个 unLock 操作,happens-before 于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个变量的写操作,happens-before 于后面对这个变量的读操作。
  • 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,则可以得出,操作 A happens-before 操作C
  • 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。
  • 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。
  • 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始

我们着重看第三点 Volatile规则:对 volatile变量的写操作,happen-before 后续的读操作。为了实现 volatile 内存语义,JMM会重排序,其规则如下:

volatile 重排序规则

  • 当第二个操作是 volatile 写操作时,不管第一个操作是什么,都不能重排序。这个规则,确保 volatile 写操作之前的操作,都不会被编译器重排序到 volatile 写操作之后。

对 happen-before 原则有了稍微的了解,我们再来回答这个问题 JVM 是如何禁止重排序的?

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile 关键字时,会多出一个 lock 前缀指令。lock 前缀指令,其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile 的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:

内存屏障)

volatile 暂且下分析到这里,JMM 体系较为庞大,不是三言两语能够说清楚的,后面会结合 JMM 再一次对 volatile 深入分析。

3. 总结

volatile 看起来简单,但是要想理解它还是比较难的,这里只是对其进行基本的了解。

volatile 相对于 synchronized 稍微轻量些,在某些场合它可以替代 synchronized ,但是又不能完全取代 synchronized 。只有在某些场合才能够使用 volatile,使用它必须满足如下两个条件:

  1. 对变量的写操作,不依赖当前值
  2. 该变量没有包含在具有其他变量的不变式中。

volatile 经常用于两个两个场景:状态标记变量、Double Check 。

参考资料

  1. 周志明:《深入理解Java虚拟机》
  2. 方腾飞:《Java并发编程的艺术》的 「2.1 volatile 的应用」「2.3 原子操作的实现原理」 章节。
  3. 《Java 并发编程:volatile 关键字解析》
  4. 《Java 并发编程:volatile 的使用及其原理》

666. 彩蛋

整理本小节,简单脑图如下:脑图

前篇博客 《【死磕 Java 并发】—– 深入分析 volatile 的实现原理》 中已经阐述了 volatile 的特性了:

  1. volatile 可见性:对一个 volatile 的读,总可以看到对这个变量最终的写。
  2. volatile 原子性:volatile 对单个读 / 写具有原子性(32 位 Long、Double),但是复合操作除外,例如 i++
  3. JVM 底层采用“内存屏障”来实现 volatile 语义。

下面 LZ 就通过 happens-before 原则volatile内存语义,两个方向分析 volatile

1. volatile 与 happens-before

在这篇博客 《【死磕 Java 并发】—– Java 内存模型之 happens-before》 中,LZ 阐述了 happens-before 是用来判断是否存在数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。下面我们就那个经典的例子,来分析 volatile 变量的读写,如何建立的 happens-before 关系。

public class VolatileTest {

    int i = 0;
    volatile boolean flag = false;

    // Thread A
    public void write(){
        i = 2;              // 1
        flag = true;        // 2
    }

    // Thread B
    public void read(){
        if(flag) {                                   // 3
            System.out.println("---i = " + i);      // 4
        }
    }
}

依据 happens-before 原则,就上面程序得到如下关系:

  • 程序顺序原则:操作 1 happens-before 操作 2 ,操作 3 happens-before 操作 4 。
  • volatile 原则:操作 2 happens-before 操作 3 。
  • 传递性原则:操作 1 happens-before 操作 4 。

操作 1、操作 4 存在 happens-before 关系,那么操作 1 一定是对 操作 4 是可见的。可能有同学就会问,操作 1、操作 2 可能会发生重排序啊,会吗?如果看过 LZ 的博客就会明白,volatile 除了保证可见性外,还有就是禁止重排序。所以 A 线程在写 volatile 变量之前所有可见的共享变量,在线程 B 读同一个 volatile 变量后,将立即变得对线程 B 可见。

2. volataile 的内存语义及其实现

在 JMM 中,线程之间的通信采用共享内存来实现的。volatile 的内存语义是:

  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值,立即刷新到主内存中。
  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

那么 volatile 的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于 volatile 的变量则不能。这样会影响其内存语义,所以为了实现 volatile 的内存语义,JMM 会限制重排序。其重排序规则如下:

翻译如下:

  1. 如果第一个操作为 volatile 读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile之后的操作,不会被编译器重排序到 volatile 读之前;
  2. 如果第二个操作为 volatile 写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile之前的操作,不会被编译器重排序到 volatile 写之后;
  3. 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。

volatile 的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM 采用了保守策略

策略如下:

  • 在每一个 volatile 写操作前面,插入一个 StoreStore 屏障
  • 在每一个 volatile 写操作后面,插入一个 StoreLoad 屏障
  • 在每一个 volatile 读操作后面,插入一个 LoadLoad 屏障
  • 在每一个 volatile 读操作后面,插入一个 LoadStore 屏障

原因如下:

  • StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操作重排序
  • LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序
  • LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序

2.1 案例 1:VolatileTest

下面我们就上面 VolatileTest 例子重新分析下:

public class VolatileTest {
    
    int i = 0;
    volatile boolean flag = false;
    
    public void write() {
        i = 2;
        flag = true;
    }

    public void read() {
        if (flag){
            System.out.println("---i = " + i);
        }
    }
    
}

内存屏障图例

2.2 案例 2:VolatileBarrierExample

volatile 的内存屏障插入策略非常保守,其实在实际中,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况优化省略不必要的屏障。如下例子,摘自方腾飞 《Java并发编程的艺术》:

public class VolatileBarrierExample {
    int a = 0;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite(){
        int i = v1;     //volatile读
        int j = v2;     //volatile读
        a = i + j;      //普通读
        v1 = i + 1;     //volatile写
        v2 = j * 2;     //volatile写
    }
}

没有优化的示例图如下:

未优化)

我们来分析,上图有哪些内存屏障指令是多余的

  • 1:这个肯定要保留了
  • 2:禁止下面所有的普通写与上面的 volatile 读重排序,但是由于存在第二个 volatile读,那个普通的读根本无法越过第二个 volatile 读。所以可以省略
  • 3:下面已经不存在普通读了,可以省略
  • 4:保留
  • 5:保留
  • 6:下面跟着一个 volatile 写,所以可以省略
  • 7:保留
  • 8:保留

所以 2、3、6 可以省略,其示意图如下:

已优化

参考资料

  1. 方腾飞:《Java并发编程的艺术》的 「3. Java 内存模型」 章节。

666. 彩蛋

整理本小节,简单脑图如下:脑图

这些信息有用吗?
Do you have any suggestions for improvement?

Thanks for your feedback!