作用
- 保证内存可见性;
首先什么是可见性?见如下例子:flag变量如果没有被volatile修饰,那线程t1会一直while true循环,因为它看不到主线程对flag的修改。
补充:如果while循环里,有sout打印操作,那即使flag不用volatile修饰,while循环也会结束,为什么呢?某些语句会触发内存和缓存数据的同步刷新,点进去看sout的源码,它使用了synchronized 关键字。
public class HelloVolatile {private static /*volatile*/ boolean flag = true;private static void m(){System.out.println("m() start...");while (flag){// 某些语句会触发内存和缓存数据的同步刷新,synchronized同步代码块会//System.out.println("hello m...");}System.out.println("m() end ...");}public static void main(String[] args) throws Exception {new Thread(HelloVolatile::m,"t1").start();Thread.sleep(1000);flag = false;}}
补充:volatile修饰引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段的可见性,不过一般它修饰引用类型很少。如下案例:
public class HelloVolatileReference {private static class A{private /*volatile*/ boolean flag = true;void m(){System.out.println("m start...");while (flag){}System.out.println("m end...");}}private static A a = new A();public static void main(String[] args) throws Exception {new Thread(a::m,"t1").start();Thread.sleep(1000);a.flag=false;}}
- 防止指令重排序;
上文《内存模型》中,我们提到MESI缓存同步带来的CPU资源利用率低(因为通知其他cache是同步),所以为了提高CPU使用效率,它引入了store buffer概念,改成了异步通知;同时在通知cache的时候,那CPU就继续干活,处理下边其他的指令。这样就有可能造成指令重排序,在单线程环境下,CPU指令重排序对程序没有问题。但是多线程环境下,就会造成共享数据错误问题,最常见的案例是 double check 的单例模式。
public class SingletonDemo {private /*volatile*/ static SingletonDemo instance = null;private SingletonDemo() {System.out.println(Thread.currentThread().getName() + "\t 我是构造函数SingletonDemo() 私有化");}//DCL(Double Check Lock双端检锁机制)public static SingletonDemo getInstance() {//第一次检测if (instance == null) {//同步代码块synchronized (SingletonDemo.class) {if (instance == null) {//多线程环境下可能会出现问题的地方instance = new SingletonDemo();}}}return instance;}}
实例化对象instance = new SingletonDemo() 其实在CPU的指令大概可以分为3条,但其实指令重排序,可能CPU是按着132的顺序读取的。因为指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当第一条线程访问instance==null时,执行后边synchronized代码块初始化对象,但由于指令重排序,对象还为null,但其实已经给对象分配了内存。这时候第2个线程又判断instance==null,所以可能会导致单例失败。
memory = allocate(); //1.分配对象内存空间instance(memory); //2.初始化new对象instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null
memory = allocate(); //1.分配对象内存空间instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!instance(memory); //2.初始化new对象
面试的时候必问,那它是如何保证可见性的?又是如何防止指令重排序的?
原理
- 可见性
首先当volatile修饰的变量进行修改操作时,JVM会向CPU处理器发一条lock前缀的指令,将缓存的数据写回到主内存;同时处理器使用嗅探技术保证内部缓存和其他处理器的缓存的数据在总线上保持一致。
它还有一个关系:happends-before规则:
1.程序次序法则:按照代码顺序执行 2.监视器锁法则:一个unlock操作要先于同一个锁的lock操作 3.volatile变量法则:对volatile域的写入操作happends-before于每一个后续对同一域的读操作 4.线程启动法则:在一个线程里,对Thread.start()的调用会先于Thread.run(); 5.线程终结法则:线程中的任何动作都happends-before于其他线程检测到这个线程已经终结,或者从Thread.join 调用中成功返回,或者Thread.isAlive返回false 中断法则:一个线程调用另一个线程的interrupt.happens-before于被中断的线程发现中断。(通过跑出interruptedException,或者调用isInterrupted和interrupted) 6.终结法则:一个对象的构造函数的结束happends-before于这个对象finalizer的开始。 7.传递性:如果A happens-before于B, 且B happends-before 于C, 则A happens-before 于C
- 有序性
volatile是jvm底层使用内存屏障来保证有序性的,被volatile修饰的变量,在程序编译成字节码的时候,会在生成的指令插入内存屏障来防止CPU指令重排序。
1.在每个volatile写操作前插入StoreStore屏障;对于这样的语句Store1; StoreLoad; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 2.在每个volatile写操作后插入StoreLoad屏障;对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 3.在每个volatile读操作前插入LoadLoad屏障;对于这样的语句Load1;LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 4.在每个volatile读操作后插入LoadStore屏障;对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
问题
上文提到了硬件层面保证缓存一致性的两种方法:总线加锁、MESI,那为什么还需要有volatile呢?
MESI是通过CPU多核之间的嗅探机制实现的。volatile限定的是从缓存读取时刻的校验,如果两个CPU同时从各自缓存读取一个变量n=1(此时,变量n在各个CPU缓存上都是有效的),并且同时修改了变量n=n+1,再写回缓存,这个时候n的值等于2,而不是等于3。因此,在多线程操作共享变量(例如:计数器)的时候,正确的方式是使用同步或者Atomic工具类。
