上次我们提到在代码中加上volatile就可以防止JVM的指令重排问题,这节我们来详细讲解一下volatile关键字。
1. volatile保证可见性
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
我们先来介绍一下读屏障和写屏障是什么。
-
写屏障(sfence,Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) { num = 2; ready = true; // ready 是 volatile 赋值带写屏障 // 写屏障 }
-
读屏障(lfence,Load Barrier)保证在该屏障之后的,对共享变量的读取,从主存刷新变量值,加载的是主存中最新数据
public void actor1(I_Result r) { // 读屏障 // ready 是 volatile 读取值带读屏障 if(ready) { r.r1 = num + num; } else { r.r1 = 1; } }
-
全能屏障:mfence(modify/mix Barrier),兼具 sfence 和 lfence 的功能
2. volatile保证有序性
很明显,利用读屏障和写屏障也能够保障指令的有序性。
-
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) { num = 2; ready = true; // ready 是 volatile 赋值带写屏障 // 写屏障 }
-
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) { // 读屏障 // ready 是 volatile 读取值带读屏障 if(ready) { r.r1 = num + num; } else { r.r1 = 1; } }
注意:
-
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到写屏障之前
-
有序性的保证也只是保证了本线程内相关代码不被重排序
3. 缓存一致
使用 volatile 修饰的共享变量,底层通过汇编 lock 前缀指令进行缓存锁定,在线程修改完共享变量后写回主存,其他的 CPU 核心上运行的线程通过 CPU 总线嗅探机制会修改其共享变量为失效状态,读取时会重新从主内存中读取最新的数据
lock 前缀指令就相当于内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
内存屏障有三个作用:
- 确保对内存的读-改-写操作原子执行
- 阻止屏障两侧的指令重排序
- 强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效
多线程中,因为JIT编译器优化就会导致读取不到最新的值,所以要阻止这种优化。指令重排这个前面老师又讲,这里主要是将没有讲的JVM原理结合
4. DCL问题
4.1 问题出现
DCL即Double-Checked Locking,双端检锁机制,我们咦单例模式为例看看这个问题的出现:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
上述代码当多线程进行访问的时候,可能会不断地尝试占用锁,这样对性能有很大的影响,于是我们可以尝试如下改进,在进入同步模块之前,首先判断 INSTANCE
变量是否是null,如果是的话,再进入同步代码块,代码如下:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null){
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
上面的代码优化了操作,性能提高了不少,但是在多线程环境下,还是有一些问题,即DCL问题。
我们来看一下上述代码的字节码:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
我们来看最核心的,也就是产生问题的几行字节码:
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 引用地址
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
产生的问题依旧是JVM的重排问题。
也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例,这就会导致其进入阻塞队列。
4.2 问题解决
可以将INSTANCE变量加一个volatile,上面的代码改为:
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
字节码上看不出来 volatile 指令的效果,我们可以根据读写屏障来进行分析:
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
-
可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
-
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
-
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
流程如下:
注意:
- 在synchronized中的语句是可以被JVM重排的
- 如果变量只出现在synchronized语句中,是可以保证有序性的
4.3 问题细节
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用getInstance)时的线程安全。
-
// 问题1:为什么加 final // 原因:防止以后有子类创建新的实例,子类重写破坏单例,加了final就不允许被继承 // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例 // 原因:需要加入下述代码,当反序列化时,系统会调用 readResolve() 方法,从而确保返回的是单例对象,加入下面的readResovle方法 public final class Singleton implements Serializable { // 问题3:为什么设置为私有? 是否能防止反射创建新的实例? // 原因:私有不能防止反射 private Singleton() {} // 问题4:这样初始化是否能保证单例对象创建时的线程安全? // 原因:能,静态成员是由JVM在类加载阶段完成,该阶段由JVM保证线程安全 private static final Singleton INSTANCE = new Singleton(); // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由 // 原因: 提供静态方法可以提供更多的控制,比如在获取单例对象时可以进行额外的处理。而直接将 INSTANCE 设置为 public 则失去了这种控制的能力。此外,静态方法也可以用来延迟实例化,即在第一次调用时再创建实例,而直接将 INSTANCE 设置为 public 则无法实现延迟加载。 public static Singleton getInstance() { return INSTANCE; } public Object readResolve() { return INSTANCE; } }
-
// 问题1:枚举单例是如何限制实例个数的 // 原因: 枚举类型在 Java 中保证只有一个实例的方式是 JVM 在加载枚举类的时候,会保证每个枚举值在 JVM 中只有一个实例,因此枚举类型天生就是单例的。在枚举中,每个枚举值都是枚举类型的一个实例 // 问题2:枚举单例在创建时是否有并发问题 // 原因: 枚举单例在创建时没有并发问题。因为枚举类型的实例是在类加载阶段就被创建好的,并且由 JVM 来保证线程安全 // 问题3:枚举单例能否被反射破坏单例 // 原因: 枚举单例不能被反射破坏单例。因为枚举类型的实例是在类加载的时候被创建好的,并且 JVM 保证每个枚举值在 JVM 中只有一个实例,无法通过反射创建新的实例 // 问题4:枚举单例能否被反序列化破坏单例 // 原因: 枚举单例不能被反序列化破坏单例。因为枚举类型默认实现了 Serializable 接口,同时枚举类型的序列化和反序列化由 JVM 内部处理,保证了在反序列化过程中不会创建新的实例 // 问题5:枚举单例属于懒汉式还是饿汉式 // 原因: 枚举单例属于饿汉式。因为枚举类型的实例是在类加载的时候被创建好的,无论是否被使用,都会在类加载的时候进行实例化 // 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做 // 原因: 枚举类型的实例在类加载时就被创建,所以如果需要在创建时进行初始化逻辑,可以在枚举中定义一个构造方法,并在枚举值中调用该构造方法来进行初始化,如下 enum Singleton { INSTANCE; private Singleton() { // 进行初始化逻辑 } }
-
public final class Singleton { private Singleton() { } private static Singleton INSTANCE = null; // 分析这里的线程安全, 并说明有什么缺点 // 使用synchronized保证了线程安全,但是锁的范围太大了 public static synchronized Singleton getInstance() { if( INSTANCE != null ){ return INSTANCE; } INSTANCE = new Singleton(); return INSTANCE; } }
-
public final class Singleton { private Singleton() { } // 问题1:解释为什么要加 volatile ? // 原因: 使用volatile的读写屏障使得JVM不进行指令重排 private static volatile Singleton INSTANCE = null; // 问题2:对比实现3, 说出这样做的意义 // 原因:这种方式只在实例为 null 时才进行同步,避免了每次调用 getInstance() 方法都需要获取锁的开销 public static Singleton getInstance() { if (INSTANCE != null) { return INSTANCE; } synchronized (Singleton.class) { // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗 // 原因: 防止指令重排导致多个线程进来 if (INSTANCE != null) { // t2 return INSTANCE; } INSTANCE = new Singleton(); return INSTANCE; } } }
-
public final class Singleton { private Singleton() { } // 问题1:属于懒汉式还是饿汉式 // 原因: 属于是懒汉式,因为该对象在类加载的时候才加载,其他地方都不进行加载 private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); } // 问题2:在创建时是否有并发问题 // 类加载阶段进行加载,由JVM保证线程安全,没有并发问题 public static Singleton getInstance() { return LazyHolder.INSTANCE; } }
5. Balking设计模式
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,比如说下面的代码,多次调用 start()
方法,会产生多个监控记录,
@Slf4j(topic = "c.Test20")
public class Test1 {
volatile static boolean run = true; //添加volatile
final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
tpt.start();
tpt.start();
}
}
class TwoPhaseTermination {
// 监控线程
private Thread monitor;
// 停止标记
private volatile boolean stop = false;;
// 启动监控线程
public void start() {
monitor = new Thread(() -> {
while (true) {
Thread thread = Thread.currentThread();
if (stop) {
System.out.println("后置处理");
break;
}
try {
Thread.sleep(1000);// 睡眠
System.out.println(thread.getName() + "执行监控记录");
} catch (InterruptedException e) {
System.out.println("被打断,退出睡眠");
}
}
});
monitor.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitor.interrupt();// 让线程尽快退出Timed Waiting
}
}
多个监控记录如下:
Thread-1执行监控记录
Thread-0执行监控记录
Thread-2执行监控记录
Thread-2执行监控记录
Thread-1执行监控记录
如果想要发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,即只创建一个新的对象,那么可以设置一个标记位用于判断是否执行过 start()
方法,如果执行过,那么直接返回,代码如下:
class TwoPhaseTermination {
// 监控线程
private Thread monitor;
// 停止标记
private volatile boolean stop = false;;
// 判断是否执行过start方法
private boolean starting = false;
// 启动监控线程
public void start() {
// 如果启动过,就直接返回
synchronized (this){
if(starting){
return;
}
starting = true;
}
monitor = new Thread(() -> {
while (true) {
Thread thread = Thread.currentThread();
if (stop) {
System.out.println("后置处理");
break;
}
try {
Thread.sleep(1000);// 睡眠
System.out.println(thread.getName() + "执行监控记录");
} catch (InterruptedException e) {
System.out.println("被打断,退出睡眠");
}
}
});
monitor.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitor.interrupt();// 让线程尽快退出Timed Waiting
}
}