天天看点

Java中volatile关键字原理深度剖析

作者:闪念基因

运营规则团队:焦星星、假面、武聪

前言

由于近两月一直赶项目,中间还夹带一些技改等工作,没什么空闲时间修炼技术。想起很早之前看了不少有关volatile的文章,里面提及过一个单例模式下对该对象进行修饰的必要性问题,刚好在组内一次技术分享中提到了volatile关键字并详细阐述了此问题。借此为契机,顺便对CPU多级缓存架构设计、MESI协议、JMM内存模型等技术进行梳理,这篇文章会把volatile的底层实现分析透彻。

如果对volatile和可见性不熟悉的话,可以先参看以下这些文章:

(1)《深入理解Java内存模型(JMM)中的可见性》

(2)《理解 Memory barrier(内存屏障)》

(3)《从 CPU 层面深度剖析指令重排序的本质》

这篇文章前后用了两周时间整理,文字较多,希望耐心阅读。

一、CPU多级缓存架构剖析

1.1 计算机的组成

Java中volatile关键字原理深度剖析

图1-1 计算机组成结构

现代的计算机,大部分都是基于冯诺依曼体系结构:运算器、控制器、存储器、输入设备、输出设备。对于程序运行环境来说最重要的两个模块是内存和CPU,通常我们写的程序运行流程大概是如下:

从计算机的角度看:

(1)指令会被控制器(CU)获取

(2)CU解析指令交给ALU

(3)ALU经过一系列计算输出到I/O

从程序本身看:

(1)当程序运行的时候程序和数据会被从磁盘装入到内存

(2)源文件会被替换成可执行文件

(3)从可执行文件的首地址开始执行

1.2 计算机存储器的层次结构

Java中volatile关键字原理深度剖析

图1-2 计算机存储器的层次结构

整个存储器的层次结构,其实都类似于 SRAM 和 DRAM 在性能和价格上的差异。SRAM 更贵,速度更快,DRAM 更便宜,容量更大。CPU并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道,例如,寄存器只从L1高速缓存中拿数据,L1只从L2高速缓存中拿数据,以此类推。各存储器之间访问速度关系大概是:

寄存器 > L1 > L2 > L3 > 主内存 > 本地存储 > 远程存储           

1.3 多核CPU内部结构

Java中volatile关键字原理深度剖析

图1-3 多核CPU的内部结构示例

画这个图是想说明一下多核CPU各级缓存的访问关系,图中有两颗CPU,每颗CPU都有两个核,从图中可以看出,L1和L2是每个核独占的,而L3是CPU内部每个核共享的,而我们平时说的内存又是被多个CPU之间共享的。

1.4 缓存行(Cache Line)

Java中volatile关键字原理深度剖析

图1-4 缓存行示例

上面提到各存储器只和相邻的存储设备打交道,也就是说,CPU读取数据时不会直接从主存读取,而是先从CPU Cache中读,读不到再从主存读。之后CPU如果重复读写这个数据,就不需要再访问主存,而是直接访问高速缓存中对应的缓存行上的数据。这样就能频繁避免读写主存所带来的开销(CPU和高速缓存间的速度差距比CPU和主存之间小的多得多)。CPU Cache的缓存策略是基于局部性原理设计的。

局部性分两点:

(1)时间局部性,即最近刚被访问的数据,大概率会被再次访问;

(2)空间局部性,即最近刚被访问的数据,相邻的数据大概率会被访问。

根据以上原理,CPU Cache在缓存数据时,并不是以单个字节为单位缓存的,而是以CPU Cache Line大小为单位缓存,CPU Cache Line在一般的x86环境下为64字节。也就是说,即使只需要从内存读取1个字节的数据,也会将邻近的64个字节一并缓存至CPU Cache中(这种设计在多线程环境下会产生伪共享问题)。例如:上图中,变量x、y在同一个缓存行中,当CPU读取变量x时,先从L1中读取,L1中没数据会从L2中读取,L2中没数据从L3中读取,L3中没数据从内存中读取,发现内存中有数据,会将x所在的缓存行的数据(即x,y)一起加载到L3,L2,L1各级缓存中。

1.5 MESI Cache一致性协议

CPU引入了高速缓存后,会导致多个CPU核之间的缓存一致性问题。首先CPU提供了一种总线锁的机制来解决缓存一致性问题,即通过锁总线的方式来让多个CPU核访问主存时互斥,属于粗粒度的加锁方式,会导致CPU性能急剧下降,所以CPU又采用了缓存一致性协议来解决缓存一致性问题。通常使用的缓存一致性协议为MESI,MESI分别代表缓存行数据所处的四种状态,通过对这四种状态的切换,来达到对缓存数据进行管理的目的。

