单例模式引起的一些思考

单例模式通常有饿汉式和懒汉式,懒汉式

饿汉式

无线程安全性问题

1
2
3
4
5
6
7
8
9
public class SingleHungryStyle {
private static SingleHungryStyle instince = new SingleHungryStyle();

public static SingleHungryStyle getInstince(){
return instince;
}

}

懒汉式

单线程下

demo1

1
2
3
4
5
6
7
8
9
10
11
public class SingleLazyStyle {
private static SingleLazyStyle instance = null;

public static SingleLazyStyle getInstance(){
if(instance == null){
instance = new SingleLazyStyle();
}
return instance;
}
}

上述代码在多线程的情况下会出现多个实例,所以需要进行一个加锁判断。

多线程下

demo2

1
2
3
4
5
6
7
8
9
10
11
12
public class SingleLazy1Style {
private static SingleLazy1Style instance = null;

public static SingleLazy1Style getInstance(){
synchronized (SingleLazy1Style.class){
if(instance == null){
instance = new SingleLazy1Style();
}
return instance;
}
}
}

上述代码在多线程的情况下运行多次偶尔会出现一个问题,就是CPU的重排序会导致instance还未完全初始化就被使用了。

例如:

此时线程二就有可能报错,因为JVM在进行一个类的初始化的时候是分为三步的。

Java SE 8的JVM规范里面对一个类的加载进行了详细的描述:Java SE 8的JVM规范
具体来说就是分为三步:

  • Loading
  • Linking
  • Initializing
Creation and Loading

在这一步,JVM需要判断需要初始化的类是数组还是一个普通类,如果是一个普通类的话,就再进行判断是需要使用bootstrap class loader来进行加载还是说用user-defined class loader进行加载。

Linking

在这一步主要是验证准备

  • Verification
  • Preparation
  • Resolution

而将未初始化的引用绑定到实例上就是Resolution。具体可以参考: Java SE 8的JVM规范 5.4.3

Initializing

初始化,即将字段进行一个默认值初始化。

但是这里因为LinkingInitializing之间并无任何的关联性,所以可能会导致先进行了一个初始化,但是并未将该引用绑定到堆的一个实例上,而此时由轮到另一个线程执行。所以就会导致另一个线程获取的是空对象。

问题

但是在这个初始化的过程中,LinkingInitializing之间由于互相不依赖,所以CPU可能会先进行初始化,但是并未进行关联,即将引用关联到JVM里面的一个实例。而直接返回了。此时由于happens-before原则并不能跨线程,所以会出现两种情况:

  • 如果线程一在线程二之前使用了instance,此时线程二使用instance不会出现任何问题
  • 如果线程一在初始化完毕之后释放了锁资源,然后线程二执行,因为线程二判断instance已经被初始化了(但此时实际上并未Linking),所以在使用的时候会报错。

但是这个时候,由于CPU的重排序,导致线程二获取的instance可能出现空指针异常;

所以一般为了避免这种情况,会加一个volatile关键字来禁止内存重排序。

demo3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SingleLazy2Style {
private volatile static SingleLazy2Style instance = null;

public static SingleLazy2Style getInstance(){
if(instance == null) {
synchronized (SingleLazy2Style.class) {
if (instance == null) {
instance = new SingleLazy2Style();
}
return instance;
}
}
return instance;
}
}

volatile

在这里之所以使用volatile的特性之一:防止内存进行重排序(包含写屏障和读屏障)

happens-before原则

Java SE 8中Happens-before原则

1
2
3
4
5
6
7
8
9
10
11
Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second.

If we have two actions x and y, we write hb(x, y) to indicate that x happens-before y.

If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).

There is a happens-before edge from the end of a constructor of an object to the start of a finalizer 12.6) for that object.

If an action x synchronizes-with a following action y, then we also have hb(x, y).

If hb(x, y) and hb(y, z), then hb(x, z)

happens-before原则要求在一个线程内,重排序后执行的结果与未重排序之前的执行结果必须一致。所以在demo1中单线程里面,是不会出现任何问题的,因为即使发生重排序,最后在使用instance的时候,instance也一定会完成初始化,否则就是编译器bug了。

但是在多线程的情况下,happens-before原则无法生效,所以就会导致其他线程在获取实例的时候会出现异常。

所以在多线程的情况下需要使用volatile关键字进行修饰,主要是因为需要确保首个初始化的线程必须完成整个类的初始化的操作。

作者

Somersames

发布于

2019-05-04

更新于

2021-12-05

许可协议

评论