本博客系列是学习并发编程过程中的记录总结. 由于文章比较多, 写的时间也比较散, 所以我整理了个目录贴(传送门), 方便查阅.
并发编程系列博客传送门
前言
之前的文章中讲到, JMM 是内存模型规范在 Java 语言中的体现. JMM 保证了在多核 CPU 多线程编程环境下, 对共享变量读写的原子性, 可见性和有序性.
本文就具体来讲讲 JMM 是如何保证共享变量访问的有序性的.
指令重排
在说有序性之前, 我们必须先来聊下指令重排, 因为如果没有指令重拍的话, 也就不存在有序性问题了.
指令重排是指编译器和处理器在不影响代码单线程执行结果的前提下, 对源代码的指令进行重新排序执行. 这种重排序执行是一种优化手段, 目的是为了处理器内部的运算单元能尽量被充分利用, 提升程序的整体运行效率.
重排序分为以下几种:
编译器优化的重排序. 编译器在不改变单线程程序语义的前提下, 可以重新安排语句的执行顺序.
指令级并行的重排序. 现代处理器采用了指令级并行技术来将多条指令重叠执行. 如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序.
内存系统的重排序. 由于处理器使用缓存和读 / 写缓冲区, 这使得加载和存储操作看上去可能是在乱序执行.
通过指令重排的定义可以看出: 指令重拍只能保证单线程执行下的正确性, 在多线程环境下, 指令重排会带来一定的问题(一个硬币具有两面性, 指令重排带来性能提升的同时也增加了编程的复杂性). 下面我们就来展示一个列子, 看看指令重排是怎么影响程序执行结果的.
- public class Demo {
- int value = 1;
- private boolean started = false;
- public void startSystem(){
- System.out.println(Thread.currentThread().getName()+"begin to start system, time:"+System.currentTimeMillis());
- value = 2;
- started = true;
- System.out.println(Thread.currentThread().getName()+"success to start system, time:"+System.currentTimeMillis());
- }
- public void checkStartes(){
- if (started){
- // 关注点
- int var = value+1;
- System.out.println("system is running, time:"+System.currentTimeMillis());
- }else {
- System.out.println("system is not running, time:"+System.currentTimeMillis());
- }
- }
- }
对于上面的代码, 假如我们开启一个线程调用 startSystem, 再开启一个线程不断调用 checkStartes 方法, 我们并不能保证代码执行到 "关注点" 处, var 变量的值一定是 3. 因为在 startSystem 方法中的两个赋值语句并不存在依赖关系, 所以在编译器进行代码编译时可能进行指令重排. 所以真实的执行顺序可能是下面这样的.
- started = true;
- value = 2;
也就是先执行 started = true; 执行完这个语句后, 线程立马执行 checkStartes 方法, 此时 value 值还是 1, 那么最后在关注点处的 var 值就是 2, 而不是我们想象中的 3.
有序性
有序性定义: 即程序执行的顺序按照代码的先后顺序执行.
在 JMM 中, 提供了以下三种方式来保证有序性:
happens-before 原则
synchronized 机制
volatile 机制
happens-before 原则
happens-before 原则是 Java 内存模型中定义的两项操作之间的偏序关系, 如果说操作 A 先行发生于操作 B, 其实就是说在发生操作 B 之前, 操作 A 产生的影响能被操作 B 观察到."影响" 包括修改了内存中共享变量的值, 发送了消息, 调用了方法等.
下面是 Java 内存模型下一些 "天然的" 先行发生关系, 这些先行发生关系无须任何同步器协助就已经存在, 可以在编码中直接使用. 如果两个操作之间的关系不在此列, 并且无法从下列规则推导出来的话, 它们就没有顺序性保障, 虚拟机可以对它们随意地进行重排序:
程序次序规则(Program Order Rule): 在一个线程内, 按照程序代码顺序, 书写在前面的操作先行发生于书写在后面的操作. 准确地说, 应该是控制流顺序而不是程序代码顺序, 因为要考虑分支, 循环等结构.
管程锁定规则(Monitor Lock Rule): 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作. 这里必须强调的是同一个锁, 而 "后面" 是指时间上的先后顺序.
volatile 变量规则(Volatile Variable Rule): 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作, 这里的 "后面" 同样是指时间上的先后顺序.
线程启动规则 (Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作.
线程终止规则 (Thread Termination Rule): 线程中的所有操作都先行发生于对此线程的终止检测, 我们可以通过 Thread.join() 方法结束, Thread.isAlive()的返回值等手段检测到线程已经终止执行.
线程中断规则 (Thread Interruption Rule): 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过 Thread.interrupted()方法检测到是否有中断发生.
对象终结规则 (Finalizer Rule): 一个对象的初始化完成(构造函数执行结束) 先行发生于它的 finalize()方法的开始.
传递性(Transitivity): 如果操作 A 先行发生于操作 B, 操作 B 先行发生于操作 C, 那就可以得出操作 A 先行发生于操作 C 的结论.
这边举个列子来帮助理解 happens-before 原则:
- private int value=0;
- pubilc void setValue(int value){
- this.value=value;
- }
- public int getValue(){
- return value;
- }
假设两个线程 A 和 B, 线程 A 先 (在时间上先) 调用了这个对象的 setValue(1), 接着线程 B 调用 getValue 方法, 那么 B 的返回值是多少?
对照着 hp 原则, 上面的操作不满下面的任何条件:
不是同一个线程, 所以不涉及: 程序次序规则;
不涉及同步, 所以不涉及: 管程锁定规则;
没有 volatile 关键字, 所以不涉及: volatile 变量规则
没有线程的启动, 中断, 终止, 所以不涉及: 线程启动规则, 线程终止规则, 线程中断规则
没有对象的创建于终结, 所以不涉及: 对象终结规则
更没有涉及到传递性
所以一条规则都不满足, 尽管线程 A 在时间上与线程 B 具有先后顺序, 但是, 却并不满足 hp 原则, 也就是有序性并不会保障, 所以线程 B 的数据获取是不安全的!!
时间先后顺序与先行发生原则之间基本没有太大的关系, 所以我们衡量并发安全问题的时候不要受到时间顺序的干扰, 一切必须以先行发生原则为准. 只有真正满足了 happens-before 原则, 才能保障安全.
如果不能满足 happens-before 原则, 就需要使用下面的 synchronized 机制和 volatile 机制机制来保证有序性.
synchronized 机制
volatile 机制
volatile 的底层是使用内存屏障来保证有序性的.
内存屏障有两个能力:
就像一套栅栏分割前后的代码, 阻止栅栏前后的没有数据依赖性的代码进行指令重排序, 保证程序在一定程度上的有序性.
强制把写缓冲区 / 高速缓存中的脏数据等写回主内存, 让缓存中相应的数据失效, 保证数据的可见性.
简单总结
特性 | volatile 关键字 | synchronized 关键字 | Lock 接口 | Atomic 变量 |
---|---|---|---|---|
原子性 | 无法保障 | 可以保障 | 可以保障 | 可以保障 |
可见性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 一定程度保障 | 可以保障 | 可以保障 | 无法保障 |
参考
- https://www.cnblogs.com/ssl-bl/p/11076232.html
- https://www.cnblogs.com/noteless/p/10401193.html
来源: https://www.cnblogs.com/54chensongxia/p/12120117.html