在现代计算机中,CPU往往都是多核的,而由于每个CPU Core中都有自己的高速缓存Cache,因此就会造成内存数据读写的不一致性,表现为 指令乱序
与 不可见性
问题。为此,java为了统一物理世界中的计算机组成架构,提出了JMM内存模型,并抽象了 LoadLoad
, StoreStore
, LoadStore
, StoreLoad
四个内存屏障指令来应对不同CPU的体系。本文先介绍下多核CPU体系下,并发编程需要克服的问题,然后再介绍下Java的内存屏障各自的含义,并举例说明对应的场景。
顺序性与可见性
在执行程序时,为了提高性能,编译器和处理器会通常对指令进行重排序,即:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 (同时,由于处理器中有缓存的存在,导致数据不一致,从读写操作上看,也存在类似的乱序重排的效果,即:内存系统的重排序)
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
也就是说,即使指令的执行没有重排序,是按顺序执行的,但由于缓存的存在,仍然会出现数据的非一致性的情况。我们把这种 普通读``普通写
可以理解为是 有延迟的 延迟读
、 延迟写
, 因此即使读在前、写在后,因为有延迟,然后仍然会出现写在前、读在后的情况。
为了解决上述重排带来的问题,提出了 as-if-serial
原则,即不管怎么重排序,程序执行的结果在 单线程 里保持不变。为了遵守 as-if-serial
原则,我们需要一种特殊的指令来阻止特定的重排,使其保持结果一致,这种指令就是 内存屏障 。
内存屏障有两个效果:
- 阻止指令重排序:在插入内存屏障指令后,不管前面与后面任何指令,都不能与内存屏障指令进行重排,保证前后的指令按顺序执行,即保证了
顺序性
。 - 全局可见:插入的内存屏障,保证了其对内存操作的读写结果会立即写入内存,并对其他CPU核可见,即保证了
可见性
,解决了普通读写的延迟问题。例如,插入读屏障
后,能够删除缓存,后续的读能够立刻读到内存中最新数据(至少当时看起来是最新)。插入写屏障
后,能够立刻将缓存中的数据刷新入内存中,使其对其他CPU核可见。
因此,在CPU的物理世界里,内存屏障通常有三种:
- lfence: 读屏障(load fence),即立刻让CPU Cache失效,从内存中读取数据,并装载入Cache中。
- sfence: 写屏障(write fence), 即立刻进行flush,把缓存中的数据刷入内存中。
- mfence: 全屏障 (memory fence),即读写屏障,保证读写都串行化,确保数据都写入内存并清除缓存。
JMM的四种读写屏障
由于物理世界中的CPU屏障指令和效果各不一样,为了实现跨平台的效果,针对读操作load和写操作store,Java在JMM内存模型里提出了针对这两个操作的四种组合来覆盖读写的所有情况,即:读读LoadLoad、读写LoadStore、写写StoreStore、写读StoreLoad