(注:无法被缓存的数据或者跨越多个缓存行的数据依然必须使用总线锁)

MESI演示地址:

https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESI.htm

缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行写回内存并将状态置为E之后才能操作该缓存行对应的内存数据。

表1-1 缓存行状态说明

状态 描述 监听任务

M 修改

(Modify)

本地缓存独占;

缓存行有效;

缓存行和主存数据不一致;

缓存行必须时刻监听所有试图读该缓存行相对应的内存的操作,其他缓存须在本缓存行写回内存并将状态置为E之后才能操作该缓存行对应的内存数据。

E 独享、互斥

(Exclusive)

本地缓存独占;

缓存行有效 ;

缓存行和主存数据一致 ;

缓存行必须监听其他缓存读主内存中该缓存行相对应的内存的操作,一旦有这种操作,该缓存行需要变成S状态。

S 共享

(Shared)

多个CPU缓存共享;

缓存行有效;

缓存行和主存数据一致;

缓存行必须监听其他缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行置为I状态。

I 无效

(Invalid)

多个CPU缓存共享;

缓存行无效;

缓存行和主存数据不一致;

MESI协议带来的问题:就是各个CPU缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其它缓存了该数据的CPU。并且要等到它们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了提高并行度,现代的CPU都会增加写缓冲区(Store Buffer)和失效队列(Invalidation Queue)将MESI协议的请求异步化。

写缓冲区:写入指令放到写缓冲区后并发送 RFO 请求后,CPU 就可以去执行其它任务,等收到 ACK 后再将写入操作写到 Cache 上。

失效队列:先把其它核心发过来的 RFO 请求放到失效队列,然后直接返回 ACK,等当前核心处理完任务后再去处理失效队列中的失效请求。

Java中volatile关键字原理深度剖析

图1-5 写缓冲区和失效队列示意图

既然 CPU 已经实现了 MESI 协议,为什么 Java 语言层面还需要定义volatile 关键字呢?

精确地讲,MESI协议的缓存一致性解决的是硬件层面多CPU高速缓存之间的数据一致性(Data Conherence)问题,即同一份数据在多个副本之间的一致性问题。而volatile解决的是JVM层面多线程下的指令执行的顺序一致性(Sequential Consistency)问题。引入的写缓冲区和失效队列,也只是将MESI的强数据一致性变成了弱数据一致性(随着缓冲区和失效队列被消费,各个核心 Cache 最终还是会趋向一致状态),如果变量只是写入Store Buffer,还没及时同步到高速缓存,就不会触发MESI,而且在硬件层面甚至可能选择不将变量一直刷新到主存中,而只是将其保存在CPU缓存中-直到被需要的时候才会刷新到主存,然而,对于在CPU上运行的代码,这是不可见的。

理想下的顺序一致性模型由两个特征组成:

(1)执行顺序与编码顺序一致: 保证每个线程中指令的执行顺序与编码顺序一致;

(2)全局执行顺序一致:保证每个指令的结果会同步到主内存和各个线程的工作内存上,使得每个线程上看到的全局执行顺序一致。

如果程序完全按照顺序一致性模型来实现,那么处理器和编译器的很多重排序优化都要被禁止:编译器和处理器不能重排列没有依赖关系的指令 、 CPU 不能使用写缓冲区和失效队列机制,这些对程序的并行度会有影响,所以在 Java 虚拟机和处理器实现中,实际上使用的是弱顺序一致性模型,对应也有两个特征:

(1)不要求执行顺序与编码顺序一致:不要求单线程的执行顺序与编码顺序一致,只要求执行结果与强顺序执行的结果一致,而指令是否真的按编码顺序执行并不关心;

(2)不要求全局执行顺序一致:允许每个线程看到的全局执行顺序不一致,甚至允许看不到其他线程已执行指令的结果。

所以即使有了MESI协议,java语言层面仍需要定义volatile关键字解决JVM层面多线程下的指令执行的顺序一致性。

二、JMM(Java Memory Model)

JMM是Java内存模型的简称,是围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型,由Java虚拟机规范定义的一个抽象概念,用来屏蔽各个平台的硬件差异 。它规定:

(1)所有共享变量存储在主内存,每一个线程都有自己独立的工作内存,里面保存了共享变量的副本;

(2)线程不能直接读写主内存中的变量,所有操作均在工作内存中完成;

(3)不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

Java中volatile关键字原理深度剖析

