诗海

我们越谦卑,就离真理越近

0%

详解单例模式

最近由于课程作业的缘故,使用Scala语言的次数较多。在Scala中,类型还是挺丰富的,其中有一种类型称为Obejct,可以说对应着Java中的单例模式,今天正好来详细说一下自己一直没有搞清楚的这个东西,同时也是面试中经常出现的一个知识点。

单例模式是一种比较特殊的设计模式(Design Pattern),在Java中,它表示一个Class只有一个实例化的Object。单例模式分为两种,一种叫饿汉模式(Object的new操作发生在Class创建时);一种叫懒汉模式(Object的new操作发生在getInstance时)。这两种的区别就在于单例模式实例化Object的早晚。

其中,饿汉模式是一种标准的写法,这也是《Effective Java》一书中推荐的,其具体代码如下:

class Singleton {
// 这里的static member是public的,外部可以直接访问
public static final Singleton INSTANCE = new Singleton();

private Singleton() {
// initialize object
....
}

// other methods for Singleton instance
public void method1() {
....
}
}

这第一种写法是最基础的,在之后的代码中如果想使用单例,就直接调用Singleton.INSTANCE即可;另外还有一种写法,使用Static Factory Method,使得代码更加灵活。

class Singleton {
private static final Singleton INSTANCE = new Singleton();

private Singleton() {
// constructor, initialize object
....
}

public static Singleton getInstance() {
return INSTANCE
}

// other methods for Singleton instance
....
}

不管是是哪一种写法,都是属于饿汉模式,即在Singleton Class创建的时候就完成Object的new操作。这其中有两点最为关键:

第一点是声明构造函数(Constructor)为private,防止在其他Class的代码中new出新的Singleton Object;

第二点是提供一个public static的member/function或直接,或间接地让外部用户获得这个单例。

另外一种设计思路就是懒汉模式,它通常与多线程问题一起考察(含锁的双检查),主要是为了面试能够多引出一些知识点来,我个人认为实际的代码中一般不会用到这种,不过我们还是可以梳理一下面试思考问题的思路。

首先是最基础的版本:

class Singleton {
private static Singleton instance;

private Singleton() {
// constructor, initialize object
....
}

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

// other methods for Singleton instance
....
}

它的问题在于多线程,当Thread1,Thread2同时检查到if (instance == null)时,此时都进入if语句块,假设此时Thread1先获得了CPU的控制权,那么它创建(new)了一个Singleton对象,然后返回;紧接着Thread2获得CPU,也会创建一个Singleton对象,那么相当于此时内存堆中拥有两个不同的Singleton Object,违反了单例模式。

解决的方案有两种,第一种是最简单的,直接对getInstance函数加锁(synchronized),不过这样是一种简单粗暴的互斥访问,会造成并发量的下降(关于synchronized底层实现的本质和具体的性能表现我尚未有特别详细的研究)

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

第二种方法就稍微复杂些,那就是含锁的双检查:

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

这里为什么是if (instance == null)检查两次呢?第一次的检查非常好理解(看看是不是已经创建了instance,有的话就直接返回,节省进入synchronized的开销),第二次的检查是为了避免创建两个不同的单例对象,违反单例模式的原则。具体来说是这样的:

如果Thread1,Thread2同时处于synchronized(Singleton.class)这一临界区间(Critical Section)等待获取锁。此时,如果Thread1先进入,那么Thread2就会因获得不到锁而进入BLOCKED状态,再也无法和RUNNABLE状态的Thread1竞争CPU的时间片,它只能被动等待synchronized锁的释放并获得锁,然后再进入RUNNABLE状态。

Thread1 Thread2
获取synchronized锁
再次判断instance为空
创建并完成instance的初始化
释放synchronized锁
返回已初始化的instance
获取synchronized锁
判断instance不为空
(如果没有第二次检查,那么就会再创建一个Singleton对象)
返回已初始化的instance

含锁的双检查模式已经是足够好了,但是还不是最好的。这是因为实例化对象的代码instance = new Singleton();实际上包含了3个操作:

  1. 分配对象的内存空间
  2. 初始化对象
  3. instance引用指向刚分配的内存空间

在Java中编译器存在指令的重排序,所以以上的3个操作不一定会依次按步骤进行,第2步和第3步有可能会进行重排序,此时这一行代码的执行操作是:

  1. 分配对象的内存空间
  2. instance引用指向刚分配的内存空间
  3. 初始化对象

此时对于Thread1,Thread2来说,就存在以下的一种多线程执行顺序:

Thread1 Thread2
首次判断instance为空
获取synchronized锁
再次判断instance为空
分配instance的内存空间
将instance引用指向刚分配的内存空间
判断instance不为空(第一次判断)
返回未初始化的instance
初始化instance
释放syncrhonized锁
返回已初始化的instance

注意,这里尽管Thread1运行在synchronized代码块中,但是依然存在CPU时间片的线程切换,这是因为在Java线程模型中,将OS中的Ready(就绪),Running(运行)都认为是RUNNABLE的状态。也就是说,所有处于RUNNABLE状态的线程,都有争夺CPU时间片的资格。Thread2并没有因为执行到synchronized代码块前没有获得锁而进入BLOCKED状态,所以就可以和Thread1争夺CPU控制权。

因此,我们说synchronized关键字能保证在它划定的临界区间(Critical Section)中最多只有一个线程能够运行;但是它不能保证当线程在临界区间上运行时,不会被其他RUNNABLE状态的线程切换。

此时,Thread2就会提前返回一个(内存堆中)没有完成初始化的Singleton instance对象,虽然说没有在内存堆中生成两个以上的对象,违反单例原则,但是没有完成初始化的对象会造成后续操作的不可预见性

正确的避免指令重排序的方法就是对instance使用volatile关键字,所以最后的正确版本是:

class Singleton {
private static volatile Singleton instance;

private Singleton() {
// constructor, initialize object
....
}

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

// other methods for Singleton instance
....
}