原子性、可见性、有序性 攻略

460 阅读44分钟

攻略大全

0. 攻略精华

0.1 CPU物理缓存结构

由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容量也越小。

image.png

0.2 并发编程的三大问题

0.2.1 原子性问题

所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。

0.2.2 可见性问题

一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。

0.2.3 有序性问题

所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。

0.3 硬件层的MESI协议原理

为了解决内存的可见性问题,CPU主要提供了两种解决办法:总线锁和缓存锁。

1.总线锁

总线锁的缺陷是:某一个CPU访问主存时,总线锁把CPU和主存的通信给锁住了,其他CPU不能操作其他主存地址的数据,使得效率低下,开销较大。

总线锁的粒度太大了,最好的方法就是控制锁的保护粒度,只需要保证被多个CPU缓存的同一份数据一致即可。所以引入了缓存锁(如缓存一致性机制),后来的CPU都提供了缓存一致性机制,Intel 486之后的处理器就提供了这种优化。

2.缓存锁

相比总线锁,缓存锁降低了锁的粒度。为了达到数据访问的一致,需要各个CPU在访问高速缓存时遵循一些协议,在存取数据时根据协议来操作,常见的协议有MSI、MESI、MOSI等。最常见的就是MESI协议。

就整体而言,缓存一致性机制就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从主存中重新读取。

1.M:被修改(Modified)

简单来说,处于Modified状态的缓存行数据只在本CPU中有缓存,且其数据与内存中的数据不一致,数据被修改过。

2.E:独享的(Exclusive)

简单来说,处于Exclusive状态的缓存行数据只在本CPU中有缓存,且其数据与内存中一致,没有被修改过。

3.S:共享的(Shared)

简单来说,处于Shared状态的缓存行的数据在多个CPU中都有缓存,且与主存一致。

4.I:无效的(Invalid)

简单来说,该缓存行是无效的,可能有其他CPU修改了该缓存行。

1.4 volatile

volatile的原理

一个共享变量var加了volatile关键字后,在汇编指令中,操作var之前多出一个lock前缀指令lock addl,该lock前缀指令有三个功能。

(1)将当前CPU缓存行的数据立即写回系统内存

(2)lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效

(3)lock前缀指令禁止指令重排

volatile不具备原子性

对于复合操作,volatile变量无法保障其原子性,如果要保证复合操作的原子性,就需要使用锁。并且,在高并发场景下,volatile变量一定需要使用Java的显式锁结合使用。

1. 粘贴攻略

原子性、可见性、有序性是并发编程所面临的三大问题。

Java通过CAS操作解决了并发编程中的原子性问题。

1.1 CPU物理缓存结构

由于CPU的运算速度比主存(物理内存)的存取速度快很多,为了提高处理速度,现代CPU不直接和主存进行通信,而是在CPU和主存之间设计了多层的Cache(高速缓存),越靠近CPU的高速缓存越快,容量也越小。

按照数据读取顺序和与CPU内核结合的紧密程度,CPU高速缓存有L1和L2高速缓存(即一级高速缓存和二级缓存高速),部分高端CPU还具有L3高速缓存(即三级高速缓存)。

每一级高速缓存中所存储的数据都是下一级高速缓存的一部分,越靠近CPU的高速缓存读取越快,容量也越小。

所以L1高速缓存容量很小,但存取速度最快,并且紧靠着使用它的CPU内核。L2容量大一些,存取速度也慢一些,并且仍然只能被一个单独的CPU核使用。

L3在现代多核CPU中更普遍,容量更大、读取速度更慢些,能被同一个CPU芯片板上的所有CPU内核共享。

最后,系统还拥有一块主存(即主内存),由系统中的所有CPU共享。

拥有L3高速缓存的CPU,CPU存取数据的命中率可达95%,也就是说只有不到5%的数据需要从主存中去存取。

image.png

CPU内核读取数据时,先从L1高速缓存中读取,如果没有命中,再到L2、L3高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。

高速缓存大大缩小了高速CPU内核与低速主存之间的速度差距。以三层高速缓存架构为例:

  • L1高速缓存最接近CPU,容量最小(如32KB、64KB等)、存取速度最快,每个核上都有一个L1高速缓存。

  • L2高速缓存容量更大(如256KB)、速度低些,在一般情况下,每个内核上都有一个独立的L2高速缓存。

  • L3高速缓存最接近主存,容量最大(如12MB)、速度最低,由在同一个CPU芯片板上的不同CPU内核共享。