图2-1 JMM示意图

从JVM的角度看,主内存和工作内存其实就是堆和线程栈之间的内存划分:

Java中volatile关键字原理深度剖析

图2-2 JVM视角的内存模型示意图

Java内存模型和硬件内存体系结构是不同的,硬件内存架构不区分线程栈和堆。在硬件上,线程栈和堆都位于主存中。线程栈和堆的某些部分有时会出现在CPU缓存和内部CPU寄存器中:

Java中volatile关键字原理深度剖析

图2-3 JVM内存和硬件内存映射关系图

JMM对主内存与工作内存之间的具体交互协议定义了八种原子操作:

(1)read(读取):作用于主内存变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 动作使用;

(2)load(载入):作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本中;

(3)use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行此操作;

(4)assign(赋值):作用于工作内存变量,把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量进行赋值的字节码指令时执行此操作;

(5)store(存储):作用于工作内存变量,把工作内存中一个变量的值传递到主内存中,以便后续 write 操作;

(6)write(写入):作用于主内存变量,把 store 操作从工作内存中得到的值放入主内存变量中;

(7)lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态;

(8)unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。

三、volatile关键字原理

上面提到的CPU缓存架构、JMM模型其实都是铺垫,但是对于理解voaltile关键字原理又很重要。接下来我们一起看下volatile关键字的可见性和禁止指令重排序都是怎么实现的。

3.1 可见性保证

Java volatile关键字保证了跨线程变量更改的可见性。这听起来可能有点抽象,在这里我们进行一下详细分析。

在多线程应用程序中,线程对共享变量进行操作,出于性能考虑,每个线程在处理共享变量时可能会将共享变量从主存复制到CPU缓存中。如果您的计算机包含多个CPU,则每个线程可能运行在不同的CPU上。这意味着,每个线程都可以将共享变量复制到不同CPU的CPU缓存中。如下图3-1所示:

Java中volatile关键字原理深度剖析

图3-1 多核CPU缓存示意图

对于non-volatile变量,Java虚拟机(JVM)不能保证何时将数据从主存读取到CPU缓存,或何时将数据从CPU缓存写入主存。这就导致了可见性问题,例如有一个boolean类型的共享变量flag,线程B更新flag的值为true,线程A只读取flag变量的值。

public class SharedObject {
    public boolean flag;
}           

如果flag变量没有被声明为volatile,则不能保证flag变量的值何时从CPU缓存写入主存。这意味着CPU缓存中的flag变量的值可能与主存中的不相同。这种情况如下所示:

Java中volatile关键字原理深度剖析

图3-2 未使用volatile关键字示意图

不管线程A是在线程B修改flag变量之前还是之后去读取flag变量的值,只要线程B的修改结果没有同步到主存中,线程A从主存读取到的flag变量的值就永远为false,这就是可见性问题:一个线程的更新对另一个线程不可见。

Java中volatile关键字就可以解决可见性问题。通过将flag变量声明为volatile,所有对flag变量的写操作将立即被写回主存。同样,所有对flag变量的读取都将直接从主存中读取。下面是flag变量被声明为volatile的情况。

public class SharedObject {
    public volatile boolean flag;
}           

在这个例子中,不管线程A是在线程B修改flag变量之前还是之后去读取flag变量的值,都能从主存读取到flag变量最新的值。我们结合JMM中的八大原子操作用几张图来解释下volatile关键字实现可见性的整个过程。如下图3-3,假设线程A和线程B都在同一时刻将flag变量读到了自己的工作内存中:

Java中volatile关键字原理深度剖析

图3-3 read-load-use-assign 操作示意图

上图3-3中线程A和线程B同时将变量flag读取到了自己的工作内存中,且线程B读取后将flag变量的值改为了true,即将执行assign操作将修改后的值写入到工作内存中。

Java中volatile关键字原理深度剖析

图3-4 store-lock操作示意图

上图3-4中,线程B执行完assign操作,将自己工作内存中变量flag的值更新为了true,同时执行store操作将最新值写回主存,此时会触发lock操作将flag变量所在的缓存行锁住,同时通过总线嗅探技术告知其它拥有此缓存行的CPU此缓存行已被修改,其它CPU将此缓存行置为无效状态,然后flag变量的最新值通过write操作被写回主内存,在写回主存期间其它线程读取不到这个缓存行里的值,只有最后执行完unlock操作,其它线程才可以读取。

Java中volatile关键字原理深度剖析

图3-5 write-unlock操作示意图

