1、单例模式
之前写过一篇单例模式的博客,有不了解单例模式的可以看看。
2、指令重排
指令重排指的是在程序执行时,为了性能考虑,编译器和CPU可能会对指令进行重新排序,下面举个例子,比如有如下程序:
int a,b;
a = 2;
b = 2;
这个程序在执行的时候,可能执行顺序就会颠倒,变成先执行“b = 2”,再执行“a = 2”,这个就叫指令重排。
指令重排有几个基本原则,不清楚的可以看我引用的博客,这里要说的是顺序执行原则,指令重排保证在单线程内语义的串行性,举个例子:
int a,b;
a = 2;
b = a;
比如上面这个代码,如果顺序颠倒,先执行“b = a”,再执行“a = 2”,那么程序的意思就会发生改变,那么这种指令重排是不被允许的。
3、单例模式与指令重排
说完指令重排,那么说说其和单例的关系。
看到这的小伙伴想必都知道单例的饱汉模式,而饱汉模式有双重校验锁的实现方式,代码如下:
public class Singleton {
private static Singleton singleton;
private Singleton(){
System.out.println("生成了一个实例");
}
public static Singleton getInstance(){
if(singleton==null){
synchronized(Singleton.class){
if(singleton==null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
按照我所了解到的,在上述代码中,语句“singleton = new Singleton()”在程序执行时会发生指令重排,这样一个语句,实际上被分成了以下三个步骤:
- 分配对象的内存空间
- 初始化内存空间
- 将对象指向该内存空间
而当指令重排的时候,三个步骤的顺序可能会变成这样:
- 分配对象的内存空间
- 将对象指向该内存空间
- 初始化内存空间
那么问题就来了,假设我们现在有两个线程,A和B,当A执行到上述步骤中的第二步的时候,B执行到了第一个校验语句“if(singleton==null)”,此时对象已经指向了分配的内存空间,所以singleton不为空,那么B线程就会获得一个未经初始化的对象,从而造成程序错误。
因此需要将singleton声明为volatile类型,以此来禁止指令重排。
4、思考
昨天在写代码的时候,正好写到这个单例模式,突然间想到个问题,单例模式的双重校验锁真的会有指令重排问题吗?
按照上面的说法,B线程确实有可能会获取到未经初始化的对象,但是B线程拿这个对象做什么呢?我认为对对象的操作无非就是读写,那么就引发了另一个问题,像双重校验锁这样的写法,A线程在加了锁之后,B线程是否还能够对singleton进行操作?
于是我写了以下测试代码:
public class Main2 {
public static void main(String[] args) {
Thread1 thread1 = new Thread1();
thread1.start();
Thread2 thread2 = new Thread2();
thread2.start();
}
}
class Thread1 extends Thread{
@Override
public void run() {
Solution solution = new Solution();
System.out.println("1:" + solution.print());
}
}
class Thread2 extends Thread{
@Override
public void run() {
Solution solution = new Solution();
System.out.println("2:" + solution.print());
}
}
class Solution {
public static Tmp tmp = null;
public Solution(){
if(tmp == null){
synchronized (Solution.class){
if(tmp == null){
tmp = new Tmp();
try{
Thread.sleep(3000);
} catch (Exception e){
System.out.println(e.getMessage());
}
}
}
}
}
public Tmp print(){
return tmp;
}
}
@Data
class Tmp{
private String string;
public Tmp(){
string = "hello";
}
}
我在加锁的代码里加了个3秒的等待时间,然后启动两个线程去获取tmp对象并输出,在多次测试中,我发现,当A线程在执行下列代码的时候,B线程要输出tmp对象需要等待A线程先执行完,将锁释放:
synchronized (Solution.class){
if(tmp == null){
tmp = new Tmp();
try{
Thread.sleep(3000);
} catch (Exception e){
System.out.println(e.getMessage());
}
}
}
为了更加明显的看出这个问题,我对修改了下代码:
public Solution(){
if(tmp == null){
synchronized (Solution.class){
if(tmp == null){
tmp = new Tmp();
try{
System.out.println("锁内等待开始");
Thread.sleep(3000);
System.out.println("锁内等待结束");
} catch (Exception e){
System.out.println(e.getMessage());
}
}
}
try{
System.out.println("锁外等待开始");
Thread.sleep(3000);
System.out.println("锁外等待结束");
} catch (Exception e){
System.out.println(e.getMessage());
}
}
}
emmmm,最后的测试结果推翻了我上面的结论…双重校验锁确实有指令重排问题!
其实昨天打算写这篇文章的时候,是打着推翻权威的心思的,不过今天写的时候,写着写着就觉得权威果然是权威,写博客还是有点用的,可以让自己理清思路,不愧是我!