CPU通过高速缓存进行数据读取有以下优势:

(1)写缓冲区可以保证指令流水线持续运行,可以避免由于CPU停顿下来等待向内存写入数据而产生的延迟。

(2)通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。

1.2 并发编程的三大问题

1.2.1 原子性问题

所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。

++操作示例: image.png

反汇编: image.png image.png

解释一下上面的4个关键性的汇编指令:

① 获取当前sum变量的值,并且放入栈顶。

② 将常量1放入栈顶。

③ 将当前栈顶中的两个值(sum的值和1)相加,并把结果放入栈顶。

④ 把栈顶的结果再赋值给sum变量。

通过以上4个关键性的汇编指令可以看出,在汇编代码的层面,++操作实质上是4个操作。这4个操作之间是可以发生线程切换的,或者说是可以被其他线程中断的。所以,++操作不是原子操作,在并行场景会发生原子性问题。

1.2.2 可见性问题

一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。

JMM(Java Memory Model,Java内存模型)规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。

如果两个线程同时操作一个共享变量,就可能发生可见性问题。举一个例子:

(1)主存中有变量sum,初始值为0。

(2)线程A计划将sum加1,先将 sum = 0 复制到自己的私有内存中,然后更新sum的值。线程A操作完成之后其私有内存中sum的值为1,然而线程A将更新后的sum值回刷到主存的时间是不固定的。

(3)在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1。

线程B没有将sum变成2的原因是:线程A的修改还在其工作内存中,对线程B不可见,因为线程A的修改还没有刷入主存。这就发生了典型的内存可见性问题。

要想解决多线程的内存可见性问题,所有线程都必须将共享变量刷新到主存,一种简单的方案是:使用Java提供的关键字volatile修饰共享变量。

在Java中,所有的局部变量、方法定义参数都不会在线程之间共享,所以也就不会有内存可见性问题。所有的Object实例、Class实例和数组元素都存储在JVM堆内存中,堆内存在线程之间共享,所以存在可见性问题。

1.2.3 有序性问题

所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。

一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。

重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,因此会导致工作线程似乎表现出了随机行为。

指令重排序不会影响单个线程的执行,但是会影响多个线程并发执行的正确性。

总之,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有得到保证,就有可能会导致程序运行不正确。

1.3 硬件层的MESI协议原理

为了缓解内存速度和CPU内核速度差的问题,现代计算机会在CPU上增加高速缓存,每个CPU内核都只有自己的一级、二级高速缓存,CPU芯片板上的CPU内核之间共享一个三级高速缓存。

每个CPU的处理过程为:先将计算需要用到的数据缓存在CPU的高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写回高速缓存中。在整个运算过程完成后,再把高速缓存中的数据同步到主存。

由于每个线程可能会运行在不同的CPU内核中,因此每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个CPU内核中,在不同CPU内核中运行的线程看到同一个变量的缓存值就会不一样,就可能发生内存的可见性问题。

硬件层的MESI协议是一种用于解决内存的可见性问题的手段。

1.3.1 总线锁和缓存锁

为了解决内存的可见性问题,CPU主要提供了两种解决办法:总线锁和缓存锁。

1.总线锁

操作系统提供了总线锁机制。前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号,通过地址总线发送地址信号指定其要访问的部件,通过数据总线实现双向传输。

在CPU内核1要执行i++操作的时候,将在总线上发出一个LOCK#信号锁住缓存(具体来说是变量所在的缓存行),这样其他CPU内核就不能操作缓存了,从而阻塞其他CPU内核,使CPU内核1可以独享此共享内存。

每当CPU内核访问L3中的数据时,都会通过线程总线来进行读取。总线锁的意思是在线程总线中加入一把锁,例如,当不同的CPU内核访问同一个缓存行时,只允许一个CPU内核进行读取,如图所示,a、b存储于L3高速缓存中,当CPU内核1对a进行访问时,会在总线上发送一个LOCK#信号,CPU内核2想对b进行查询,但是总线被锁住,得等CPU内核1访问完,CPU内核2才能访问b。

image.png

在多CPU的系统中,当其中一个CPU要对共享主存进行操作时,在总线上发出一个LOCK#信号,这个信号使得其他CPU无法通过总线来访问共享主存中的数据,总线锁把CPU和主存之间的通信锁住了,这使得锁定期间,其他CPU不能操作其他主存地址的数据,总线锁的开销比较大,这种机制显然是不合适的。