flag的最新值被写回主存后,线程A在自己的工作内存中读取flag变量时,发现flag变量所在缓存行无效,就会重新从主存读取flag变量的值,最后线程A将flag变量的最新值读取到了自己的工作内存中,这就是线程间的可见性:一个线程的更新对另一个线程可见。

总结一下就是:对于volatile变量的读取,若变量发生过修改,每次都会从计算机的主存中读取,而不是从CPU缓存中读取;对volatile变量的每次写入都将写入主存中,而不仅仅是写入CPU缓存中。

3.2 不保证原子性

我们以非原子操作i++为例,分析一下volatile为什么不能保证原子性。假设线程A和线程B同一时间都将i的初始值0从主存加载到了自己的工作内存,且都执行了i++操作,这个时候线程A和线程B的工作内存中i的值都为1,如下图3-6所示:

Java中volatile关键字原理深度剖析

图3-6 volatile不保证原子性的示意图(第1步)

然后线程B优先将自己工作内存中 i=1 的值写回主存中,线程A中的i所在的缓存行被置为无效状态,但是由于线程A已经进行过 i++ 计算,所以即使i所在缓存行失效也不会再重新读取计算,最后线程A也会将自己工作内存中 i=1 的值写回主存中,覆盖掉线程B的执行结果。经过两次++操作,i的值应该为2,但现在i的值为1,所以volatile并不能保证原子性。

Java中volatile关键字原理深度剖析

图3-7 volatile不保证原子性的示意图(第2步)

3.3 禁止指令重排序

为什么会出现指令重排序的问题呢? 除了编译器层面的代码优化,最根本原因就在于CPU的乱序执行:为了提升效率,在保证最终执行结果一样的前提下,在等待某一指令执行结果时同时执行了其它的指令(比如指令1是去内存读数据,访问速度比读高速缓存慢100倍,在这期间cpu会执行和指令1没有依赖关系的指令2),入下图3-8所示:

Java中volatile关键字原理深度剖析

图3-8 指令重排示意图

乱序执行,说白了就是CPU对指令的并行执行,下面两条指令彼此不依赖,因此可以并行执行:

a = b + c
d = e + f           

但是,下面两条指令不能并行执行,因为第二条指令取决于第一条指令的结果:

a = b + c
d = a + e           

假设上面的两条指令是某个大指令集的一部分,就像下面这样:

a = b + c
d = a + e


l = m + n
y = x + z           

则指令可以重新排序如下:

a = b + c


l = m + n
y = x + z


d = a + e           

然后CPU可以并行执行至少前3条指令,一旦第一条指令执行完成,它就可以开始执行第四条指令。由此可见,重新排序指令可以增加CPU中指令的并行执行度,提高并行度意味着提高性能。

只要程序的语义不改变,JVM和CPU就允许指令重新排序,但最终结果必须是相同的,就好像指令是按照源代码中列出的顺序执行的一样。这其实就是as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

接下来我们看一个例子证明乱序执行的存在:

public class ReorderingTest {


    static int x = 0, y = 0;


