单例模式

概念

  • 单例模式属于创建型模式,一个单例类在任何情况下都只存在一个实例,构造方法必须是私有的、由自己创建一个静态变量存储实例,对外提供一个静态公有方法获取实例。
  • 优点是内存中只有一个实例,减少了开销,尤其是频繁的创建和销毁实例的情况下并且可以避免对资源的多重占用。缺点是没有抽象层,难以扩展,与单一职责原则冲突。

单例模式

饿汉式

饿汉式单例模式,顾名思义,类一加载就创建对象,这种方式比较常用。
缺点

  • 容易产生垃圾对象,浪费内存空间;
  • 单例会在加载类后一开始就被初始化,即使客户端没有调用getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:例如Singleton实例的创建是依赖参数或者配置文件的,在getInstance()之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用。

优点
线程安全,没有加锁,执行效率较高

1
2
3
4
5
6
7
8
9
10
public class Singleton {
//1.私有化构造方法
private Singleton(){}
//2.定义一个静态变量指向自己类型
private final static Singleton instance = new Singleton();
//3.对外提供一个公共的方法获取实例
public static Singleton getInstance() {
return instance;
}
}


懒汉式

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null){
instance = new Singleton();
}
return instance;
}
}

缺点:当有多个线程并行调用getInstance()的时候,就会创建多个实例。也就是说在多线程下不能正常工作。

解决方案
为了解决上面的问题,最简单的方法是将整个getInstance()方法设为同步(synchronized)。

1
2
3
4
5
6
7
public static synchronized Singeleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

此方法虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用getInstance()方法。
但是同步操作只在第一次调用时才被需要,即第一次创建单例实例对象时。

这时候我们就需要双重检验锁


双重检验锁(DCL —— double-checked locking)

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查instance == null,一次是在同步块外,一次是在同步块内。
为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的if,如果在同步块内不进行二次检验的话就会生成多个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
// 声明成volatile
private volatile static Singleton instance;
private Singleton(){}

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

这里的双重检验是指两次非空判断,锁是指synchronized加锁

  • 第一重判断,如果实例已经存在,那么就不用再进行同步操作,而是直接返回这个实例,如果没有创建,才会进入同步块,同步块的目的与之前相同,目的是为了防止有多个线程同时调用时,生成多个实例,有了同步块,每次只能有一个线程调用访问同步块内容,当第一个抢到锁的调用获取了实例之后,这个实例就会被创建,之后的所有调用都不会进入同步块,直接在第一重判断就返回了单例;
  • 第二重空判断,当多个线程一起到达锁位置时,进行锁竞争,其中一个线程获取锁,如果是第一次进入则为null,会进行单例对象的创建,完成后释放锁,其他线程获取锁后就会被空判断拦截,直接返回已创建的单例对象。

instance = new Singleton()并非是一个原子操作,事实上在JVM中这句话大概做了一下三件事:

  1. instance分配内存
  2. 调用Singleton的构造函数来初始化成员变量
  3. instance对象指向分配的内存空间(执行完这步instance就为非null了)

但在JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1-2-3也可能是1-3-2.如果是后者,则在3执行完毕,2未执行之前,被线程二抢占了,这时instance已经是非null了(但是没有初始化),所以线程二会直接返回instance,然后使用,这时就会出错

instance变量声明成volatile就可以避免了


Why volatile ?

有些人认为使用volatile的原因是可见性,也就是可以保证线程在本地不会存有instance的副本,每次都是去主内存中读取。但其实是不对的。使用volatile的主要原因是其另外一个特性:禁止指令重排序优化
也就是说,在volatile变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完1-2-3之后或者1-3-2之后,不存在执行到1-3然后取到值的情况。
先行发生原则的角度理解的话,就是对于一个volatile变量的写操作都先行发生于后面对这个变量的读操作。
但是Java5之前的版本使用volatile的双检锁还是有问题的。其原因是Java5以前的JMM(Java内存模型)是存在缺陷的,即使将变量声明成volatile也不能完全避免重排序,主要是volatile变量前后的代码仍然存在重排序问题。这个volatile屏蔽重排序的问题在Java5中才得以修复。