DLC单例介绍
1.1什么是DCL单例(知道什么是DCL单例的可以忽略1.1)
DCL的全称为 Double Check Lock 中文翻译为,双重检查锁。顾名思义运用双重检查的方式进行加锁
我们先来看一段代码A-1
package com.gxw.first.code.volite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
public class DCLSignal {
private Integer i;
//未用volatile
private static DCLSignal dclSignal; //1-1
//使用volatile
private volatile static DCLSignal dclSignal; //1-2
//构造一个初始值
DCLSignal() {
this.i = 111;
}
//双重检查锁获取单例
public static DCLSignal getInstance() {
if (dclSignal == null ) {
//这行代码是为了造成一些时差,方便验证DCL单例不加volatile的后果
System.out.println("即将进入锁竞争 "+Thread.currentThread().getName());
synchronized (DCLSignal.class) {
if (dclSignal == null) {
dclSignal = new DCLSignal();
}
}
}
return dclSignal;
}
public static void clear() {
dclSignal = null;
}
public Integer getI() {
return i;
}
public void setI(Integer i) {
this.i = i;
}
public static void main(String[] args) throws InterruptedException {
int num = 10;
ArrayList<Thread> threads = new ArrayList<>();
long start=System.currentTimeMillis();
while (true) {
DCLSignal.clear();
for (int i = 0; i < num; i++) {
Thread thread = new Thread(() -> {
DCLSignal instance = DCLSignal.getInstance();
//这里用来验证重排序的发生
if (instance.getI() == null || instance.getI() == 0) {
//注意这里不能直接打印instance.getI()因为System.out.println()中有synchronized,会打破重排序,所以这里会打印出instance.i=111;
System.out.println("instance i = 0 耗时:"+((System.currentTimeMillis()-start)/1000L+"秒"));
System.exit(0);
}
});
thread.start();
threads.add(thread);
}
threads.forEach((t) -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
}
打开1-1的执行结果
…
即将进入锁竞争 Thread-12781
即将进入锁竞争 Thread-12790
即将进入锁竞争 Thread-12800
即将进入锁竞争 Thread-12801
instance i = 0 耗时:3秒
可以看到未使用volatile修饰的情况下返回了一个和预期不符的值。为什么会返回一个(i==null)呢?从理论上来将,在只要对象的构造方法执行了,返回的对象的成员变量i就不可能为null,必然会有初始值111。这里发生了什么?我们来详细探讨一下。
1.2 JAVA当中的new指令
我们来看一下new对象发生了什么?写出以下代码
package com.gxw.first.code.volite;
public class NewMain {
public static void main(String[] args) {
Object o= new Object();
}
}
在IDEA中点击view->show Bytecode
可以看到以下编译代码
// class version 52.0 (52)
// access flags 0x21
public class com/gxw/first/code/volite/NewMain {
// compiled from: NewMain.java
// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/gxw/first/code/volite/NewMain; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x9
public static main([Ljava/lang/String;)V
// parameter args
L0
LINENUMBER 5 L0
NEW java/lang/Object // new对象第一步
DUP //new对象第二部
INVOKESPECIAL java/lang/Object.<init> ()V //new对象第三步
ASTORE 1 //new对象的第三步
L1
LINENUMBER 6 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE o Ljava/lang/Object; L1 L2 1
MAXSTACK = 2
MAXLOCALS = 2
}
可以看到java创建一个对象被编译成了4条指令
1、NEW
2、DUP
3、INVOKESPECIAL
4、ASTORE
那么这四条汇编指令都是做什么呢?可以查一下汇编手册
1、NEW指令负责开内存上开辟一个空间
2、DUP指令可以不用太关心(new创建的实例是默认初始化值,可以认为是没有进行构造方法的一个对象,创建后会将这个对象压入操作数栈顶;后面因为INVOKESPECIAL方法会消耗一个栈顶的引用给对象初始化,所以这里DUP是用来拷贝一份对象的引用来进行真正的引用传递)
3、INVOKESPECIAL 用来调用new创建实例的初始化工作可以看到这里关联了java.lang.Object.init() 方法用来初始化
4、ASTORE方法,ASTORE方法负责把创建的对象关联到 Object o=new Object() 代码中 o这个变量上
通过前面两章的探讨,我们知道CPU由于需要优化寄存器和ALU之间的运算速度不匹配所有会对指令进行重排序,所以如果以上代码的顺序发生了改变会怎么样呢?
当出现以下的重排序场景时
1、NEW //开辟对象空间
2、DUP //复制对象引用
3、LASTORE //返回对象引用
4、INVOKESPECIA //调用构造方法初始化对象
可以发现对象的引用在重排序下,是会提前返回的。返回对象引用的时候对象可能还没有被初始化。
我们之前讲过重排序遵循 as-if-serial 那么这种情况是否遵循这个原则呢?
可以看到new指令在单线程情况下先进行返回引用复制,然后再进行初始化后并不影响这条指令的最终结果,但是在多线程下会发生些什么呢?我们来分析一下
可以看到这里线程B返回了一个未进行构造方法的对象,这就是如上代码A-1发生和预期不符结果的原因。解决方法也很简单,使用volatile来修饰单例 DCLInstance就可以了。
学到这里是不是很头疼?感觉重排序带来的问题无处不在,在编码的时候很难全部考虑到所有的重排序场景,所以这里有了happens-before原则
1.3 happens-before原则(先行发生原则)
1、程序次序规则(Program Order Rule)
单线程内,先写的代码,先执行,后写的代码后执行
2、管程锁定规则(Monitor Lock Rule):管程—一种通用的同步语句,在Java中主要指的就是Synchronized。而管程锁定规则指的就是对一个锁的unlock happens-before 后续对这个锁的lock操作。如下,若线程A、B同时访问read方法,当线程A执行完之后,线程B能够获取到线程A对变量的操作。
public class HappensBefore {
int value = 10;
public void read(){
synchronized (this){ //此处加锁
if(value < 100){
value ++;
}
}//此处自动解锁
}
3.volatile变量规则(Volatile Variable Rule):
对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。
4.传递性(Transitivity):
如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
5.线程启动规则(Thread Start Rule):
Thread对象的start()方法先行发生于此线程的每一个动作。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程B前的操作。换句话说就是,如果线程A调用线程B的Start()方法(在线程A中启动线程B),那么该start()操作happens-before于线程B中的任意操作。