    public static void main(String[] args) throws InterruptedException {
        Set<String> resultSet = new HashSet<>();
        Map<String, Integer> resultMap = new HashMap<>();
        for (int i = 0; i < 1000000; i++) {
            x = 0;
            y = 0;
            resultMap.clear();
            Thread t1 = new Thread(() -> {
                int a = y;
                x = 1;
                resultMap.put("a", a);
            });
            Thread t2 = new Thread(() -> {
                int b = x;
                y = 1;
                resultMap.put("b", b);
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            resultSet.add("a=" + resultMap.get("a") + "," + "b=" + resultMap.get("b"));
            System.out.println(resultSet);
        }
    }
}           

在上述代码中,除主线程外额外开启了两个线程t1和t2,且t1、t2线程中的两条指令没有依赖关系。在每次循环中,都会把本次得到的变量a和变量b的值的组合存入一个set集合中,且每次循环都会把目前为止遇到的a和b值的组合情况打印出来。接下来我们看下遇到的组合情况有几种:

[a=0,b=1]
[a=0,b=1]
[a=0,b=1]           

这是循环刚开始的时候打印出的a、b值的组合【a=0,b=1】,从这个结果来看是t1线程执行之后,x值变为了1,然后t2线程才开始执行,t1和t2线程内部没有发生指令重排,接着往下看:

[a=0,b=1]
[a=0,b=1]
[a=0,b=0, a=0,b=1]           

跟上次比多了一种组合【a=0,b=0】,这种组合结果说明在t1线程执行完int a = y指令后且在执行x = 1指令之前,t2线程执行了int b = x指令,t1和t2线程内部没有发生指令重排,接着往下看:

[a=0,b=0, a=0,b=1]
[a=0,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1]           

随着循环次数的增多,跟前面比又出现了一种新的组合【a=1,b=0】,出现这个组合的原因是t2线程先执行,将y的值变为了1,然后t1线程才开始执行,t1和t2线程内部没有发生指令重排,再接着往下看:

[a=0,b=0, a=1,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1]
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]           

在循环的后面阶段,又出现了一种新的组合【a=1,b=1】,我们分析一下:t1线程中的a=1,说明t2线程中的 y = 1已经执行,如果t1、t2线程没有发生指令重排,说明是t2线程执行完了后t1线程才执行,t2线程在执行y = 1前会先执行 int b = x,而t1线程还没执行x = 1这个指令,此时b的值应该为0才对,但是现在b的值为1,说明在t1和t2线程中至少有一个线程的指令发生了重排序。如果不想让指令发生重排序,一般的做法是使用内存屏障。

3.4 内存屏障

从功能类型上划分,内存屏障主要分为三种:

1、写屏障:

(1)保证写事务的强一致性。

(2)当CPU遇到写屏障时,必须强制等待存储缓存中的写事务全部处理完毕后,CPU才能继续工作。

(3)其目的是保证当前CPU的写操作,能够通知到其他CPU并响应失效ACK。

2、读屏障:

(1)保证读事务的强一致性。

(2)当CPU遇到读屏障时,必须强制处理完当前失效队列的所有无效事务。

(3)其目的是保证,读屏障之后的读指令,能够读到最新的值。

3、全能屏障:

(1)同时包含读、写屏障的功能。

从实现上来划分,分为硬件实现和JVM实现。

1、硬件层面(X86架构CPU内存屏障指令):

(1)lfence(读屏障)

(2)sfence(写屏障)

(3)mfence(全能屏障)

2、JVM级内存屏障(JSR-133):

(1)LoadLoadBarries:Load1; LoadLoad; Load2 确保Load1数据的装载先于Load2以及所有后续装载指令。

(2)StoreStoreBarries:Store1; StoreStore; Store2 确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令 的装载。

(3)LoadStoreBarries:Load1; LoadStore; Store2 确保Load1数据装载先于Store2及所有后续存储指令刷新到内存。

(4)StoreLoadBarries:Store1; StoreLoad; Load2 确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续装载指令的装载。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。

根据JMM规范需要存在这么多内存屏障,但实际情况并不需要加这么多内存屏障。

以我们常见的X86处理器为例,X86处理器不会对读-读、读-写和写-写操作做重排序,会省略掉这3种操作类型对应的内存屏障,仅会对写-读操作做重排序。

处理器 Load-Load Load-Store Store-Store Store-Load 数据转换
X86 N N N Y N

所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障。而在x86处理器中,有三种方法可以实现StoreLoad屏障的效果,分别为:

(1)mfence指令:上文提到过,这是一个全能屏障,具备lfence和sfence的能力。

(2)cpuid指令:cpuid操作码是一个面向x86架构的处理器补充指令,它的名称派生自CPU识别,作用是允许软件发现处理器的详细信息。

(3)lock指令前缀:总线锁,现在处理器一般都采用锁缓存行替代锁总线。

不同的虚拟机对volatile的实现不同。我们常用的HotSpot虚拟机对volatile的实现就是使用的lock指令前缀。接下来我们结合jdk源码看一下:

jdk8-hotspot源码地址:

https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/69087d08d473/

字节码解释执行器目录:src/share/vm/interpreter/bytecodeInterpreter.cpp

Java中volatile关键字原理深度剖析

图3-9 volatile实现源码部分截图

从上图3-9的源码截图可以看出,对volatile变量进行写操作的时候会执行OrderAccess::storeload()方法,对于这个方法不同的cpu或操作系统有不同的实现,我们看下Linux_x86版本下这个方法的实现:

文件目录:src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp

Java中volatile关键字原理深度剖析

图3-10 storeload方法实现源码部分截图

从上图3-10中可以看出,最终会执行OrderAccess::fence()方法,方法中if括号里的os::is_MP()的意思就是,操作系统是否为多核处理器(MP是Multi-Processing的简写),如果是多核处理器,对于volatile修饰的变量,都会加一个lock前缀指令。

至于Hotspot为什么要使用lock指令而不是mfence指令,按照我的理解,其实就是省事,实现起来简单。

因为lock功能过于强大,不需要有太多的考虑。而且lock指令优先锁缓存行,在性能上,lock指令也没有想象中的那么差,mfence指令更没有想象中的好。所以,使用lock是一个性价比非常高的一个选择。而且,lock也有对可见性的语义说明。

lock指令的作用:

(1)确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。

(2)禁止该指令与前面和后面的读写指令重排序。

(3)把写缓冲区的所有数据刷新到内存中。

平时开发中我们不用关注如何使用内存屏障,我们只要遵循happens-before原则,即可保证可见性,有序性。

四、happens-before原则

happens-before(先行发生)原则主要内容如下:

(1)程序次序规则(Program Order Rule):在同一个线程中,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说是程序的控制流顺序,考虑分支和循环等。

(2)管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时间上的顺序)对同一个锁的lock操作。

