目录
Java 内存模型(JMM)
JMM 抽象结构
重排序
源码 ->最终指令序列
编译器重排序
处理器重排序
数据依赖性
- as-if-serial
- happens-before
happens-before 的规则
happens-before 关系的定义
重排序对多线程的影响
顺序一致性
数据竞争与顺序的一致性
顺序一致性内存模型
总结
JMM 遵循的基本原则:
as-if-serial 与 happens-before 的异同
Java 内存模型(JMM)
Java 内存模型 (JMM) 定义了程序中各个变量的访问规则, 即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节.
在 Java 中, 所有实例域, 静态域和数组元素都存在堆内存中, 堆内存在线程之间共享, 这些变量就是共享变量.
局部变量 (Local Variables), 方法定义参数(Formal Method Parameters) 和异常处理参数 (Exception Handler Parameters) 不会在线程之间共享, 它们不存在内存可见性问题.
JMM 抽象结构
图参考自《Java 并发编程的艺术》3-1
上图是抽象结构, 一个包含共享变量的主内存 (Main Memory), 出于提高效率, 每个线程的本地内存中都拥有共享变量的副本. Java 内存模型(简称 JMM) 定义了线程和主内存之间的抽象关系, 抽象意味着并不具体存在, 还涵盖了其他具体的部分, 如缓存, 写缓存区, 寄存器等.
此时线程 A,B 之间是如何进行通信的呢?
A 把本地内存中的更新的共享变量刷新到主内存中.
B 再从主内存中读取更新后的共享变量.
明确一点, JMM 通过控制主内存与每个线程的本地内存之间的交互, 确保内存的可见性.
重排序
编译器和处理器为了优化程序性能会对指令序列进行重新排序, 重排序可能会导致多线程出现内存可见性问题.
源码 ->最终指令序列
下图为《Java 并发编程的艺术》3-3
编译器重排序
编译器优化的重排序: 编译器不改变单线程程序语义的前提下, 可以重新安排语句的执行顺序.
JMM 对于编译器重排序规则会禁止特定类型的编译器重排序.
处理器重排序
指令级并行的重排序: 现代处理器采用指令级并行技术 (Instruction-Level-Parallelism,ILP) 将多条指令重叠执行. 如果不存在数据依赖性, 处理器可以改变语句对应及其指令的执行顺序.
内存系统的重排序: 处理器使用缓存和读 / 写缓冲区, 使得加载和存储的操作看起来在乱序执行.
对于处理器重排序, JMM 的处理器重排序会要求 Java 编译器在生成指令序列时, 插入特定类型的内存屏障指令, 以禁止特定类型的处理器重排序.
数据依赖性
如果两个操作访问同一变量, 且这两个操作中有一个为写操作, 此时这两个操作之间就存在数据依赖性.
编译器和处理器会遵守数据依赖性, 不会改变存在数据依赖关系的两个操作的执行顺序.(针对单个处理器中执行的指令序列和单个线程中执行的操作)
考虑抽象内存模型, 现代处理器处理线程之间数据的传递的过程: 将数据写入写缓冲区, 以批处理的方式刷新写缓冲区, 合并写缓冲区对同一内存地址的多次写, 减少内存总线的占用. 但每个写缓冲区只对它所在的处理器可见, 处理器对内存的读 / 写操作可能就会改变.
as-if-serial
不管怎么重排序,(单线程)程序的执行结果不能被改变, 同样, 不会对具有数据依赖性的操作进行重排序, 相应的, 如果不存在数据依赖, 就会重排序.
- double pi = 3.14; // A
- double r = 1.0; // B
- double area = pi * r * r; // C
C 与 A 访问同一变量 pi,C 与 B 访问同一变量 r, 且存在写操作, 具有依赖关系, 它们之间不会进行重排序.
A 与 B 之间不存在依赖关系, 编译器和处理器可以重排序, 可以变成 B->A->C.
很明显, as-if-serial 语义很好地保护了上述单线程, 让我们以为程序就是按照 A->B->C 的顺序执行的.
happens-before
从 JDK5 开始, Java 使用新的 JSR-133 内存模型, 使用 happens-before 的概念阐述操作之间的内存可见性.
有个简单的例子理解所谓的可见性和 happens-before"先行发生" 的规则.
- i = 1; // 在线程 A 中执行
- j = i; // 在线程 B 中执行
我们对线程 B 中这个 j 的值进行分析:
假如 A happens-before B, 那么 A 操作中 i=1 的结果对 B 可见, 此时 j=1, 是确切的. 但如果他们之间不存在 happens-before 的关系, 那么 j 的值是不一定为 1 的.
在 JMM 中, 如果一个操作执行的结果需要对另一个操作可见, 两个操作可以在不同的线程中执行, 那么这两个操作之间必须要存在 happens-before.
happens-before 的规则
以下源自《深入理解 Java 虚拟机》
意味着不遵循以下规则, 编译器和处理器将会随意进行重排序.
程序次序规则(Program Order Rule): 在一个线程内, 按照程序代码顺序, 书写在前面的操作先行发生于书写在后面的操作.
监视器锁规则(Monitor Lock Rule): 一个 unLock 操作在时间上先行发生于后面对同一个锁的 lock 操作.
volatile 变量规则(Volatile Variable Rule): 对一个 volatile 变量的写操作在时间上先行发生于后面对这个量的读操作.
线程启动规则 (Thread Start Rule):Thread 对象的 start() 先行发生于此线程的每一个动作.
线程终止规则(Thread Termination Rule): 线程中的所有操作都先行发生于对此线程的终止检测.
线程中断规则 (Thread Interruption Rule): 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生.
对象终结规则 (Finalizer Rule): 一个对象的初始化完成先行发生于它的 finalize() 方法的开始.
传递性(Transitivity):A 在 B 之前发生, B 在 C 之前发生, 那么 A 在 C 之前发生.
happens-before 关系的定义
如果 A happens-before B,A 的执行结果对 B 可见, 且 A 的操作的执行顺序排在 B 之前, 即时间上先发生不代表是 happens-before.
A happens-before B,A 不一定在时间上先发生. 如果两者重排序之后, 结果和 happens-before 的执行结果一致, 就 ok.
举个例子:
- private int value = 0;
- public void setValue(int value){
- this.value = value;
- }
- public int getValue(){
- return value;
- }
假设此时有两个线程, A 线程首先调用 setValue(5), 然后 B 线程调用了同一个对象的 getValue, 考虑 B 返回的 value 值:
根据 happens-before 的多条规则一一排查:
存在于多个线程, 不满足程序次序的规则.
没有方法使用锁, 不满足监视器锁规则.
变量没有用 volatile 关键字修饰, 不满足 volatile 规则.
后面很明显, 都不满足.
综上所述, 最然在时间线上 A 操作在 B 操作之前发生, 但是它们不满足 happens-before 规则, 是无法确定线程 B 获得的结果是啥, 因此, 上面的操作不是线程安全的.
如何去修改呢? 我们要想办法, 让两个操作满足 happens-before 规则. 比如:
利用监视器锁规则, 用 synchronized 关键字给 setValue()和 getValue()两个方法上一把锁.
利用 volatile 变量规则, 用 volatile 关键字给 value 修饰, 这样写操作在读之前, 就不会修改 value 值了.
重排序对多线程的影响
考虑重排序对多线程的影响:
如果存在两个线程, A 先执行 writer()方法, B 再执行 reader()方法.
- class ReorderExample {
- int a = 0;
- boolean flag = false;
- public void writer() {
- a = 1; // 1
- flag = true; // 2
- }
- Public void reader() {
- if (flag) { // 3
- int i = a * a; // 4
- ......
- }
- }
- }
在没有学习重排序相关内容前, 我会毫不犹豫地觉得, 运行到操作 4 的时候, 已经读取了修改之后的 a=1,i 也相应的为 1. 但是, 由于重排序的存在, 结果也许会出人意料.
操作 1 和 2, 操作 3 和 4 都不存在数据依赖, 编译器和处理器可以对他们重排序, 将会导致多线程的原先语义出现偏差.
顺序一致性
数据竞争与顺序的一致性
上面示例就存在典型的数据竞争:
在一个线程中写一个变量.
在另一个线程中读这个变量.
写和读没有进行同步.
我们应该保证多线程程序的正确同步, 保证程序没有数据竞争.
顺序一致性内存模型
一个线程中的所有操作必须按照程序的顺序来执行.
所有线程都只能看到一个单一的操作执行顺序.
每个操作都必须原子执行且立刻对所有线程可见.
这些机制实际上可以把所有线程的所有内存读写操作串行化.
顺序一致性内存模型和 JMM 对于正确同步的程序, 结果是相同的. 但对未同步程序, 在程序顺序执行顺序上会有不同.
JMM 处理同步程序
对于正确同步的程序(例如给方法加上 synchronized 关键字修饰),JMM 在不改变程序执行结果的前提下, 会在在临界区之内对代码进行重排序, 未编译器和处理器的优化提供便利.
JMM 处理非同步程序
对于未同步或未正确同步的多线程程序, JMM 提供最小安全性.
一, 什么是最小安全性?
JMM 保证线程读取到的值要么是之前某个线程写入的值, 要么是默认值(0,false,Null).
二, 如何实现最小安全性?
JMM 在堆上分配对象时, 首先会对内存空间进行清零, 然后才在上面分配对象. 因此, 在已清零的内存空间分配对象时, 域的默认初始化已经完成(0,false,Null)
三, JMM 处理非同步程序的特性?
不保证单线程内的操作会按程序的顺序执行.
不保证所有线程看到一致的操作执行顺序.
不保证 64 位的 long 型和 double 型的变量的写操作具有原子性.(与处理器总线的工作机制密切相关)
对于 32 位处理器, 如果强行要求它对 64 位数据的写操作具有原子性, 会有很大的开销.
如果两个写操作被分配到不同的总线事务中, 此时 64 位写操作就不具有原子性.
总结
JMM 遵循的基本原则:
对于单线程程序和正确同步的多线程程序, 只要不改变程序的执行结果, 编译器和处理器无论怎么优化都 OK, 优化提高效率, 何乐而不为.
as-if-serial 与 happens-before 的异同
异: as-if-serial 保证单线程内程序的结果不被改变, happens-before 保证正确同步的多线程程序的执行结果不被改变.
同: 两者都是为了在不改变程序执行结果的前提下, 尽可能的提高程序执行的并行度.
参考资料:
《Java 并发编程的艺术》方腾飞
《深入理解 Java 虚拟机》周志明
来源: https://www.cnblogs.com/summerday152/p/12296420.html