总线锁的缺陷是:某一个CPU访问主存时,总线锁把CPU和主存的通信给锁住了,其他CPU不能操作其他主存地址的数据,使得效率低下,开销较大。

总线锁的粒度太大了,最好的方法就是控制锁的保护粒度,只需要保证被多个CPU缓存的同一份数据一致即可。所以引入了缓存锁(如缓存一致性机制),后来的CPU都提供了缓存一致性机制,Intel 486之后的处理器就提供了这种优化。

2.缓存锁

相比总线锁,缓存锁降低了锁的粒度。为了达到数据访问的一致,需要各个CPU在访问高速缓存时遵循一些协议,在存取数据时根据协议来操作,常见的协议有MSI、MESI、MOSI等。最常见的就是MESI协议。

就整体而言,缓存一致性机制就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从主存中重新读取。

用MESI描述的原理如图所示。

image.png

为了提高处理速度,CPU不直接和主存进行通信,而是先将系统主存的数据读到内部高速缓存(L1、L2或其他)后再进行操作,但操作完不知道何时会写入内存。如果对声明了volatile的变量进行写操作,JVM就会向CPU发送一条带lock前缀的指令,将这个变量所在缓存行的数据写回系统主存。

但是,即使写回系统主存,如果其他CPU高速缓存中的值还是旧的,再执行计算操作也会有问题。所以,在多CPU的系统中,为了保证各个CPU的高速缓存中数据的一致性,会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当CPU发现自己缓存行对应的主存地址被修改时,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据执行修改操作时,会重新从系统主存中把数据读到CPU的高速缓存中。

因为高速缓存的内容是部分主存内容的副本,所以应该与主存内容保持一致。而CPU对高速缓存副本如何与主存内容保持一致有几种写入模式供选择,主要的写入模式有以下两种:

(1)Write-Through(直写)模式:在数据更新时,同时写入低一级的高速缓存和主存。此模式的优点是操作简单,因为所有的数据都会更新到主存,所以其他CPU读取主存时都是最新值。此模式的缺点是数据写入速度较慢,因为数据修改之后需要同时写入低一级的高速缓存和主存。

(2)Write-Back(回写)模式:数据的更新并不会立即反映到主存,而是只写入高速缓存。只在数据被替换出高速缓存或者变成共享(S)状态时,如果发现数据有变动,才会将最新的数据更新到主存。此模式的优点是数据写入速度快,因为发生数据变动时不需要写入主存,所以这种模式占用总线少,大多数CPU的高速缓存采用这种模式。此模式的缺点为:实现一致性协议比较复杂,因为最新值可能存放在私有高速缓存中,而不是存放在共享的高速缓存或者主存中。

主要的缓存一致性协议有MSI协议、MESI协议等。

1.3.2 MSI协议

多核CPU都有自己的专有高速缓存(一般为L1、L2),以及同一个CPU芯片板上不同CPU内核之间共享的高速缓存(一般为L3)。不同CPU内核的高速缓存中难免会加载同样的数据,为了保证数据的一致性,需要用到缓存一致性协议。

缓存一致性协议的基础版本为MSI协议,也叫作写入失效协议。如果同时有多个CPU要写入,总线会进行串行化,同一时刻只会有一个CPU获得总线的访问权。

image.png

1.3.3 MESI协议及RFO请求

目前主流的缓存一致性协议为MESI写入失效协议,而MESI是MSI协议的扩展。在MESI协议中,每个缓存行(Cache Line)有4种状态,即M、E、S和I(全名是Modified、Exclusive、Shared和Invalid),可用2 bit表示。

缓存行是高速缓存操作的基本单位,在Intel的CPU上一般是64字节。

1.M:被修改(Modified)

该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,是被修改过的(Dirty),即与主存中的数据不一致,且没有更新到内存中。该缓存行中的内存需要在未来的某个时间点(允许其他CPU读取主存中相应的数据之前)写回(Write Back)主存。当被写回主存之后,该缓存行的状态会变成独享状态。

简单来说,处于Modified状态的缓存行数据只在本CPU中有缓存,且其数据与内存中的数据不一致,数据被修改过。

2.E:独享的(Exclusive)

该缓存行的数据只在本CPU的私有高速缓存中进行了缓存,而其他CPU中没有,缓存行的数据是未被修改过的(Clean),并且与主存中的数据一致。该状态下的缓存行在任何时刻被其他CPU读取之后,其状态将变成共享状态。在本CPU修改了缓存行中的数据后,该缓存行的状态可以变成Modified状态。