(3)volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时间上的顺序)对该变量的读操作。

(4)线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

(5)线程终止规则(Thread Termination Rule):线程的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

(6)线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时事件的发生。Thread.interrupted()可以检测是否有中断发生。

(7)对象终结规则(Finilizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的开始。

(8)传递性(Transitivity):如果操作A 先行发生于操作B,操作B 先行发生于操作C,那么可以得出A 先行发生于操作C。

happens-before原则让人最难以理解的是如何去理解先行发生这个词,这个词的意思并不是指代码执行的顺序,而是指一个操作执行的结果或产生的影响对另一个操作可见。比如管程锁定规则,不能理解为一个unlock操作先发生于对同一个锁的lock操作,还没lock呢怎么会unlock呢?而要这样理解,对于"同一把锁",如果在程序运行过程中"一个unlock操作先行发生于同一把锁的一个lock操作",那么"该unlock操作所产生的影响(修改共享变量的值、发送了消息、调用了方法)对于该lock操作是可见的"。

举个volatile变量规则的例子:

public class VolatileO1 {


    private static volatile boolean flag = false;


    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("t1 set flag true.");
        });


        Thread t2 = new Thread(() -> {
            while (true) {
                if (flag) {
                    System.out.println("t2 stopped.");
                    break;
                }
            }
        });
        t1.start();
        t2.start();
    }
}           

执行结果:

t1 set flag true.
t2 stopped.           

根据volatile变量规则,t1线程对flag变量的写操作先行发生于t2线程对flag变量的读操作,即:t1线程将flag变量设置为true的结果对t2线程是可见的,所以t2线程最后会输出 "t2 stopped."。

我们再稍微延伸一下,看另一个例子,这个例子中我们新增一个变量a并且用volatile修饰,flag不用volatile修饰:

public class VolatileO2 {


    private static boolean flag = false;
    private static volatile Integer a = 0;


    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            a = 1;
            System.out.println("t1 set flag true.");
        });


        Thread t2 = new Thread(() -> {
            while (true) {
                Integer b = VolatileO2.a;
                if (flag) {
                    System.out.println("t2 stopped.");
                    break;
                }
            }
        });
        t1.start();
        t2.start();
    }
}           

执行结果:

t1 set flag true.
t2 stopped.           

我们发现,即使这个例子中flag变量没有被volatile修饰,t2线程也会输出"t2 stopped.",即:t1线程执行的结果对t2线程仍然可见。这是为什么呢?这是因为线程在往主存写入volatile变量的值时也会将该线程可见的non-voaltile变量的值写回主存,当线程从主存读取voaltile变量的值时也会将该线程可见的non-volatile变量的值一同读回。在这个例子中,t1线程会将flag和a的最新值写回主存,t2线程在读取a的值时也会将flag的最新值一并读回,所以最终t1线程执行flag = true的结果对t2线程可见。

如果从happens-before原则角度分析,基于程序次序规则我们可以得出:

(1)【flag = true】先行发生于【a = 1】

(2)【Integer b = VolatileO2.a】先行发生于【if (flag)】

基于volatile变量规则我们可以得出:

(3)【a = 1】先行发生于【Integer b = VolatileO2.a】

得出上面3个公式后,我们再根据传递性可得:

(4)【flag = true】先行发生于【if (flag)】

即:t1线程执行flag = true的结果对t2线程可见。

从第二个例子又引申出一个技术名词叫“借助同步”,即,虽然变量flag没有加volatile,但是它借助了被volatile修饰的变量a的happens-before关系完成了同步操作。“借助同步”在jdk源码中也有应用,java.util.concurrent包下的FutureTask类就使用了这种技术。我们拿这个类的set和get方法分析一下,这两个方法中都会用到两个字段:state(任务运行状态),和outcome(任务运行结果),我们先看看这个类的set方法:

