Java内存模型及变量可见性的一些简述

最近有同事在线上遇到多线程共享变量可见性问题,简单处理后,我觉得这些基础应该需要再整理总结一下。(该部分内容需要熟悉JVM并发相关概念和了解JVM解释运行过程,有一定阅读门槛)

首先,我们应该先了解一下计算机内存、缓存和CPU之间的关系。

计算机内存和CPU

大家都知道,CPU的运行速度很快,而计算机物理内存的读写速度却远远落后于CPU,为此,在CPU和物理内存之间添加了高速缓存来过渡。

为了权衡成本效益,缓存又被分成多层架构:一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache),其容量依次变大,速度、和成本依次下降。

现代架构的CPU在显微镜下其局部大概是这样的:

CPU显微图局部

可以看到那一块块排列整齐的“建筑区”就是缓存,其示意图大概是这样的:

缓存行

缓存行(Cache Line)是存储交换管理的最小单元。

当CPU要读取数据时,首先会从最近的存储中依次查找(首先是L1 Cache,找不到就去L2 Cache,依次直到找到要读取的数据):

存储架构
  • 单核CPU,只有一个核心,缓存是CPU独占,不会出现访问冲突的问题;
  • 单核多线程CPU,虽然有多个逻辑核心,但只有一个物理核心,多线程访问共享数据都会映射到相同的缓存位置,而且任何时刻只有一个线程在执行,因此也不会产生冲突;
  • 多核多线程CPU,那么每一个核心都会有至少一个L1 Cache,多个线程分别在不同的核心上运行,那么每个核心都会保留一份共享数据的缓存,由于多核是并行执行,可能会出现各个核心的缓存数据不一致的冲突。

为此,就要考虑多线程场景下CPU之间缓存如何保持一致。

缓存一致性协议

当多个cpu对同一块内存地址进行操作时,即共享数据时,如何保证它们操作的数据的一致性呢?

缓存一致性,即当CPU0对缓存行中的数据做了修改,就要通知其他CPU,例如CPU1,它的缓存行失效了,那么CPU1想要操作该缓存行的数据时就会退行到从下一级存储重新加载数据。

缓存一致性协议有很多种不同的实现,但此处主要简述一下典型的MESI协议场景。

在MESI协议中,每个缓存行都会有一个固定状态:ModifiedExclusiveSharedInvalid

假设当前有2个CPU核心:A和B

  • 当A从存储中加载一个变量时,会读取一段连续的数据,即缓存行,该缓存行将会被标记为“Exclusive”,此时只有A在操作该缓存行(唯一的数据缓存拷贝,且数据和主内存是一致的),数据会持续保留在缓存中(对缓存的修改不会立即刷新到下级存储,仅在有必要的时候)。
  • 此时,B也要读取一个变量,刚好和A读取的数据在同一块缓存行中,那么此时B的缓存中也缓存了同样的缓存行,但A、B缓存行都会被标记为“Shared”(缓存数据和主内存依旧是保持一致的)。
  • 然后我们假设A接下来对它读取的变量进行了修改,那么A的缓存行会被标记为“Modified”(此时,该缓存行的数据就和主内存不一致,且仅该缓存行的数据才是最新的),同时A会通知B说你的缓存行过时了,此时B的缓存行会被标记为“Invalid”(此缓存行已过期,缓存的内容不会再被使用)。
  • 那么过了一段时间,B要再次读取它的变量了,但B此时的缓存行已经失效了,它应该去下一级存储重新读取数据,B对该数据块的读取访问会强制要求A将“Modified”标记的该缓存行刷新到下一级存储,等数据刷新后B读取到的就是最新的缓存行,此时A、B的缓存行再次被标记为“Shared”。

处理器优化

上面提到缓存在多个CPU之间一致性的问题。除了缓存,还有一种硬件问题也需要注意,就是处理器优化。

为了使CPU的运算单元能被充分使用,处理器就会对代码指令做乱序执行,在单核CPU下这不会有什么问题,结果最终都是一样的,但在多核心下,对共享数据读写的乱序就会导致意想不到的问题。

缓存之外

