四、共享模型之内存
上一章讲解的 Monitor 主要关注的是访问共享变量时,保证临界区代码的原子性
这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题
5.1 Java 内存模型
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。(主要是为了程序员不直接面对底层)
JMM 体现在以下几个方面:
原子性 - 保证指令不会受到线程上下文切换的影响
可见性 - 保证指令不会受 cpu 缓存的影响
有序性 - 保证指令不会受 cpu 指令并行优化的影响
主存:所有线程都共享的数据,包括静态成员变量、成员变量等
工作内存:每个线程私有的内存,比如局部变量
5.2 可见性
5.2.1 退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
@Slf4j(topic = "c.Test01") public class Test01 { static boolean run = true; public static void main(String[] args) { Thread t = new Thread(() -> { while (run) { //... } }); t.start(); Sleeper.sleep(0.5); run = false; } }
为什么呢?分析一下:
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
回顾JVM的知识:由于JVM采用JIT和解释器混合执行所以会循环超过1w次以后生成热点代码。此后就不重新编译,直接使用热点代码,所以修改flag的值没用
==提出问题:==一个线程对主存中的数据进行了修改,但是另外一个线程使用的仍然是缓存中的数据,从而出现问题。所以如何解决这个问题呢?
5.2.2 解决方法
方法一:volatile
(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
注意:不能修饰局部变量,因为局部变量是线程私有的
volatile static boolean run = true;
方法二:synchronized
@Slf4j(topic = "c.Test01") public class Test01 { //锁对象 final static Object lock = new Object(); static boolean run = true; public static void main(String[] args) { Thread t = new Thread(() -> { while (true) { synchronized (lock){ if(!run){ break; } } //... } }); t.start(); Sleeper.sleep(1); log.debug("停止 t"); synchronized (lock){ run = false; } run = false; } }
分析:在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁
5.2.3 可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性。volatile仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true getstatic run // 线程 t 获取 run true getstatic run // 线程 t 获取 run true getstatic run // 线程 t 获取 run true putstatic run // 线程 main 修改 run 为 false, 仅此一次 getstatic run // 线程 t 获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错
// 假设i的初始值为0 getstatic i // 线程2-获取静态变量i的值 线程内i=0 getstatic i // 线程1-获取静态变量i的值 线程内i=0 iconst_1 // 线程1-准备常量1 iadd // 线程1-自增 线程内i=1 putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1 iconst_1 // 线程2-准备常量1 isub // 线程2-自减 线程内i=-1 putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低
对于一个写线程,多个读线程的情况,可以使用volatile。并且该操作是轻量级的
思考
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
5.3 有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i; static int j; // 在某个线程内执行如下赋值操作 i = ...; j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...; j = ...;
也可以是
j = ...; i = ...;
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解一下吧
原理之指令级并行*
5.3.1 诡异的结果
int num = 0; boolean ready = false; public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } public void actor2(I_Result r) { num = 2; ready = true; }
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
有同学这么分析:
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
但我告诉你,结果还有可能是 0 😁😁😁,信不信吧!
这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
相信很多人已经晕了 😵😵😵
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:
借助 java 并发压测工具 jcstress https://wiki.openjdk.java.net/display/CodeTools/jcstress
使用下面的命令创建一个maven项目 ,并提供测试类
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=cn.itcast -DartifactId=ordering -Dversion=1.0
测试类:
@JCStressTest @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")//预料之中的 @Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")//感兴趣的 @State public class ConcurrencyTest { int num = 0; boolean ready = false; @Actor public void actor1(I_Result r) { if(ready) { r.r1 = num + num; } else { r.r1 = 1; } } @Actor public void actor2(I_Result r) { num = 2; ready = true; } }
执行
mvn clean install java -jar target/jcstress.jar
会输出我们感兴趣的结果,摘录其中一次结果:
*** INTERESTING tests Some interesting behaviors observed. This is for the plain curiosity. 4 matching test results. [OK] cn.itcast.ConcurrencyTest (JVM args: [-XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM]) Observed state Occurrences Expectation Interpretation 0 32,811 ACCEPTABLE_INTERESTING !!!! 1 108,504,290 ACCEPTABLE ok 4 74,160,270 ACCEPTABLE ok [OK] cn.itcast.ConcurrencyTest (JVM args: [-XX:-TieredCompilation, -XX:+UnlockDiagnosticVMOptions, -XX:+StressLCM, -XX:+StressGCM]) Observed state Occurrences Expectation Interpretation 0 28,770 ACCEPTABLE_INTERESTING !!!! 1 87,596,769 ACCEPTABLE ok 4 91,251,172 ACCEPTABLE ok [OK] cn.itcast.ConcurrencyTest (JVM args: [-XX:-TieredCompilation]) Observed state Occurrences Expectation Interpretation 0 25,764 ACCEPTABLE_INTERESTING !!!! 1 118,604,894 ACCEPTABLE ok 4 59,673,373 ACCEPTABLE ok [OK] cn.itcast.ConcurrencyTest (JVM args: []) Observed state Occurrences Expectation Interpretation 0 19,905 ACCEPTABLE_INTERESTING !!!! 1 116,382,562 ACCEPTABLE ok 4 58,300,934 ACCEPTABLE ok
可以看到,出现结果为 0 的情况有 上万 次,虽然次数相对很少,但毕竟是出现了。
5.3.2 解决方法
volatile
修饰的变量,可以禁用指令重排
volatile boolean ready = false;
测试结果:
*** INTERESTING tests Some interesting behaviors observed. This is for the plain curiosity. 0 matching test results.
* 原理之 volatile
5.3.3 happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
1. 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x; static Object m = new Object(); public void method1() { //假设t1比t2先运行 new Thread(()->{ synchronized(m) { x = 10; } },"t1").start(); new Thread(()->{ synchronized(m) { System.out.println(x); } },"t2").start(); }
分析:synchronized
可以保证原子性和可见性。加锁前要从主存中获取最新值,解锁时要把工作内存的值及时刷回主存
2. 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
//volatile可以保证变量的可见性以及防止指令重排。 volatile static int x; public void method2(){ //假设t1比t2先执行 new Thread(()->{ x = 10; },"t1").start(); new Thread(()->{ System.out.println(x); },"t2").start(); }
3. 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x; public void method3(){ x = 10; new Thread(()->{ System.out.println(x); },"t2").start(); }
4. 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x; public void method4(){ Thread t1 = new Thread(()->{ x = 10; },"t1"); t1.start(); t1.join(); System.out.println(x); }
5. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
t2.interrupted 或 t2.isInterrupted)
static int x; public static void main(String[] args) { Thread t2 = new Thread(()->{ while(true) {//如果不被打断一直空执行 if(Thread.currentThread().isInterrupted()) { System.out.println(x); break; } } },"t2"); t2.start(); new Thread(()->{ sleep(1); x = 10;//为x赋值 t2.interrupt();//打断t2 },"t1").start(); while(!t2.isInterrupted()) {//如果t2没有被打断一直空转 Thread.yield(); } System.out.println(x);//t2被打断后主程序继续往下执行 }
6. 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 默认值是指:类加载过程中,准备阶段,赋的默认值
7. 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
//写屏障会把前面的指令对共享变量的改动都同步到主存中 volatile static int x; static int y; public void method7(){ //假设t1比t2先执行 new Thread(()->{ y = 10; x = 20; },"t1").start(); new Thread(()->{ // x=20 对 t2 可见, 同时 y=10 也对 t2 可见 System.out.println(x); },"t2").start(); }