我们先看这段代码
class MyCounter {
public int flag = 0;
}
public class ThreadDemo15 {
public static void main(String[] args) {
MyCounter myCounter = new MyCounter();
Thread t1 = new Thread(() -> {
while (myCounter.flag == 0) {
// 这个循环体咱们就空着
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码的逻辑是,t1 有个循环,flag 作为判断循环条件的变量,t2 修改这个变量,修改完了 t1 线程按道理也就结束了。
但运行出来的结果却并没有结束,这个情况叫做“内存可见性问题”,即一个线程针对一个变量进行读取操作,另一个线程针对这个变量进行修改,此时读到的值,不一定是修改后的,因为这个变量没有感知到变量的变化。(编译器 / JVM在多线程环境下优化时产生了误判)
这里使用汇编来理解大概就是以下两步操作:
1. load,把内存中的 flag 值读到寄存器中
2. cmp,把寄存器中的值和 0 进行比较,然后根据结果决定下一步(条件跳转指令)
由于 load 执行速度太慢(相对于 cmp ),再加上反复 load 的结果都是一样的,JVM 就做了一个大胆的决定,只进行一次 load。不再真正的重复 load 了,因为判定没人修改 flag 值(编译器优化的一种方式)。
此时,就需要程序猿手动进行过干预了 → 给 flag 这个变量加上 volatile 关键字,意思是告诉编译器这个变量是“易变的”,这个时候编译器就不会进行这种激进式的优化了。
static class Counter {
public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.
从 JMM(Java Memory Model、Java内存模型)的角度表述内存可见性问题:
在 Java 程序里,有主内存,每个线程也有自己的工作内存 ( t1 和 t2 的工作内存不是同一个东西 ) t1 线程在进行读取的时候,只是读取了工作内存中的值. t2 线程在进行修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中 但是由于编译器优化,导致 t1 没有重新从主内存中同步数据到工作内存,因此读到的结果就是“修改之前”的。
把“主内存”代替为“内存”,“工作内存”代替为“工作存储区”这样就好理解了。工作内存(工作储存区)并非是所说的内存,而是 CPU 寄存器 + CPU 的 cache(缓存)
volatile 能保证内存可见性
volatile 修饰的变量, 能够保证 "内存可见性"
代码在写入 volatile 修饰的变量的时候
改变线程工作内存中volatile变量副本的值
将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
从主内存中读取volatile变量的最新值到线程的工作内存中
从工作内存中读取volatile变量的副本
前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度
非常快, 但是可能出现数据不一致的情况.
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.
volatile 不保证原子性
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性。因此如果某个代码既要考虑原子性也要考虑内存可见性,就把 synchronized 和 volatile 都用上即可。