上面也提到了,缓存一致性协议会保证数据在缓存中是一致的。但理论是这样的,实际现实的时候,传统MESI协议中将缓存行(Cache Line)标记为“Invalid”和将“Invalid”标记的Cache Line刷入新数据的操作花费的成本很高,因此会有一个Invalidate Queue队列来异步进行。

同时,即使是离CPU最近的L1 Cache,依旧需要耗费好几个周期来访问数据,而当缓存写共享数据时的延迟更高,为了追求性能提升,在CPU和L1 Cache之间又加入了一层缓冲,称之为Store Buffer。但store buffer和其他缓存是有区别的,它只缓存写操作,CPU的部分写操作会先写入store buffer,之后store buffer会通过FIFO(先进先出)的顺序写入Cache。

上面两个额外的缓冲队列会导致数据加载或写入顺序出现乱序。

缓存一致

当CPU0写共享数据时,先发送“Invalid”消息,然后把数据写入store buffer(会在某个时刻写入Cache),而读数据时,则先从store buffer中查找(其次再从Cache中查找),此时CPU1是看不到当前CPU0的store buffer中的数据,需要等store buffer刷新到Cache才会触发缓存行失效的操作;

在CPU1收到“Invalid”消息时,会把消息放入“Invalidate Queue”,随后异步将对应Cache Line设为“Invalid”,和store buffer不同,CPU1读取Cache时并不会查找“Invalidate Queue”,所以在这个异步标记Cache Line的过程中就会存在脏读。

Java内存模型(JMM)

上面这么多硬件的问题,开发者已经头痛欲裂了。那么我们如何在编写程序的时候不用去过分考虑这么多硬件问题呢?
为了共享内存的正确,内存模型定义了多线程读写操作的行为规范,其解决并发问题的主要手段为:限制处理器优化内存屏障

Java内存模型定义了变量存储在主内存中,每个线程有自己的工作内存,其中保存了该线程使用的变量在主内存的副本拷贝,线程对变量的操作都在工作内存中进行,不能直接访问主内存,因此不同线程间无法直接访问对方工作内存中的变量,线程间共享变量的传递需要在工作内存和主存之间进行数据同步来进行。

以上提到的工作内存、主内存并非物理概念的内存,而是抽象概念,我们无需关心数据此时在Cache中还是在物理内存中,又或是在store buffer中。那么基于JMM,我们就可以不用管那一堆硬件问题导致的多线程问题,我们只需关注JMM本身即可。

Java内存模型就作用于工作内存和主内存之间,规定了如何进行数据同步以及什么时候进行,目的是解决多线程共享数据存在的数据不一致、指令重排、乱序执行等带来的问题。

原子性

即在一个操作中,不可以被中断操作,要么完全执行,要么完全不执行。

在Java中使用关键字synchronized,通过对应字节码指令monitorentermonitorexit来确保代码块的操作是原子的。

有序性

上面提到,指令重排,它是一种优化,编译器会对程序指令进行重排优化,和CPU优化的指令乱序执行道理是一样的。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
private boolean ready = false;
private int x = 1;
// 线程0计算
public void first(){
x = x + 1;
ready = true;
}
// 线程1计算
public void second(){
while (!ready) {
}
x = x + 1;
}

编译优化排序后的指令不一定会按照书写顺序执行:

CPU时间片序号 线程 操作 线程内存 主内存
1 thread-0 读取 x=1 x=1
2 thread-0 赋值 x=1, ready=true x=1, ready=false
3 thread-0 写回 x=1, ready=true x=1, ready=true
4 thread-1 读取 ready=true, x=1 x=1, ready=true
5 thread-0 计算 x=2, ready=true x=1, ready=true
6 thread-0 写回 x=2, ready=true x=2, ready=true
..

而且,部分编译器还会对代码进行逻辑优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 优化1
public void second(){
boolean a1 = ready;
while (a1) {}
x = x + 1;
}
// 优化2
public void second(){
boolean a1 = ready;
if (!a1) return;
while (true) {}
x = x + 1;
}

为此,我们可以使用synchronizedvolatile关键字来确保有序:volatile关键字会限制指令重排和优化,synchronized则保证同一时刻只有一个线程运行指令从而避免。

可见性

Java中的volatile关键字提供了一个功能,那就是其修饰的变量被修改后可以立即同步到主内存,其变量在每次读取前都从主内存刷新。

除了volatilesynchronizedfinal两个关键字也可以实现可见性,其中synchronized对应的字节码指令monitorentermonitorexit会分别:

  • 清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;
  • 把工作内存中的共享变量的值写入到主内存。

扩展

伪共享(False Sharing)

如果多个变量存在同一缓存行中不同区域时,对不同变量的修改就会争用同一个缓存行,导致看起来像是争用同一个共享变量一样。

缓存一致性

缓存行会因此被不停标记为失效,并刷新和重新加载,这会带来性能问题。因此,对于同一块数据块上的不同变量的操作,可以通过填充数据使它们分布到不同的缓存行上来避免此性能问题。

变量易失性

那么我们再次使用之前提到的代码,只是稍微简化了一下:

1
2
3
4
5
6
7
8
9
10
private boolean ready = false; 
// 线程0
public void first(){
ready = true;
}
// 线程1
public void second(){
while (!ready) {
}
}

我们有2个线程会分别执行上面的对应方法,如代码所写:线程1将无限循环直到线程0将共享变量ready的值做出修改,从而终止线程1的循环。

然而事情并没有这么简单,首先每个线程都会有共享变量的副本(JMM)保存在各自的堆栈中,而当线程0对共享变量进行修改后,线程1并不能看到该变量的修改,它仍将保持无限循环。

而且编译器很“聪明”,它认为在单线程下,变量ready不会被修改,甚至对条件判断逻辑做出优化。

volatile会强制让数据交换使用主内存(JMM抽象概念的主内存,并非物理主内存),这里有几点需要注意。

  1. 在Java1.5时代,主流CPU架构中,缓存一致性是可选功能且默认不开启,volatile为此担任了一个重要角色就是使用指令开启缓存一致性。但如今该功能已成为架构中的必选项,缓存一致性是硬件层协议,软件开发者无需额外处理。
  2. volatile不会也无法直接让数据从物理内存中读写,因为效率太低了。
  3. CPU存储架构对其上的应用是透明的,JMM足够抽象,volatile不能使CPU立即刷新缓存,系统会在必要的时候进行缓存刷新操作,由缓存一致性保证数据在缓存中是始终一致的。

同时volatile关键字标记的变量会在编译器或CPU进行指令优化时做出一些限制。涉及到几个屏障(barriers):LoadStoreLoadLoadStoreLoadStoreStore

缓存一致性&内存一致性

缓存一致性协议确保对同一内存位置的读取将始终返回最新的值。仅当同一内存位置的缓存有多个副本时,才需要缓存一致性协议,其工作内容就是保持所有数据副本在缓存中的一致性。

内存一致性模型则关注于读取和写入到不同内存位置的相对顺序,完全存储定序,TSO(Total Storage Order)

我们定义读取操作为load,写入操作为store,由于store buffer的存在,CPU对数据的写操作其他CPU并不能及时看见。

例如:

1
2
y=2;
a=x;

即使CPU0做了先store y,再load x,在CPU1看来就是先load x,再store y。因此StoreLoad可能会变成LoadStore

由于X86架构CPU提供了TSO,所以LoadStoreLoadLoadStoreStore这几个屏障相当于空操作,主要用于限制编译器优化,StoreLoad会有额外指令实现。

屏障类型 示例指令 说明
LoadLoad Load1;LoadLoad;Load2 该屏障确保Load1数据的加载先于Load2及其后所有读取指令的操作
StoreStore Store1;StoreStore;Store2 该屏障确保Store1的写入操作先于Store2及其后所有写入指令的操作
LoadStore Load1;LoadStore;Store2 确保Load1的数据加载先于Store2及其后所有的写入指令的操作
StoreLoad Store1;StoreLoad;Load2 确保Store1的写入操作先于Load2及其后所有读取指令的操作。同时该屏障之前的所有数据访问指令完成之后,才执行该屏障之后的数据访问指令

先看几个例子(我们假设有volatile变量AB):

1):

