Volatile是和内存可见性问题是密切相关的。先看下面一段代码,执行结果是什么?
class MyCount{ public int flag = 0; } public class ThreadDemo15 { public static void main(String[] args) { MyCount myCount = new MyCount(); Thread t1 = new Thread(()->{ while (myCount.flag == 0){ //循环体代码 } System.out.println("t1 线程循环执行结束"); }); Thread t2 = new Thread(()->{ Scanner sc = new Scanner(System.in); System.out.println("给flag赋予非0值"); myCount.flag = sc.nextInt(); }); t1.start(); t2.start(); } }
运行结果如下:
我们的预期是t2把flag改成非О的值之后, t1随之就结束循环了。但是,此时无论怎么给flag赋予非零值,t1线程一直处理循环没有结束,也就是说t1线程拿到的flag是0。
这种情况就是一种内存可见性问题,这段代码实际上是有bug的,涉及到一个线程读一个线程修改,是一个线程不安全问题。下面再来探究一下为什么t1线程总是拿到的是0:
t1线程这里循环体的判断是要分成两个步骤的,先是load,把内存中flag的值读取到寄存器里,然后再是cmp,把寄存器的值和0进行比较,CPU针对寄存器的操作要比内存操作快3-4个数量级,计算机对于内存的操作比硬盘快3-4个数量级。根据比较结果,决定下一步往哪个地方执行。在t2线程真正修改flag值之前,t1线程的循环已经执行了很多次了,而且t1线程load的结果都是一样的。这里会涉及到编译器的优化问题,由于load是在内存中进行加载,执行的速度太慢了(相对于cmp来说),在加上反复load的结果是一样的,JVM就认为不用再重复的load了,认为flag的值没有修改,认为只读一次就好了,这就是编译器的一种优化方式。
一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定就是修改后的值,这个读线程没有感知到变量的变化,这就是一种内存可见性问题。此时需要手动干预,可以给flag这个变量加上volatile关键字,这样每一次的读取操作都要重新读取到这个变量的内存内容,就不会进行优化操作了。
class MyCount{ volatile public int flag = 0; }
下面是来自于Java官方文档的对于内存可见性问题的解释:
从JMM(Java内存模型)的角度重新表述内存可见性问题:
Java程序里,有主内存,每个线程还有自己的工作内存。
t1线程进行读取的时候,只是读取了自己工作内存的值。
t2线程进行修改的时候,先修改的自己工作内存的值,然后再把工作内存的内容同步到主内存中。但是由于编译器优化,导致t1没有重新的从主内存同步数据到自己工作内存,读到的结果就是"修改之前”的结果。
在最开始的代码解释中,我只用了内存和寄存器两种概念,但是其实内存和寄存器之间的存储读取的速度差异实在是太大了,在他们中间还有一种高速缓存器cache,因为不同cup的cache不一样,所以应该是为了避免这种差异,统一叫做工作内存。
总结:volatile是不保证原子性的,原子性是靠synchronized来保证的。但是volatile和synchronized都能保证线程安全。在多线程中,针对同一个变量,一个线程进行读取,一个线程进行修改,那么加上volatile关键字可以保证线程安全问题。