Java内存模型精讲
1.JAVA 的并发模型
共享内存模型
在共享内存的并发模型里面,线程之间共享程序的公共状态,线程之间通过读写内存中公共状态来进行隐式通信
该内存指的是主内存,实际上是物理内存的一小部分
2.JAVA 内存模型的抽象
2.1 java内存中哪些数据是线程安全的,哪些是非安全的
- 非线程安全 : 在 java 中所有的实例域、静态域、和数组元素都存放在堆内存中,并且这些数据是线程共享的,所以会存在内存可见性问题
- 线程安全 : 局部变量、方法定义的参数、异常处理器参数是当前线程的虚拟机栈中的数据,并且不会进行线程共享,所以不会存在内存可见性问题
2.2 线程间通讯的本质
- 线程间通讯的本质是 :JMM即 JAVA 内存模型进行控制,JMM决定了一个线程对共享变量的写入何时对其他线程可见。
由上图能看出来线程间的通讯都是通过主内存来进行传递消息的, 每个线程在进行共享数据处理的时候都是将共享的数据复制到当前线程本地(每个线程自己都有一个内存)来进行操作。
- 消息通讯过程(不考虑数据安全性的问题) :
- 线程一将主内存中的共享变量 A 加载到自己的本地内存中进行处理。比如 A = 1;
- 此时将修改的共享变量 A 刷入到主内存中, 之后线程二再将主内存中的共享变量 A 读取到本地内存进行操作;
整个数据交互的过程是JMM控制的,主要控制主内存与每个线程的本地内存如何进行交互来提供共享数据的可见性
3.重排序
程序在执行的时候为了提高效率会将程序指令进行重新排序
3.1 重排序分类
- 编译器优化重排序
编译器在不改变单线程程序语义的情况下进行语句执行顺序的优化
- 指令集并行重排序
如果不存在数据的依赖性的话,处理器可以改变语句对应机器指令的执行顺序
- 内存系统重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
3.2 重排序过程
以上三种重排序都会导致我们在写并发程序的时候出现内存可见性的问题。
JMM的编译器重排序规则会禁止特定类型的编译器重排序;
JMM的处理器重排序规则会要求java编译器在生成指令序列的时候插入特定的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器进行重排序
3.3 处理器重排序
由于为了避免处理器等待向内存中写入数据的延时,在处理器和内存中间加了一个缓冲区,这样处理器可以一直向缓冲区中写入数据,等到一定时间将缓冲区的数据一次性的刷入到内存中。
优点 :
- 处理器不同停顿,提高了处理器的运行效率
- 减少在向内存写入数据时的内存总线的占用
缺点 :
- 每个处理器上的写缓冲区只对当前处理器可见,所以就会造成内存操作的执行顺序和实际情况不符合
例如以下场景 :
在当前场景中就可能出现在处理器 A 和处理器 B 没有将它们各自的写缓冲区中的数据刷回内存中, 将内存中读取的A = 0、B = 0 进行给X和Y赋值,此时将缓冲区的数据刷入内存,导致了最后结果和实际想要的结果不一致。因为只有将缓冲区的数据刷入到了内存中才叫真正的执行
以上主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,JMM定义了以下8种操作来完成
操作 | 语义解析 |
---|---|
lock(锁定) | 作用于主内存的变量,把一个变量标记为一条线程独占状态 |
unlock(解锁) | 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read(读取) | 作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 |
load(载入) | 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中 |
use(使用) | 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎 |
assign(赋值) | 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量 |
store(存储) | 作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作 |
write(写入) | 作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送 到主内存的变量中 |
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行
操作执行流程图解:
同步规则分析
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
3.4 内存屏障指令
为了解决处理器重排序导致的内存错误,java编译器在生成指令序列的适当位置插入内存屏障指令,来禁止特定类型的处理器重排序
内存屏障指令
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoadBarriers | Load1;LoadLoad;Load2 | Load1数据装载发生在Load2及其所有后续数据装载之前 |
StoreStoreBarriers | Store1;StoreStore;Store2 | Store1数据刷回主存要发生在Store2及其后续所有数据刷回主存之前 |
LoadStoreBarriers | Load1;LoadStore;Store2 | Load1数据装载要发生在Store2及其后续所有数据刷回主存之前 |
StoreLoadBarriers | Store1;StoreLoad;Load2 | Store1数据刷回内存要发生在Load2及其后续所有数据装载之前 |
3.5 happens-before(先行规则)
happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据
在JMM中如果一个操作中的结果需要对另一个操作可见,那么这两个操作之前必须要存在happens-before关系 (两个操作可以是同一个线程也可以不是一个线程)
规则内容:
- 程序顺序规则 : 指的是在一个线程内控制代码顺序,比如分支、循环等,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行
- 加锁规则 : 一个解锁(unlock)操作一定要发生于一个加锁(lock)操作之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)
- volatile变量规则 : 对一个volatile的变量的写操作要发生在对这个变量的读操作之前,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值
- 线程启动规则 : 线程的启动方法 start() 要发生在当前线程所有操作之前
- 线程终止规则 : 线程中所有的操作都要发生在线程终止之前,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见
- 线程中断规则 : 线程调用interrupt()方法要发生在被中断线程的代码检查出中断事件之前
- 对象终结规则 : 对象的初始化完成要发生在对象被回收之前
- 传递性规则 : 如果操作 A 发生在操作 B 之前,操作 B 又发生在操作 C 之前,那么操作A一定发生于操作 C 之前
注意: 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行,只需要前一个操作的结果对后一个操作可见,并且前一个操作按顺序要排在后一个操作之前。
3.6 数据依赖性
就是前一个操作的结果对后一个操作的结果产生影响,此时编译器和处理器在处理当前有数据依赖性的操作时不会改变存在数据依赖的两个操作的执行顺序
注意: 此时所说的数据依赖仅仅针对单个处理器中执行的指令序列或者单个线程中执行的操作。不同处理器和不同线程的情况编译器和处理器是不会考虑的
3.7 as-if-serial
在单线程情况下不管怎么重排序程序的执行结果不能被改变,所以如果在单处理器或者单线程的情况下,编译器和处理器对于有数据依赖性的操作是不会进行重排序的。反之如果没有数据依赖性的操作就有可能发生指令重排。
5.数据竞争与顺序一致性
在多线程情况下才会出现数据竞争
5.1 数据竞争
在一个线程中写了一个变量,在另一个线程中读一个变量,而且写和读并没有进行同步
5.2 顺序一致性
如果在多线程条件下,程序能够正确的使用同步机制,那么程序的执行将具有顺序一致性(就像在单线程条件下执行一样) 程序最终运行的结果与你预期的结果一样
5.3 顺序一致性内存模型
5.3.1特性:
- 一个线程中的所有操作必须按照程序的顺序来执行
- 所有的操作都必须是原子性的操作,并且对其他线程可见的
5.3.2概念:
在概念上,顺序一致性有一个单一的全局内存,在任意时间点最多只有一个线程可以连接到内存,当在多线程的场景下,会把所有内存的读写操作变成串行化
5.3.3案例:
例如有多个并发线程 A B C, A 线程有两个操作 A1 A2, 他们的执行的顺序是 A1 -> A2 。B 线程有三个操作 B1 B2 B3, 他们的执行的顺序是 B1 -> B2 ->B3 。C 线程有两个操作 C1 C2 那么他们在程序中执行的顺序是 C1 -> C2 。
场景分析 :
场景一 : 并发安全(同步)执行顺序
A1 -> A2 -> B1 -> B2 ->B3 -> C1 -> C2
场景二: 并发不安全(非同步)执行顺序
A1 -> B1 -> A2 -> C1 -> B2 ->B3 -> C2
结论 :
在非同步的场景下,即使三个线程中的每一个操作乱序执行,但是在每个线程中的各自操作还是保持有序的。并且所有线程都只能看到一个一致的整体执行顺序,也就是说三个线程看到的都是该顺序 : A1 -> B1 -> A2 -> C1 -> B2 ->B3 -> C2 ,因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
以上案例场景在JMM中不是这样的,未同步的程序在JMM中不仅整体的执行顺序变了,就连每个线程的看到的操作执行顺序也是不一样的。
例如前面所说的如果线程A将变量的值 a = 2 写入到了自己的本地内存中,还没有刷入到主存中,在线程 A 来看值是变了,但是其他线程 B 线程 C 根本看不到值的改变,就认为线程A 的操作还没有发生,只有线程 A 将工作内存中的值刷回主内存线程 B和线程C 才能的到。但是如果是同步的情况下,顺序一致性模型和JMM模型执行的结果是一致的,但是程序的执行顺序不一定,因为在JMM中,会发生指令重排现象所以执行顺序会不一致。