1
2
3
4
tmp=A // volatile 读取
[LoadLoad]
[LoadStore]
// 读读、读写屏障,此时任何读取或写入操作都不能移动到屏障之前,但它们之间的顺序可重排

2):

1
2
3
4
// 读写、写写屏障,此时任何读取或写入操作都不能移动到屏障之后,但它们之间的顺序可重排
[LoadStore]
[StoreStore]
B=tmp // volatile 写入

3):

1
2
3
4
5
6
[LoadStore]
[StoreStore]
A=tmp1 // volatile 写入
tmp2=B // volatile 读取
[LoadLoad]
[LoadStore]

如例3,volatile变量的读取或写入仍然可能会被重新排序,此时需要额外的屏障去阻止CPU或编译器对此的优化排序,此处会添加一个新屏障,就是StoreLoad,该屏障会被添加到写操作之后。

1
2
3
4
5
6
7
[LoadStore]
[StoreStore]
A=tmp1 // volatile 写入
[StoreLoad]
tmp2=B // volatile 读取
[LoadLoad]
[LoadStore]

StoreLoad屏障同时具备StoreStoreLoadLoadLoadStore三个屏障的效果,会被编译为MFENCElock addl %(RSP),0指令。锁定主线,停止读取操作直到所有缓冲区(注意,CPU寄存器、Store Buffer也算缓冲区)已经刷新,确保了加载的数据全局可见。

X86是一种处理器架构,我们用的大多数CPU都是X86架构,Intel和AMD生产的绝大多数CPU,比如奔腾(Pentium),赛扬(Celeron),酷睿(core),Xeon等等都是该架构,而X86_64是X86的扩充,并且兼容X86

内存屏障

如上面所述,既然提到了内存屏障,自然会提到Java API提供的几种屏障:

  1. VarHandle#acquireFence = loadFence

对应指令lfence,实现Load屏障,实现方式按架构不同,如:将Invalidate Queue失效,强制读L1 Cache,且等待之前的读取完成,从而确保之后的读不会调度到lfence之前

  1. VarHandle#releaseFence = storeFence

对应指令sfence,实现Store屏障,实现方式按架构不同,如:将等待store buffer中的缓冲刷新到L1 Cache,从而确保后续写不会调度到sfence之前;但一直等待缓冲刷新会很不效率,有些实现会将后续写无论是否命中Cache都加入store buffer,但会对这些写进行标记,在标记过的写刷入Cache之前,sfence之前的写一定已经刷入Cache了,从而确保后续写不会调度到sfence之前

  1. VarHandle#fullFence = fullFence

对应指令mfence,有些平台会对应lock。首先mfence实现了Full屏障,会同时刷新store bufferInvalidate Queue,即实现了Load和Store屏障的效果;而lock指令修饰的操作会锁定,只能由当前CPU访问,这个修饰会让指令原子化,且自带Full屏障的效果。同时,有些平台上IO操作、exch原子交换等指令也带有该内存屏障的效果。

同时注意,Full屏障并不能单纯的组合lfencesfence,因为组合这两个屏障只能解决指令重排的问题,依然会有可见性问题存在,可以视为lfencesfence也能被重排。

  1. VarHandle#loadLoadFence = loadLoadFence / VarHandle#storeStoreFence = storeStoreFence

用于编译调整,实际实现指向lfencesfence

补充

在有些平台的JVM实现中,longdouble等64位数据的读写会被拆成两次32位读写操作,在多线程并发下可能会导致读取或写入不一致的问题,使用volatile可以保证该类型变量读写原子性。但实际上是有性能损耗的(volatile还保证了顺序性),即使在X86架构64位机器上,其已经保证了对64bit数据的读取和写入是原子性的,但我们无法保证程序不会在32位机器上运行,所以对longdouble使用volatile是有意义的。

基于上述屏障,在volatile被全局可见时,其之前写入的其他数据也会被刷新,从而全局可见。例如:

1
2
3
a=2;
b=++a;
X=1; // volatile 写入

那么其他线程在X=1可见时,其对变量ab也可见。


Java内存模型及变量可见性的一些简述
https://vicasong.github.io/java/juc-visible-sample-doc/
作者
Vica
发布于
2023年3月27日
许可协议