1. 内存可见性问题
内存可见性的概念
什么是内存可见性问题呢?
- 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
- 在Java中,可以借助 synchronized、volatile 以及各种Lock 实现可见性。
- 如果我们将变量声明为 volatile,这就指示JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取,不能优化。
编译器优化
造成内存可见性问题的原因,是因为编译器优化的机制而造成的,我们先介绍一下什么是编译器优化。
背景
为什么要有编译器优化这样的机制呢?由于程序员的水平参差不齐,研究JDK 的大佬们,就希望通过让 编译器 & JVM 对程序员写的代码,自动的进行优化;
优点
- 对于程序员原有的代码,编译器/JVM会在原有逻辑不变的前提下,对代码进行调整,使程序效率更高;这样的操作,就是编译器优化。
- 也就是说,我们写的代码,编译器会在原有逻辑上,帮我们调整代码,通过编译器优化,可保证原有逻辑不变的前提下,对代码进行修改,使得执行效率得到提高。
缺点
- 编辑器在编译代码的时候,其实它并没有执行代码,它只是根据这个编译的一个静态的代码,来分析这个程序应该怎么去进行调整;所以编译器 “保证原因逻辑不变,再进行优化 ”,这样的 “保证” 并非是能够 100% 生效的;
- 尤其是很多线线程的程序中,因为并发编程随机调度的特点,使得执行多线程程序的过程中,可能会出现诸多变数,这些变数可能会导致编译器出现判断失误;
- 因此,编译器在针对不同的程序,能够做出的判断是有限,并且容易失误的。所以在经过编译器优化后的代码逻辑,与优化前的逻辑,可能会出现偏差。
案例描述
内存可见性问题是造成线程安全问题的原因之一,我们通过下面的代码来感受一下,内存可见性是如何造成线程安全问题的:
代码逻辑:
- 我们定义一个成员变量 flag ,用来作为 t1 线程 run() 方法中的循环终止条件;
- t2 线程用来修改 flag 的值;
- 这样的操作,相当于一个线程进行读取,另一个线程进行修改;
原子性问题和可见性问题代码演示的区别
- 这里演示因为内存可见性,造成线程安全问题,是令一个线程进行读取另一个线程进行修改;
- 演示因为操作非原子性,而造成线程安全问题,是令两个线程同时修改同一个变量,所以两个线程都是在进行修改操作;
预期效果:
- 只要我们通过 t2 ,输入给 flag 的数字是一个非零的值,就会使得我们 t1 线程的循环能够结束;
程序运行结果:
但是当我真正输入一个非零值的时候,回车,发现 t1 线程并没有结束循环,打印结束日志。
通过 Jconsole 观察 t1 线程的状态
- 因为 t2 线程只有一次输入修改 flag 的操作,已经终止;
- 观察到 t1 的线程状态是Runnable,正在持续执行循环;
- 在 t2 线程输入非零值,能让 t1 线程循环结束,进而 t1 终止,这是我们预期结果;
- 但是实际执行结果却并非如此;
- 一个线程读取,一个线程修改,t2(修改线程) 修改的值,并没有被 t1(读取线程)读到,这就是因为编译器优化而造成的"内存可见性问题”;
分析出现内存可见性问题的原因
对于上述代码中,t1 线程的循环判断条件 flag== 0,对其进行细分,会分出两个指令,分别是比较指令(==)和读取指令(读flag);
程序会先执行读取指令 load ,只有把 flag 这个变量在内存中的值,读取到寄存器中,才会执行比较指令 cmp;
而因为 load,cmp 两步指令是在循环中完成的,while 循环如果没有休眠限制,会在短时间内循环多次,从而重复执行多次 load -> cmp 这样的指令。
但是,load (读内存操作)和 cmp (纯CPU寄存器操作)两步指令的开销是非常大的;
load 的时间开销是 cmp 的几千倍,因为虽然读内存数据比读硬盘数据要快很多,但是如果是拿CPU寄存器和内存比,那就是寄存器快很多;
因此,在 t1 创建好后,run() 方法执行的时间,几乎都在load,cmp 的时间开销是可以忽略不计的;
所以在执行的过程中,JVM就能感知到,load 反复执行的结果是一样的;哪怕我们通过 t2 的 scanner 输入 flag 的时间只有不到 1s,但是站在计算机的角度,这 1s 可以说是沧海桑田;
因此,程序在执行的过程中,JVM 会感受到程序一直在反复读内存的值;
为了减小时间开销, t1 线程的读操作,会被编译器优化成:从读内存的值,到读CPU寄存器(t1 线程的工作内存)的值;
后续再执行 load 指令,就不会再重新读内存,而是直接从寄存器(工作内存)中读取,从而大大减小开销,并且提高了效率;
于是,等到很多秒后,用户真正输入新的值,真正修改 flag 的值,此时 t1 线程就感知不到了
(编译器优化,使得 t1 线程的读操作,不是读取内存)
2. JMM模型文档
JMM(Java 内存模型)详解
3. volatile
volatile 能保证内存可见性
内存可见性就是保证, 每次去读取的时候, 读取到的值都是最新的值(内存中的值),而不是之前缓存在寄存器中的值;volatile 修饰的变量,能够保证"内存可见性";
代码在写入 volatile 修饰的变量的时候:
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
- 从主内存中读取 volatile 变量的最新值到线程的工作内存中
- 从工作内存中读取 volatile 变量的副本
前面我们讨论内存可见性时说了,直接访问工作内存(实际是CPU 的寄存器或者 CPU 的缓存),速度
非常快,但是可能出现数据不一致的情况;
加上 volatile,强制读写内存,速度是慢了,但是数据变的更准确了: