文章目录
一、线程三特性
学习volatile关键字之前,先了解下java线程安全在内存级别的三个特性,保证了这三个特性就保证了线程安全
1.1 原子性
原子性是指一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。在JMM层面,原子性就是保证指令不会受到线程上下文切换的影响。synchronized、ReentrantLock都可以保证代码块的原子性。
1.2 可见性
可见性是指当一个线程修改了某一个共享变量的值,其他线程能够立即知道这个修改。在JMM层面,可见性就是保证指令不受cpu缓存的影响。synchronized、ReentrantLock、volatile都可以保证共享变量的可见性。
1.2.1 可见性VS原子性
可见性仅用在一个写线程,多个读线程的情况,不能解决指令交错的问题。从字节码理解下可见性和原子性的区别:
主线程修改之后,t线程马上看到修改后的值,说明具备可见性了
上述情况,一个线程i++,一个线程i–,由于指令交错,最终得到-1。这个过程中i的值对线程始终可见,因此在多个线程写操作时,保证可见性是不够的。
1.3 有序性
在并发时,程序的执行可能会出现乱序。因为程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。在JMM层面,有序性就是保证指令不受CPU指令并行优化的影响。volatile可以禁止关键字前后的代码指令重排,一定程度上保证了有序性。
二、volatile使用
2.1 解决可见性
先看一个代码示例
//无法结束的while循环
public class Test1 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (run) {
// System.out.println(run);
}
}).start();
Thread.sleep(100);
run=false;
}
}
从内存的角度分析
- 子线程从主内存(方法区)读取run的值到工作内存(虚拟机栈)
- 线程中的while循环会频繁从主内存读取数据到工作内存,JIT(即时编译器)将run的值缓存到高速缓存(CPU)缓存,减少对run的访问,提高效率
- 之后主线程修改了run的值,但是子线程还是从缓存中取原来值,这就导致了run的值修改对其他线程不可见
改成如下即可解决
static volatile boolean run = true;
2.2 解决有序性
先看代码示例
public class Disorder {
private static int num = 0, r = 0;
private static boolean ready = false;
//无论这两个线程怎么排列组合 如果没有重排序,一定不会存在num=2 且 r=0
public static void main(String[] args) throws InterruptedException {
int i = 0;
//不停的死循环
while (true) {
i++;
num = 0;
r = 0;
ready = false;
Thread t1 = new Thread(() -> {
if (ready) {
r = num + num;
} else {
r = 1;
}
});
Thread t2 = new Thread(() -> {
//如果不存在乱序,num=2一定在ready = true前面
num = 2;
ready = true;
});
t2.start();
t1.start();
t2.join();
t1.join();
String result = "第" + i + "次(" + num + "," + r + ")";
if (num == 2 && r == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
最终出现了r=0,说明ready=true的指令在num=2之前执行了,此时t1线程判断ready为真A,进入r=num+num得到0。
在变量ready前加上volatile关键字即可解决。
三、volatile原理
3.1 内存屏障
volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)。内存屏障是一个抽象概念,可以有不同的实现方式达到相同的效果即可。
CPU提供了三个汇编指令串行化运行读写指令达到实现保证读写有序性的目的
- SFENCE:在该指令前的写操作必须在该指令后的写操作前完成
- LFENCE:在该指令前的读操作必须在该指令后的读操作前完成
- MFENCE:在该指令前的读写操作必须在该指令后的读写操作前完成
但是,Java的volatile在实现层面用的不是fence族屏障,而是lock。lock锁用来控制cpu对一个内存区域的访问权限,具体的这里不再深入讨论。他们都能达到内存屏障的效果,在字节码指令层面可以这样理解:
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
3.2 保证可见性
分析上面的案例,volatile修饰ready,加入了读写屏障之后的时序图:
- 写屏障保证在它之前对共享变量的改动都同步到主存
Thread t2 = new Thread(() -> {
//如果不存在乱序,num=2一定在ready = true前面
num = 2;
ready = true; //ready 被volatile修饰,带写屏障
//写屏障
});
- 读屏障保证在它之后对共享变量的读取都是从主存中
Thread t1 = new Thread(() -> {
//读屏障
if (ready) {
r = num + num;
} else {
r = 1;
}
});
3.3 保证有序性
- 写屏障会确保,指令重排序时不会将写屏障前的代码,重排序到写屏障之后
Thread t2 = new Thread(() -> {
//如果不存在乱序,num=2一定在ready = true前面
num = 2;
ready = true; //ready 被volatile修饰,带写屏障
//写屏障, num = 2不可能重排序到这里
});
- 读屏障确保,指令重排序时不会将读屏障后的代码,重排序到读屏障之前
Thread t1 = new Thread(() -> {
//读屏障
if (ready) {
r = num + num;
} else {
r = 1;
}
});
3.4 不保证原子性
volatile不能保证原子性,例如两个线程对同一共享变量i(volatile修饰)操作,一个做i++,另一个i–。看下执行过程
原因是volatile只能保证本线程内部的相关代码不能重排序,而线程间对共享变量操作的顺序由CPU调度决定,还是会产生指令交错的问题。
四、经典案例
4.1 线程安全单例
4.1.1 DCL线程安全单例
DCL全称double-checked locking,先看下普通线程安全单例
public class NormalSingleton {
private NormalSingleton() {
}
private static NormalSingleton singleton = null;
//给方法加上synchronized,其实第一次new的时候加锁即可,后面直接返回无需加锁
public static synchronized NormalSingleton getInstance() {
if (singleton != null) {
return singleton;
}
singleton = new NormalSingleton();
return singleton;
}
}
优化一下,双重检查缩小加锁范围,增强性能
public class DCLSingleton {
private DCLSingleton() {
}
private static DCLSingleton singleton = null;
public static DCLSingleton getInstance() {
if (singleton != null) {
return singleton;
}
synchronized (DCLSingleton.class) {
//两次检查,防止new执行多次
if (singleton != null) {
return singleton;
}
singleton = new DCLSingleton();
return singleton;
}
}
}
优化之后还有一个隐蔽的问题,new DCLSingleton();在字节码层面不是一条指令,可能发生指令重排。字节码如下:
public class _volatile.DCLSingleton {
public static _volatile.DCLSingleton getInstance();
Code:
0: getstatic #2 // Field singleton:L_volatile/DCLSingleton;
3: ifnull 10
6: getstatic #2 // Field singleton:L_volatile/DCLSingleton;
9: areturn
10: ldc #3 // class _volatile/DCLSingleton
12: dup
13: astore_0
14: monitorenter
15: getstatic #2 // Field singleton:L_volatile/DCLSingleton;
18: ifnull 27
21: getstatic #2 // Field singleton:L_volatile/DCLSingleton;
24: aload_0
25: monitorexit
26: areturn
27: new #3 // 创建对象,将对象引用入栈
30: dup // 复制一份对象应用
31: invokespecial #4 // 利用对象引用调用构造方法 Method "<init>":()V
34: putstatic #2 // 赋值对象引用给静态变量singleton
37: getstatic #2 // Field singleton:L_volatile/DCLSingleton;
40: aload_0
41: monitorexit
42: areturn
43: astore_1
44: aload_0
45: monitorexit
46: aload_1
47: athrow
Exception table:
from to target type
15 26 43 any
27 42 43 any
43 46 43 any
static {};
Code:
0: aconst_null
1: putstatic #2 // Field singleton:L_volatile/DCLSingleton;
4: return
}
分析得知,如果发生指令重排,先将地址引用赋值给singleton再去执行构造方法(34在31之前执行)。这就有可能导致其他线程,在第一个判断非空之后拿到一个尚未初始化完成的对象去使用,产生一些不可预知的问题。解决方法同样是加volatile:private static volatile NormalSingleton singleton = null;
结合这个问题,说明synchronized代码块中的指令可以发生重排,不能保证有序性。但是,如果共享变量的读写操作都被synchronized保护,即使发生指令重排也不会产生有序性问题(不受指令重排优化的影响)。因此,在此前提下也可以说synchronized能保证有序性,原子性,可见性。
4.1.2 枚举单例
enum EnumSingleton {
INSTANCE
}
枚举只有一个值时,就是单例了,而且默认是静态属性,由JVM保证了线程安全。而且枚举类不能使用反序列化或者反射破坏单例。静态成员变量在类加载时初始化,属于饿汉式单例。
4.1.3 静态内部类单例
public class StaticInnerClassSingleton {
private StaticInnerClassSingleton() {
}
private static class InnerClass {
static final StaticInnerClassSingleton singleton = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance() {
return InnerClass.singleton;
}
}
类加载也是懒加载的,在没有调用getInstance方法时,静态内部类InnerClass不会被加载。一旦调用了getInstance,触发InnerClass加载,同时静态成员变量singleton就会被初始化。类加载和静态成员变量初始化由JVM提供了线程安全保障。因此静态内部类单例是线程安全的,也是懒汉式的。
4.2 happens-before 规则