Java多线程之volatile关键字及内存屏障实例解析
作者:老胡
前面一篇文章在介绍Java内存模型的三大特性(原子性、可见性、有序性)时,在可见性和有序性中都提到了volatile关键字,那这篇文章就来介绍volatile关键字的内存语义以及实现其特性的内存屏障。
volatile是JVM提供的一种最轻量级的同步机制,因为Java内存模型为volatile定义特殊的访问规则,使其可以实现Java内存模型中的两大特性:可见性和有序性。正因为volatile关键字具有这两大特性,所以我们可以使用volatile关键字解决多线程中的某些同步问题。
volatile的可见性
volatile的可见性是指当一个变量被volatile修饰后,这个变量就对所有线程均可见。白话点就是说当一个线程修改了一个volatile修饰的变量后,其他线程可以立刻得知这个变量的修改,拿到最这个变量最新的值。
结合前一篇文章提到的Java内存模型中线程、工作内存、主内存的交互关系,我们对volatile的可见性也可以这么理解,定义为volatile修饰的变量,在线程对其进行写入操作时不会把值缓存在工作内存中,而是直接把修改后的值刷新回写到主内存,而当处理器监控到其他线程中该变量在主内存中的内存地址发生变化时,会让这些线程重新到主内存中拷贝这个变量的最新值到工作内存中,而不是继续使用工作内存中旧的缓存。
下面我列举一个利用volatile可见性解决多线程并发安全的示例:
public class VolatileDemo { //private static boolean isReady = false; private static volatile boolean isReady = false; static class ReadyThread extends Thread { public void run() { while (!isReady) { } System.out.println("ReadyThread finish"); } } public static void main(String[] args) throws InterruptedException { new ReadyThread().start(); Thread.sleep(1000);//sleep 1秒钟确保ReadyThread线程已经开始执行 isReady = true; } }
上面这段代码运行之后最终会在控制台打印出: ReadyThread finish ,而当你将变量isReady的volatile修饰符去掉之后再运行则会发现程序一直运行而不结束,而控制台也没有任何打印输出。
我们分析下这个程序:初始时isReady为false,所以ReadyThread线程启动开始执行后,它的while代码块因标志位isReady为false会进入死循环,当用volatile关键字修饰isReady时,main方法所在的线程将isReady修改为true之后,ReadyThread线程会立刻得知并获取这个最新的isReady值,紧接着while循环就会结束循环,所以最后打印出了相关文字。而当未用volatile修饰时,main方法所在的线程虽然修改了isReady变量,但ReadyThread线程并不知道这个修改,所以使用的还是之前的旧值,因此会一直死循环执行while语句。
volatile的有序性
有序性是指程序代码的执行是按照代码的实现顺序来按序执行的。
volatile的有序性特性则是指禁止JVM指令重排优化。
我们来看一个例子:
public class Singleton { private static Singleton instance = null; //private static volatile Singleton instance = null; private Singleton() { } public static Singleton getInstance() { //第一次判断 if(instance == null) { synchronized (Singleton.class) { if(instance == null) { //初始化,并非原子操作 instance = new Singleton(); } } } return instance; } }
上面的代码是一个很常见的单例模式实现方式,但是上述代码在多线程环境下是有问题的。为什么呢,问题出在instance对象的初始化上,因为 instance = new Singleton();
这个初始化操作并不是原子的,在JVM上会对应下面的几条指令:
memory =allocate(); //1. 分配对象的内存空间 ctorInstance(memory); //2. 初始化对象 instance =memory; //3. 设置instance指向刚分配的内存地址
上面三个指令中,步骤2依赖步骤1,但是步骤3不依赖步骤2,所以JVM可能针对他们进行指令重拍序优化,重排后的指令如下:
memory =allocate(); //1. 分配对象的内存空间 instance =memory; //3. 设置instance指向刚分配的内存地址 ctorInstance(memory); //2. 初始化对象
这样优化之后,内存的初始化被放到了instance分配内存地址的后面,这样的话当线程1执行步骤3这段赋值指令后,刚好有另外一个线程2进入getInstance方法判断instance不为null,这个时候线程2拿到的instance对应的内存其实还未初始化,这个时候拿去使用就会导致出错。
所以我们在用这种方式实现单例模式时,会使用volatile关键字修饰instance变量,这是因为volatile关键字除了可以保证变量可见性之外,还具有防止指令重排序的作用。当用volatile修饰instance之后,JVM执行时就不会对上面提到的初始化指令进行重排序优化,这样也就不会出现多线程安全问题了。
volatile使用场景
volatile的可以在以下场景中使用:
当运算结果不依赖变量当前的值,或者能确保只有单一线程修改变量的值的时候,我们才可以对该变量使用volatile关键字
变量不需要与其他状态变量共同参与不变约束
volatile与原子性
volatile关键字能保证变量的可见性和代码的有序性,但是不能保证变量的原子性,下面我再举一个volatile与原子性的例子:
public class VolatileTest { public static volatile int count = 0; public static void increase() { count++; } public static void main(String[] args) { Thread[] threads = new Thread[20]; for(int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { for(int j = 0; j < 1000; j++) { increase(); } }); threads[i].start(); } //等待所有累加线程结束 while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(count); } }
上面这段代码创建了20个线程,每个线程对变量count进行1000次自增操作,如果这段代码并发正常的话,结果应该是20000,但实际运行过程中经常会出现小于20000的结果,因为count++这个自增操作不是原子操作。
上面的count++自增操作等价于count=count+1,所以JVM需要先读取count的值,然后在count的基础上给它加1,然后再将新的值重新赋值给count变量,所以这个自增总共需要三步。
上图中我将线程对count的自增操作画了个简单的流程,一个线程要对count进行自增时要先读取count的值,然后在当前count值的基础上进行count+1操作,最后将count的新值重新写回到count。
如果线程2在线程1读取count旧值写回count新值期间读取count的值,显然这个时候线程2读取的是count还未更新的旧值,这时两个线程是对同一个值进行了+1操作,这样这两个线程就没有对count实现累加效果,相反这些操作却又没有违反volatile的定义,所以这种情况下使用volatile依然会存在多线程并发安全的问题。
volatile与内存屏障
前面介绍了volatile的可见性和有序性,那JVM到底是如何为volatile关键字实现的这两大特性呢,Java内存模型其实是通过内存屏障(Memory Barrier)来实现的。
内存屏障其实也是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令来禁止特定的指令重排序。
另外内存屏障还具有一定的语义:内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。
下面的表是volatile有关的禁止指令重排的行为:
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
从上面的表我们可以得出下面这些结论:
当第二个操作volatile写时,不论第一个操作是什么,都不能重排序。这个规则保证了volatile写之前的操作不会被重排到volatile写之后。
当第一个操作为volatile读时,不论第二个操作是什么,都不能重排。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
当第一个操作为volatile写,第二个操作为volatile读时,不能重排。
JVM中提供了四类内存屏障指令:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore | Store1; StoreStore; Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1; LoadStore; Store2 | 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
总结
volatile实现了Java内存模型中的可见性和有序性,它的这两大特性则是通过内存屏障来实现的,同时volatile无法保证原子性。
以上所述是小编给大家介绍的Java多线程之volatile关键字及内存屏障实例解析,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对脚本之家网站的支持!