Java中volatile关键字原理深度剖析

在set方法中,如果if条件成立(对state进行CAS操作成功),会给outcome赋值,然后将state的值变为NORMAL。然后看看get方法:

Java中volatile关键字原理深度剖析
Java中volatile关键字原理深度剖析

在get方法中,首先会读取state字段的值,如果state == NORMAL,会返回outcome字段的值。

主线程通过submit方法将任务提交到线程池后,会返回FutureTask,主线程通过调用FutureTask的get方法获取这个任务的返回值,这个时候的情况就是:outcome的值是由线程池里的线程写入,outcome的读取是由主线程读取。那是怎么保证outcome在主线程的可见性的呢?对outcome字段加volatile?我们来看看这个类的字段定义:

Java中volatile关键字原理深度剖析

从上图可以看出outcome字段并没有被volatile修饰,而state字段是被volatile修饰的。所以从FutureTask类的set和get方法分析中我们可以得出结论:outcome字段的可见性,是借助于被volatile修饰的state字段的happens-before关系实现的。这就是“借助同步”技术在源码中的一种体现。

五、DCL单例到底需不需要使用volatile?

先看下DCL单例的代码。

public class Singleton {


    private static Singleton instance = null;


    private Singleton() {
    }


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

讨论这个问题之前,我们先看看创建一个对象的过程是什么样的,假设有如下代码:

public class Obj {
    int a = 3;
    public static void main(String[] args) {
        Obj obj = new Obj();
    }
}           

Obj obj = new Obj()这行代码对应的字节码如下:

0 new #3 <Obj>     //开辟内存空间,此时a=0
3 dup
4 invokespecial #4 <Obj.<init>>   //调用构造函数,此时a=3
7 astore_1          //将obj引用指向这个内存地址,此时obj != null
8 return           

正常情况下,这几个指令会按顺序执行,但是指令invokespecial和astore_1不存在依赖关系,会有可能发生重排序,导致创建对象的指令变成如下顺序:

0 new #3 <Obj>     //开辟内存空间,此时a=0
3 dup
7 astore_1          //将obj引用指向这个内存地址,此时obj != null
4 invokespecial #4 <Obj.<init>>   //调用构造函数,此时a=3
8 return           

如果按这个顺序去执行,当执行完astore_1指令还未执行invokespecial指令时,此时的obj != null,但a=0。这其实就是对象的半初始化。

所以在DCL代码中,instance = new Singleton()这行代码有可能发生重排序,在多线程环境中,会有其他线程拿到半初始化对象的问题,所以我们才会讨论要不要加volatile,但是在这段代码中还涉及到一个关键字synchronized,如果在synchronized同步块内部不会发生指令重排,并且在线程执行完同步块后会将变量的最新值写回主存,是不是就不需要volatile了?接下来带着这个疑问我们简要分析一下synchronized的可见性及块内部是否会发生指令重排。

5.1 synchronized 的可见性

我们用两个例子分析synchronized的可见性。

第一个例子:

public class SyncTest_01 {


    private static boolean flag = true;


    public static void main(String[] args) throws InterruptedException{
        Thread t1 = new Thread(() -> {
            System.out.println("t1线程开始运行。。。");
            while (flag) {
                synchronized (SyncTest_01.class){
                   // 同步代码快在执行
                }
            }
            System.out.println("t1线程结束");
        });


        t1.start();
        Thread.sleep(1000);
        flag = false;
        System.out.println("main线程将flag的值改为false");
    }
}           

运行结果:

t1线程开始运行。。。
main线程将flag的值改为false
t1线程结束           

在这个例子中,t1线程while循环里有一个同步块,这么写的目的是每次循环都重新获取锁,主线程会在t1线程运行1秒后将flag的值更改为false,根据运行结果我们得知,t1线程拿到了主线程修改flag变量值的结果。

接下来看第二个例子:

public class SyncTest_02 {


    private static boolean flag = true;


    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("t1线程开始运行。。。");
            synchronized (SyncTest_02.class) {
                while (flag) {
                    // 同步代码快在执行
                }
            }
            System.out.println("t1线程结束");
        });


        t1.start();
        Thread.sleep(1000);
        flag = false;
        System.out.println("main线程将flag的值改为false");
    }
}           

运行结果:

t1线程开始运行。。。
main线程将flag的值改为false           

第二个例子出现了死循环,即t1线程没有拿到主线程修改flag变量值的结果。两个例子的区别是一个是不停的获取锁,一个是只获取一次锁。这就说明线程在获取锁的时候会从主存中读取变量的最新值。

