同步与线程安全
线程安全可以说是Java里老生常谈的问题了,包括Java的synchronize,Lock,以及他们的一些基本原理,monitor enter,monitor exit,和Java的AQS框架。
volatile被称为轻量级的线程同步工具。下面就来看一下volatile关键字的用法以及到底有什么作用。
单例模式
最常见的volatile的用法当然是单例模式的双重检验锁了
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
|
双重检验锁,顾名思义,需要判断两次null。
第一次判断是null了之后,进入synchronize代码块,随后再次判断,如果仍然是null,那么创建对象,然后赋值给singleton,最后返回。这是一个线程安全的单例模式。
那么为什么要判断两次呢。
因为如果只判断一次就进入同步代码块,线程一进入同步代码块之后就被切换到线程二,线程二此时判断仍然是null,因此会开始等待线程一退出同步代码块。当线程一创建对象退出后之后,线程二进入,仍然会创建一个新的对象,此时就出现了两个对象。
但是为什么需要加上volatile才真正的线程安全呢。
这就是volatile的第一个功能,禁止代码重排序。
禁止代码重排序
代码重排序出现有很多原因,编译器优化可能导致指令被重排,现代CPU的流水线设计,乱序执行也已经是非常普遍的功能。
new操作本质是分为三步的,首先分配内存空间,然后再将对象初始化,最后让变量指向那个内存空间。
在Java字节码中也有所体现。
1 2 3 4 5
| public class Test { public void test() { var testObj = new Test(); } }
|
这一段简单的代码,字节码为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| // class version 61.0 (61) // access flags 0x21 public class Test {
// compiled from: Test.java
// access flags 0x1 public <init>()V L0 LINENUMBER 1 L0 ALOAD 0 INVOKESPECIAL java/lang/Object.<init> ()V RETURN L1 LOCALVARIABLE this LTest; L0 L1 0 MAXSTACK = 1 MAXLOCALS = 1
// access flags 0x1 public test()V L0 LINENUMBER 3 L0 NEW Test DUP INVOKESPECIAL Test.<init> ()V ASTORE 1 L1 LINENUMBER 4 L1 RETURN L2 LOCALVARIABLE this LTest; L0 L2 0 LOCALVARIABLE testObj LTest; L1 L2 1 MAXSTACK = 2 MAXLOCALS = 2 }
|
可以看到,这里首先调用了NEW字节码,获得了对象的一个引用,然后调用DUP字节码,将操作数栈顶复制并入栈,这时操作数栈有两个该对象的引用。然后弹出一个引用,并调用方法初始化实例,也就是构造函数以及代码块进行初始化,最后再调用ASTORE将引用赋值到局部变量testObj。
因此,在这里如果不使用volatile关键字,可能会导致赋值引用和初始化实例被重排序,如果第一个线程创建对象之后没有初始化就发生了线程切换,第二个线程在第一个判断中已经不为null,使用该对象时就可能出现问题。
关于volatile如何实现禁止指令重排,这里就不得不提到Java的happens-before原则。网上已经有了很多的资料,这里贴上一篇自认为讲的很不错的文章。
Java内存访问重排序的研究 - 美团技术团队 (meituan.com)
简单来说,禁止指令重排是通过内存屏障来实现。
在Java中定义了4中内存屏障,分别是
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
jdk/orderAccess_linux_x86.hpp at master · openjdk/jdk (github.com)
1 2 3 4
| inline void OrderAccess::loadload() { compiler_barrier(); } inline void OrderAccess::storestore() { compiler_barrier(); } inline void OrderAccess::loadstore() { compiler_barrier(); } inline void OrderAccess::storeload() { fence(); }
|
至于为什么storeload是fence()而其他的是compiler_barrier(),就和x86平台的invalidate queue和store buffer有关了,又会牵扯到MESI缓存一致性协议,这里就不展开了。
当上面单例模式加上volatile之后,变量的初始化和引用赋值将会被禁止重排序,这时就不会再发生上文所说的使用到未初始化完成的对象的问题了。
上面的文章中提到了通过Unsafe. putOrderedObject来对volatile进行优化。
重点的就是下面的代码
1 2 3 4 5
| public void create() { SomeThing temp = new SomeThing(); unsafe.putOrderedObject(this, valueOffset, null); object = temp; }
|
当调用unsafe.putOrderedObject之后,上面的new操作和下面的object = temp之间就隔了一个StoreStore内存屏障,这时就不会出现new未初始化完成就赋值的问题了。
还有一种利用局部变量来减轻volatile影响的优化方案,在Spring中的单例模式中有很好地体现。
spring-framework/DefaultSingletonBeanRegistry.java at main · spring-projects/spring-framework (github.com)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Nullable protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { synchronized (this.singletonObjects) { singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } } } return singletonObject; }
|
这里利用了一个singletonObject来当做局部变量,这时在代码中进行创建的时候就不会因为外部变量的volatile而导致代码中频繁出现内存屏障,提高了性能。
内存可见性
接下来看另外一个例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import java.util.concurrent.TimeUnit;
public class Test { private static boolean flag = true;
public static void main(String[] args) {
new Thread(() -> { System.out.println("进入循环"); while (flag) {
} System.out.println("退出循环"); }).start();
new Thread(() -> { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { throw new RuntimeException(e); } flag = false; System.out.println("已经将flag设置为false"); }).start(); } }
|
在这段代码中,理论上应该当第二个线程睡眠1秒后,将flag设置为false,第一个线程就应该退出循环。
但是第一个线程却没有退出。

这里就需要提到Java的内存模型,也就是JMM(Java Memory Model)。
关于JMM,这里也有一篇文章讲的比较好。
Java内存模型(JMM)总结 - 知乎 (zhihu.com)
JMM规定,所有的变量都存储在主内存中,每一个线程都有一个私有的本地内存,线程对变量的操作必须在本地内存中进行。
因此上面的代码中,由于线程一将flag进行了缓存,被JIT优化之后,就只会读取寄存器中的值,因此不会退出循环。
加上volatile之后,每次读取都会从主存中刷新,就不会再出现这样的问题了。