简单来说,处于Exclusive状态的缓存行数据只在本CPU中有缓存,且其数据与内存中一致,没有被修改过。

3.S:共享的(Shared)

该缓存行的数据可能在本CPU以及其他CPU的私有高速缓存中进行了缓存,并且各CPU私有高速缓存中的数据与主存数据一致(Clean),当有一个CPU修改该缓存行时,其他CPU中该缓存行将被作废,变成无效状态。

简单来说,处于Shared状态的缓存行的数据在多个CPU中都有缓存,且与主存一致。

4.I:无效的(Invalid)

该缓存行是无效的,可能有其他CPU修改了该缓存行。

(1)初始阶段:开始时,缓存行没有加载任何数据,所以它处于“I状态”。

(2)本地写(Local Write)阶段:如果CPU内核写数据到处于“I状态”的缓存行,缓存行的状态就变成“M状态”。

(3)本地读(Local Read)阶段:如果本地CPU读取处于“I状态”的缓存行,很明显此缓存没有数据给它。此时分两种情况:①其他CPU的高速缓存中也没有此行数据,那么从内存加载数据到此缓存行后,将它设成“E状态”,表示只有“我”有此行数据,其他CPU都没有;②其他CPU的高速缓存有此行数据,就将此缓存行的状态设为“S状态”(注意:处于“M状态”的缓存行,再由本地CPU写入/读出,状态是不会改变的)。

(4)远程读(Remote Read)阶段:假设我们有两个CPU c1和c2,如果c2需要读c1的缓存行内容,c1需要把它的缓存行内容通过主存控制器(MemoryController)发送给c2,c2接收到后将相应的缓存行状态设为“S状态”。在设置之前,主存要从总线上得到这份数据并保存。

(5)远程写(Remote Write)阶段:其实确切地说不是远程写,而是c2得到c1的数据后,不是为了读,而是为了写。也算是本地写,只是c1也拥有这份数据的拷贝,这该怎么办呢?c2将发出一个RFO(Request For Owner)请求,说明它需要拥有这行数据的权限,其他CPU的相应缓存行设为“I状态”,除了它之外,谁也不能动这行数据。这就保证了数据的安全,但处理RFO请求以及设置“I状态”的过程将给写操作带来很大的性能消耗。

image.png

image.png

1.3.4 volatile的原理

一个共享变量var加了volatile关键字后,在汇编指令中,操作var之前多出一个lock前缀指令lock addl,该lock前缀指令有三个功能。

(1)将当前CPU缓存行的数据立即写回系统内存

在对volatile修饰的共享变量进行写操作时,其汇编指令前用lock前缀修饰。lock前缀指令使得在执行指令期间,CPU可以独占共享内存(即主存)。

