Java内存模型(JMM)学习一、Java内存模型的抽象结构&指令重排序
在并发编程中,需要处理两个关键问题:
- 线程之间如何通信(这里的线程是指并发执行的活动实体,通信是指线程之间以何种机制来交换信息)
- 线程之间如何同步
在命令式编程中,线程之间的通信机制有两种:
共享内存
和消息传递
在共享内存
的并发模型里,线程之间共享程序的公共状态,通过写-读
内存中的公共状态进行隐式通信
在消息传递
的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息
来显式进行通信
同步
是指程序中用于控制不同线程间操作发生相对顺序的机制
在共享内存
的并发模型里,同步是显式进行
的。程序员必须显式指定某个方法或某些代码需要在线程之间互斥执行
在消息传递
的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行
的
Java并发采用的是
共享内存模型
,Java线程之间的通信总是隐式进行
,整个通信过程对程序员完全透明。如果在多线程开发的多次中不理解隐式进行的线程之间通信的工作机制,很可能遇到各种各样奇怪的可见性问题
1. Java内存模型的抽象结构
在Java中,所有实例域
、静态域
、和数组元素
都存储在堆内存
中,堆内存在线程之间共享
(我们将这些可以共享的变量成为共享变量
)
局部变量(Local Variables),方法定义参数(Formal Method Parameters)和异常处理器参数(Exception Handler Parameters),不会在线程之间共享,它们不会有内存可见性问题,也不会受内存模型的影响
Java线程之间的通信由Java内存模型(JMM)
控制,JMM
决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来讲,JMM
定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在
主内存(Main Memory)
中 - 每个线程都有一个私有的
本地内存(Local Memory)
- 本地内存中存储了该线程以
读/写
共享变量的副本
本地内存是JMM
的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。其抽象示意图如下:
![](https://cdn.fengxianhub.top/resources-master/202203011536594.png)
如图所示,如果线程A和线程B之间要进行通信的话,必须要经历下面两个步骤:
- 线程A把本地内存中更新过的共享变量刷新到主内存中去
- 线程B到主内存中去读取线程A之前已更新过的共享变量
举个栗子:
假设初试时,本地内存A、B和主内存中x的值都是0,线程A在执行时,把更新后的X值(更新为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的X值刷新到主内存
中,此时主内存中的X变为了1。随后,线程B到主内存中去读取A更新后的X值,此时线程B的本地变量也变成了1,这样就完成了一次线程通信。
通过图片演示一下:
![](https://cdn.fengxianhub.top/resources-master/202203011603968.png)
从整体来看,这两个步骤实质上是线程A在给线程B发送消息,而且这个过程必须要经过
主内存
。JMM
通过控制主内存与各个线程本地内存之间的交互
,来为Java程序员保证可见性保证
2. 从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令坐重排序。重排序分为三种类型:
编译器优化的重排序
。编译器在不改变单线程程序语义的前提下,可以重新安排语义的执行顺序指令级并行的重排序
。现代处理器采用了指令级并行技术(Instruction- Level Parallelism,ILP)来将多条指令,重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的排序顺序内存系统的重排序
。由于处理器使用缓存和读/写缓存区,这使得加载和存储操作看上去可能在乱选执行
从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序
如图所示,重排序分为两种,这些重排序可能会导致对多线程程序出现内存可见性问题
如何解决重排序带来的可见性问题?
- 对于编译器重排序,
JMM
的编译器重排序规则会禁止特定类型的编译器重排序(不是所有编译器重排序都会被禁止)- 对于处理器重排序,
JMM
的处理器重排序规则要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)
,通过内存屏障指令来禁止特定类型的处理器重排序
JMM
属于语言级别的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
3. 指令重排序栗子
先给重排序下一个定义:
重排序是指编译器和处理器为了优化程序性能面对指令序列进行重新排序的一种手段
现代的处理器都是用写缓存区临时保存向内存写入数据
这样做的好处有:
- 可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟
- 减少对内存总线的占用。通过合并缓冲区对同一内存地址的对次写,减少对内存总线的占用
坏处:
- 产生了可见性问题。处理器对内存的读/写顺序,不一定与内存实际发生的读写顺序一样
举个栗子:
示例项/处理器 Processor A Processor B 代码 a = 1 ( A1
)
x = b (A2
)b = 2 ( B1
)
y = a (B2
)结果 初试状态:a = b =0
处理器允许执行后得到结果:x = y =0
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到 x = y = 0
的结果,这个过程可以看下面的图:
![](https://cdn.fengxianhub.top/resources-master/202203101519573.png)
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(过程A1,B1
),然后从内存中读取另一个共享变量(A2,B2
),最后才把自己写缓存区中保存的脏数据
刷新到内存中(A3,B3
)。当以这种顺序执行时,程序才能得到正确的结果,即:x = y =0
🚩从内存操作实际发生的顺序来看,知道处理器A
执行A3
来刷新自己的写缓存取,写操作A1
才算真正完成了,虽然处理器A
执行内存操作的顺序为:A1 -> A2
,但内存操作实际发生的顺序却是:A2 -> A1
。
此时,处理器A的内存操作顺序就是被重排序了!(处理器B情况和处理器A一样)
这里的关键是,由于写缓冲区仅对自己的处理器可见,他会导致处理器执行内存操作顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓存区,因此现代的处理器都会允许对写 - 读
操作进行重排序
(这个很重要,后面讲内存屏障的时候还会再次提起)
常见架构处理器运行的重排序类型表如下所示(N 表示两个操作不允许重排序 Y表示允许
):
处理器/规则 | Load - Load | Load - Store | Store - Store | Store - Load | 数据依赖 |
---|---|---|---|---|---|
SPARC - TSO | N | N | Y | N | N |
X86 | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
注意:
- 常见处理器都允许
Store - Load
重排序- 常见处理器都不允许对存在数据依赖的操作做重排序
X86(包括X64 和 AMD64)
和sparc - TSO 拥有相对较强的处理器内存模型,它们仅允许对写 - 读(Store - Load)做重排序
(因为它们都使用了写缓存区)
为了保证内存可见性,Java编译器
在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM
把内存屏障指令分为四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2及所有后续装载指令的装载 |
StoreStore Barriers | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到内存) 先于Store2及其后续的存储指令刷新到内存 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存中 |
StoreLoad Barriers | Store;StoreLoad;Load | 确保Store1数据对其他处理器变得可见(指刷新到内存) 先于Load2及所有后续装载指令的装载 StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成后才执行该屏障之后的内存访问指令 |
重点🚩:
StoreLoad
是一个全能型的屏障,他同时具有其他三个屏障的效果。现代处理器一般都支持该屏障- 执行
StoreLoad
屏障的开销很大,因为当前处理器通常把缓冲区中的数据全部刷新到内冲中(Buffer Fully Flush)