0%

Java volatile关键字以及线程安全

同步与线程安全

线程安全可以说是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); //将value赋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) {
// Quick check for existing instance without full singleton lock
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
synchronized (this.singletonObjects) {
// Consistent creation of early reference within full singleton lock
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,第一个线程就应该退出循环。

但是第一个线程却没有退出。

iCEOtZ.jpeg

这里就需要提到Java的内存模型,也就是JMM(Java Memory Model)。

关于JMM,这里也有一篇文章讲的比较好。

Java内存模型(JMM)总结 - 知乎 (zhihu.com)

JMM规定,所有的变量都存储在主内存中,每一个线程都有一个私有的本地内存,线程对变量的操作必须在本地内存中进行。

因此上面的代码中,由于线程一将flag进行了缓存,被JIT优化之后,就只会读取寄存器中的值,因此不会退出循环。

加上volatile之后,每次读取都会从主存中刷新,就不会再出现这样的问题了。