对共享内存的独占,老的CPU(如Intel 486)通过总线锁方式实现。由于总线锁开销比较大,因此新版CPU(如IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个CPU同时修改共享内存的数据。

(2)lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效

写回操作时要经过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改时,就会将当前CPU的缓存行设置为无效状态,当CPU要对这个值进行修改的时候,会强制重新从系统内存中把数据读到CPU缓存。

(3)lock前缀指令禁止指令重排

lock前缀指令的最后一个作用是作为内存屏障(Memory Barrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。

不同CPU产品对MESI协议的实现方案不同,具体的汇编指令也不一定相同。MESI协议仅仅是一种基于过期机制的高速缓存一致性保障协议,作为Java工程师,只需要大概了解即可,不需要深入了解该协议,更不需要了解各个CPU产品中对应的硬件指令。

总体来说,通过汇编指令可以看出,volatile关键字的底层原理是非常复杂的,涉及MESI协议、内存屏障等硬件层面的知识和技术。

1.4 有序性与内存屏障

由于CPU技术不断发展,为了重复释放硬件的高性能,编译器、CPU会优化待执行的指令序列,包括调整某些指令的顺序执行。优化的结果,指令执行顺序会与代码顺序略有不同,可能会导致代码执行出现有序性问题。

内存屏障又称内存栅栏(Memory Fences),是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。

4.4.1 重排序

为了提高性能,编译器和CPU常常会对指令进行重排序。重排序主要分为两类:编译器重排序和CPU重排序,具体如图所示。

image.png

1.编译器重排序

编译器重排序指的是在代码编译阶段进行指令重排,不改变程序执行结果的情况下,为了提升效率,编译器对指令进行乱序(Out-of-Order)的编译。

例如,在代码中,A操作需要获取其他资源而进入等待的状态,而A操作后面的代码跟A操作没有数据依赖关系,如果编译器一直等待A操作完成再往下执行的话,效率要慢得多,所以可以先编译后面的代码,这样的乱序可以提升编译速度。

编译器为什么要重排序(Re-Order)呢?它的目的为:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与CPU乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

2.CPU重排序

流水线(Pipeline)和乱序执行(Out-of-Order Execution)是现代CPU基本都具有的特性。机器指令在流水线中经历取指令、译码、执行、访存、写回等操作,为了CPU的执行效率,流水线都是并行处理的。在不影响语义的情况下,处理次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,只要满足As-if-Serial规则即可。显然,这里的不影响语义依旧只能保证指令间的显式因果关系,无法保证隐式因果关系,即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。

所谓“乱序”,仅仅是被称为“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在“数据依赖”,就可以对这两个指令乱序。

CPU重排序包括两类:指令级重排序和内存系统重排序。

(1)指令级重排序。在不影响程序执行结果的情况下,CPU内核采用ILP(Instruction-Level Parallelism,指令级并行运算)技术来将多条指令重叠执行,主要是为了提升效率。如果指令之间不存在数据依赖性,CPU就可以改变语句的对应机器指令的执行顺序,叫作指令级重排序。

(2)内存系统重排序:对于现代的CPU来说,在CPU内核和主存之间都具备一个高速缓存,高速缓存的作用主要是减少CPU内核和主存的交互(CPU内核的处理速度要快得多),在CPU内核进行读操作时,如果缓存没有的话就从主存取,而对于写操作都是先写在缓存中,最后再一次性写入主存,原因是减少跟主存交互时CPU内核的短暂卡顿,从而提升性能。但是,内存系统重排序可能会导致一个问题——数据不一致。

内存系统重排序和指令级重排序不同,内存系统重排序为伪重排序,也就是说只是看起来像在乱序执行而已。

1.4.2 As-if-Serial规则

As-if-Serial规则的具体内容为:无论如何重排序,都必须保证代码在单线程下运行正确。

为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变执行结果。但是,如果指令之间不存在数据依赖关系,这些指令可能被编译器和CPU重排序。

JIT是Just In Time的缩写,也就是“即时编译器”。JVM读入“.class”文件的字节码后,默认情况下是解释执行的。但是对于运行频率很高(如大于5000次)的字节码,JVM采用了JIT技术,将直接编译为机器指令,以提高性能。

虽然编译器和CPU遵守了As-if-Serial规则,无论如何,也只能在单CPU执行的情况下保证结果正确。在多核CPU并发执行的场景下,由于CPU的一个内核无法清晰分辨其他内核上指令序列中的数据依赖关系,因此可能出现乱序执行,从而导致程序运行结果错误。

所以,As-if-Serial规则只能保障单内核指令重排序之后的执行结果正确,不能保障多内核以及跨CPU指令重排序之后的执行结果正确。

1.4.3 硬件层面的内存屏障

多核情况下,所有的CPU操作都会涉及缓存一致性协议(MESI协议)校验,该协议用于保障内存可见性。但是,缓存一致性协议仅仅保障内存弱可见(高速缓存失效),没有保障共享变量的强可见,而且缓存一致性协议更不能禁止CPU重排序,也就是不能确保跨CPU指令的有序执行。

如何保障跨CPU指令重排序之后的程序结果正确呢?需要用到内存屏障。

1.硬件层的内存屏障定义

内存屏障又称内存栅栏,是让一个CPU高速缓存的内存状态对其他CPU内核可见的一项技术,也是一项保障跨CPU内核有序执行指令的技术。

硬件层常用的内存屏障分为三种:读屏障(Load Barrier)、写屏障(StoreBarrier)和全屏障(Full Barrier)。

(1)读屏障

读屏障让高速缓存中相应的数据失效。在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据。并且,读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。

读屏障对应着X86处理器上的lfence指令,将强制所有在该指令之后的读操作都在lfence指令执行之后被执行,并且强制本地高速缓冲区的值全部失效,以便从主存中重新读取共享变量的值。

读屏障既使得当前CPU内核对共享变量的更改对所有CPU内核可见,又阻止了一些可能导致读取无效数据的指令重排。

(2)写屏障

在指令后插入写屏障指令能让高速缓存中的最新数据更新到主存,让其他线程可见。并且,写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。

写屏障对应X86处理器上的sfence指令,sfence指令会保证所有写操作都在该指令执行之前被完成,并把高速缓冲区的数据都刷新到主存中,使得当前CPU对共享变量的更改对所有CPU可见。

(3)全屏障

全屏障是一种全能型的屏障,具备读屏障和写屏障的能力。Full Barrier又称为StoreLoad Barriers,对应X86处理器上的mfence指令。

在X86处理器平台上mfence指令综合了sfence指令与lfence指令的作用。X86处理器强制所有在mfence之前的store/load指令都在mfence执行之前被执行;所有在mfence之后的store/load指令都在该mfence执行之后被执行。简单来说,X86处理器禁止对mfence指令前后的store/load指令进行重排序。

X86处理器上的lock前缀指令也具有内存全屏障的功能。

2.硬件层的内存屏障的作用

(1)阻止屏障两侧的指令重排序

编译器和CPU可能为了使性能得到优化而对指令重排序,但是插入一个硬件层的内存屏障相当于告诉CPU和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行。

(2)强制让高速缓存的数据失效

硬件层的内存屏障强制把高速缓存中的最新数据写回主存,让高速缓存中相应的脏数据失效。一旦完成写入,任何访问这个变量的线程将会得到最新的值。

3.内存屏障的使用示例

image.png

ReorderDemo3并发运行之后,控制台所输出的x值可能是0或8。为什么x可能会输出0呢?主要原因是:update()和show()方法可能在两个CPU内核并发执行,语句①和语句②如果发生了重排序,那么show()方法输出的x就可能为0。如果输出的x结果是0,显然不是程序的正常结果。

如何确保ReorderDemo3的并发运行结果正确呢?可以通过内存屏障进行保障。Java语言没有办法直接使用硬件层的内存屏障,只能使用含有JMM内存屏障语义的Java关键字,这类关键字的典型为volatile。

image.png

修改后的ReorderDemo3代码使用volatile关键字对成员变量x进行修饰,volatile含有JMM全屏障的语义,要求JVM编译器在语句①的后面插入全屏障指令。该全屏障确保x的最新值对所有的后序操作是可见的(含跨CPU场景),并且禁止编译器和处理器对语句①和语句②进行重排序。

由于不同的物理CPU硬件所提供的内存屏障指令的差异非常大,因此JMM定义了自己的一套相对独立的内存屏障指令,用于屏蔽不同硬件的差异性。很多Java关键字(如volatile)在语义中包含JMM内存屏障指令,在不同的硬件平台上,这些JMM内存屏障指令会要求JVM为不同的平台生成相应的硬件层的内存屏障指令。

1.5 JMM详解

JMM(Java Memory Model,Java内存模型)并不像JVM内存结构一样是真实存在的运行实体,更多体现为一种规范和规则。

1.5.1 什么是Java内存模型

JMM最初由JSR-133(Java Memory Model and Thread Specification)文档描述,JMM定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。

JMM的另一大价值在于能屏蔽各种硬件和操作系统的访问差异,保证Java程序在各种平台下对内存的访问最终都是一致的。

Java内存模型规定所有的变量都存储在主存中,JMM的主存类似于物理内存,但有区别,还能包含部分共享缓存。每个Java线程都有自己的工作内存(类似于CPU高速缓存,但也有区别)。

Java内存模型定义的两个概念:

(1)主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能会发现线程安全问题。

(2)工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器、相关Native方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

Java内存模型的规定如下:(1)所有变量存储在主存中。(2)每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。(3)不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主存来传递。

JMM将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本。因此,JMM模型也需要解决代码重排序和缓存可见性问题。JMM提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。JMM提供的方案包括大家都很熟悉的volatile、synchronized、final等。JMM定义了一些内存操作的抽象指令集,然后将这些抽象指令包含到Java的volatile、synchronized等关键字的语义中,并要求JVM在实现这些关键字时必须具备其包含的JMM抽象指令的能力。

image.png

1.5.2 JMM与JVM物理内存的区别

JMM(Java内存模型)看上去和JVM(Java内存结构)差不多,很多人会误以为两者是一回事,这也就导致面试过程中经常答非所问。

JMM属于语言级别的内存模型,它确保了在不同的编译器和不同的CPU平台上为Java程序员提供一致的内存可见性保证和指令并发执行的有序性。

以Java为例,一个i++方法编译成字节码后,在JVM中是分成以下三个步骤运行的:

(1)从主存中复制i的值并复制到CPU的工作内存中。

(2)CPU取工作内存中的值,然后执行i++操作,完成后刷新到工作内存。

(3)将工作内存中的值更新到主存。

当多个线程同时访问该共享变量i时,每个线程都会将变量i复制到工作内存中进行修改,如果线程A读取变量i的值时,线程B正在修改i的值,问题就来了:线程B对变量i的修改对线程A而言就是不可见的。这就是多线程并发访问共享变量所造成的结果不一致问题,该问题属于JMM需要解决的问题。

JMM属于概念和规范维度的模型,是一个参考性质的模型。

JVM模型定义了一个指令集、一个虚拟计算机架构和一个执行模型。具体的JVM实现需要遵循JVM的模型,它能够运行根据JVM模型指令集编写的代码,就像真机可以运行机器代码一样。虽然JVM也是一个概念和规范维度的模型,但是大家常常将JVM理解为实体的、实现维度的虚拟机,通常是指HotSpot VM。

HotSpot VM是JVM模型的一个开源实现,最初由Sun开发,现在由Oracle拥有。

在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域的结构,如图所示。

image.png

几个需要特别注意的JVM知识点:

(1)JVM模型定义了Java虚拟机规范,但是不同的JVM虚拟机的实现各不相同,一般会遵守规范。

(2)JVM模型定义中定义的方法区只是一种概念上的区域,并说明了其应该具有什么功能。但是并没有规定这个区域到底应该处于何处。所以,对于不同的JVM实现来说,是有一定的自由度的。不同版本的方法区所处的位置不同,方法区并不是绝对意义上的物理区域。在某些版本的JVM实现中,方法区其实是在堆中实现的。

(3)运行时常量池用于存放编译期生成的各种字面量和符号应用。但是,Java语言并不要求常量只有在编译期才能产生。比如在运行期,String.intern()也会把新的常量放入池中。

(4)除了以上介绍的JVM运行时内存外,还有一块内存区域可供使用,那就是直接内存。Java虚拟机规范并没有定义这块内存区域,所以它并不由JVM管理,是利用本地方法库直接在堆外申请的内存区域。

(5)堆和栈的数据划分也不是绝对的,如HotSpot的JIT会针对对象分配进行相应的优化。

对于硬件内存来说只有寄存器、缓存内存、主存的概念,并没有工作内存(线程私有数据区域)和主存(堆内存)之分。也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,无论是工作内存的数据还是主存的数据,对于计算机硬件来说都会存储在计算机主存中,当然也有可能存储到CPU高速缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

image.png

1.5.3 JMM的8个操作

JMM定义了一套自己的主存与工作内存之间的交互协议,即一个变量如何从主存拷贝到工作内存,又如何从工作内存写入主存,该协议包含8种操作,并且要求JVM具体实现必须保证其中每一种操作都是原子的、不可再分的。

image.png image.png

JMM的规范细节是JVM开发人员需要掌握的内容,对于普通的Java应用工程师、应用架构师来说,只需要了解其基本的原理即可。

1.5.4 JMM如何解决有序性问题

JMM如何解决顺序一致性问题?JMM提供了自己的内存屏障指令,要求JVM编译器实现这些指令,禁止特定类型的编译器和CPU重排序(不是所有的编译器重排序都要禁止)。

1.JMM内存屏障

由于不同CPU硬件实现内存屏障的方式不同,JMM屏蔽了这种底层CPU硬件平台的差异,定义了不对应任何CPU的JMM逻辑层内存屏障,由JVM在不同的硬件平台生成对应的内存屏障机器码。

2.主要处理器对JMM四个内存屏障的支持

image.png

1.5.5 volatile语义中的内存屏障

在Java代码中,volatile关键字主要有两层语义:

  • 不同线程对volatile变量的值具有内存可见性,即一个线程修改了某个volatile变量的值,该值对其他线程立即可见。

  • 禁止进行指令重排序。

volatile语义中的有序性是通过内存屏障指令来确保的。为了实现volatile关键字语义的有序性,JVM编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

JMM建议JVM采取保守策略对重排序进行严格禁止。

不同的处理器有不同“松紧度”的处理器内存模型,只要不改变volatile读写操作的内存语义,不同JVM编译器可以根据具体情况省略不必要的JMM屏障。以X86处理器为例,该平台的JVM实现仅仅在volatile写操作后面插入一个StoreLoad屏障,其他的JMM屏障都会被省略。由于StoreLoad屏障的开销大,因此在X86处理器中,volatile写操作比volatile读操作的开销会大很多。

1.6 Happens-Before规则

1.6.1 Happens-Before规则介绍

Happens-Before规则的主要内容包括以下几个方面:

(1)程序顺序执行规则(as-if-serial规则)

在同一个线程中,有依赖关系的操作按照先后顺序,前一个操作必须先行发生于后一个操作(Happens-Before)。换句话说,单个线程中的代码顺序无论怎么重排序,对于结果来说是不变的。

(2)volatile变量规则

对volatile(修饰的)变量的写操作必须先行发生于对volatile变量的读操作。

(3)传递性规则

如果A操作先于B操作,而B操作又先行发生于C操作,那么A操作先行发生于C操作。

(4)监视锁规则(Monitor Lock Rule)

对一个监视锁的解锁操作先行发生于后续对这个监视锁的加锁操作。

(5)start规则

对线程的start操作先行于这个线程内部的其他任何操作。具体来说,如果线程A执行B.start()启动线程B,那么线程A的B.start()操作先行发生于线程B中的任意操作。

(6)join规则

如果线程A执行了B.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A所执行的ThreadB.join()操作。

1.6.2 规则1:顺序性规则

顺序性规则的具体内容:一个线程内,按照代码顺序,书写在前面的操作先行发生(Happens-Before)于书写在后面的操作。

一段程序的执行,在单个线程中看起来是有序的。程序次序规则看起来是按顺序执行的,因为虚拟机可能会对程序指令进行重排序。虽然进行了重排序,但是最终执行的结果与程序顺序执行的结果是一致的。它只会对不存在数据依赖行的指令进行重排序。

该规则就是As-if-Serial规则,仅仅用来保证程序在单线程执行结果的正确性,但是无法保证程序在多线程执行结果的正确性。

1.6.3 规则2:volatile规则

volatile规则的具体内容:对一个volatile变量的写先行发生(Happens-Before)于任意后续对这个volatile变量的读。

1.6.4 规则3:传递性规则

传递性规则的具体内容:如果A操作先行发生于B操作,且B操作先行发生于C操作,那么A操作先行发生于C操作。

1.6.5 规则4:监视锁规则

监视锁规则的具体内容:对一个锁的unlock操作先行发生于后面对同一个锁的lock操作,即无论在单线程还是多线程中,同一个锁如果处于被锁定状态,那么必须先对锁进行释放操作,后面才能继续执行lock操作。

监视锁规则不会对临界区内的代码进行约束,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,虽然线程A在临界区内进行了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

1.6.6 规则5:start()规则

start()规则的具体内容:如果线程A执行ThreadB.start()操作启动线程B,那么线程A的ThreadB.start()操作先行发生于线程B中的任意操作。反过来说,如果主线程A启动子线程B后,线程B能看到线程A在启动操作前的任何操作。

1.6.7 规则6:join()规则

join()规则的具体内容:如果线程A执行threadB.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A的threadB.join()操作。join()规则和start()规则刚好相反,线程A等待子线程B完成后,当前线程B的赋值操作,线程A都能够看到。

1.7 volatile不具备原子性

1.7.1 volatile变量的自增实例

1.7.2 volatile变量的复合操作不具备原子性的原理

image.png

虽然volatile修饰的变量可以强制刷新内存,但是其并不具备原子性。虽然其要求对变量的(read、load、use)、(assign、store、write)必须是连续出现,但是在不同CPU内核上并发执行的线程还是有可能出现读取脏数据的时候。

假设有两个线程A、B分别运行在Core1、Core2上,并假设此时的value为0,线程A、B也都读取了value值到自己的工作内存。

现在线程A将value变成1之后,完成了assign、store的操作,假设在执行write指令之前,线程A的CPU时间片用完,线程A被空闲,但是线程A的write操作没有到达主存。

由于线程A的store指令触发了写的信号,线程B缓存过期,重新从主存读取到value值,但是线程A的写入没有最终完成,线程B读到的value值还是0。

线程B执行完成所有的操作之后,将value变成1写入主存。

线程A的时间片重新拿到,重新执行store操作,将过期了的1写入主存。

image.png

对于复合操作,volatile变量无法保障其原子性,如果要保证复合操作的原子性,就需要使用锁。并且,在高并发场景下,volatile变量一定需要使用Java的显式锁结合使用。

2. 造火箭攻略

3. 拧螺丝攻略

4. 复制攻略

4.1 《Java高并发核心编程(卷2)》