其实,synchronized关键字的可见性遵循happens-before的管程锁定规则:一个unlock操作先行发生于后面(时间上的顺序)对同一个锁的lock操作;通俗一点讲就是:synchronized在释放锁之前,会将线程更改后的变量的值刷新到主存,在获得锁之后,会从主存拷贝变量的最新副本到工作内存。

5.2 synchronized 块内部是否会发生指令重排

我们把前面证明重排序的例子改造一下,将两个线程中的两条指令都放入同步快中,为了避免锁竞争,每个线程都获取不一样的锁。

public class SyncReorderingTest {


    static int x = 0, y = 0;


    public static void main(String[] args) throws InterruptedException {
        Set<String> resultSet = new HashSet<>();
        Map<String, Integer> resultMap = new HashMap<>();
        for (int i = 0; i < 1000000; i++) {
            x = 0;
            y = 0;
            resultMap.clear();
            Thread t1 = new Thread(() -> {
                synchronized (new Object()) {
                    int a = y;
                    x = 1;
                    resultMap.put("a", a);
                }
            });
            Thread t2 = new Thread(() -> {
                synchronized (new Object()) {
                    int b = x;
                    y = 1;
                    resultMap.put("b", b);
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            resultSet.add("a=" + resultMap.get("a") + "," + "b=" + resultMap.get("b"));
            System.out.println(resultSet);
        }
    }
}           

运行结果:

[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]           

如上所示,最后的运行结果还是出现了a=1, b=1这种发生了重排序的情况,所以synchronized并不能禁止同步块内部指令发生重排序。

综上所述,synchronized可以保证多线程环境中共享变量的可见性,但是块内的代码还是会发生指令重排序。所以,为了防止其他线程拿到半初始化的对象,DCL单例中的变量仍需要使用volatile修饰。

六、volatile 和 synchronized 关键字的区别

volatile和synchronized都是Java内存模型定义的关键字,用于提供多线程编程中的同步机制。二者虽然都可以保证对共享变量的操作具有可见性和有序性,但是它们的作用和使用场景略有不同,这里顺便做一下简单介绍。

1、volatile关键字和synchronized关键字的区别

(1)volatile关键字主要用于修饰变量,用来保证变量在多线程之间的可见性。

(2)synchronized关键字主要用于修饰代码块和方法,用来保证代码块和方法在多线程之间的同步性。

(3)volatile关键字只保证了变量的可见性,但不保证原子性,也就是说,对于volatile变量的读写操作不能保证线程安全。

(4)synchronized关键字保证了代码块和方法在多线程之间的同步性,同时也保证了数据的可见性和原子性。

2、volatile关键字的使用场景和注意事项

(1)volatile关键字适用于对变量的读操作比写操作频繁的场景,例如计数器、状态标志等。

(2)volatile关键字不能保证原子性,如果需要保证原子性,可以使用Atomic类或synchronized关键字。

(3)volatile关键字不能用于代码块和方法的同步操作,只能用于变量的同步操作。

3、synchronized关键字的使用场景和注意事项

(1)synchronized关键字适用于对共享数据的读写操作,可以保证数据的原子性、可见性和有序性。

(2)synchronized关键字可以用于修饰代码块和方法,对于需要保证原子性的代码块和方法,应该使用synchronized关键字进行同步。

(3)synchronized关键字会影响程序的性能,因为它会引起线程的阻塞,应该尽可能地减少synchronized关键字的使用。

4、volatile和synchronized关键字的比较和选择

(1)volatile关键字和synchronized关键字都能保证线程的安全性和数据的正确性,但针对不同的场景使用效果不同。

(2)如果需要保证变量的可见性,可以使用volatile关键字;如果需要保证代码块和方法的原子性,可以使用synchronized关键字。

(3)在高并发环境下,synchronized关键字的性能会受到一定的影响,因此应该尽量减少synchronized关键字的使用,避免对程序性能的影响。

总结一下,为了提高计算机存储器的访问速度和效率,现代的CPU都会引入多级缓存,为了提高程序运行的效率,CPU又会对指令进行重排序,这就引发了程序中数据可见性和指令重排序问题。为了解决这两个问题,在java中定义了volatile关键字,而在HotSpot虚拟机中,volatile关键字底层是通过加入lock指令来解决可见性和指令重排序的问题。

至此,这边文章到此结束,如有不正确的地方,还请批评指正。

作者:刘晓建

来源:微信公众号:新东方技术

出处:https://mp.weixin.qq.com/s/q6wthU7W0VfZMpJNbIw7CQ

继续阅读