前言

单例模式是一个很简单的设计模式,当然这里的简单说的是理解起来很简单,其实单例模式中有很多细节值得我们去深究。

什么是单例模式

单例模式顾名思义就是只有一个实例

每一个单例的类都只会存在一个实例,需要使用到该类时,就需要使用该实例,不得在创建另一个实例对象。

为什么会需要单例模式呢?每次使用类的时候就直接创建对象就好了,何必这么麻烦。

的确在大多数使用类的情况下,单例是没有必要的。但是在某些情况下单例还是很有必要的。

比如需要重复用到的类,就会不断重复创建实例,这个实例完全可以复用,但是还是重新创建了,这样是十分浪费系统资源和性能的。

再比如数据库的连接,如果每次使用都需要连接一次,使用完在断开连接,要是这样的开销完全大于一直连接,那为什么不一直连接直到程序结束呢?

所以单例模式在一些情况下显得十分必要。

单例模式的实现

单例模式实现的方式有很多种,大多都是围绕两个核心的问题来变化的。

  1. 何时生成实例,也就是是否是懒加载
  2. 是否线程安全

接下来就围绕这两个问题进行探讨。

饿汉式

首先是最简单的一种单例模式的实现方法。

1
2
3
4
5
6
7
8
9
public class Singleton {  
private static Singleton instance = new Singleton();
// 要使用实例,只能用该方法获取
public static Singleton getInstance() {
return instance;
}
// 重写构造方法,使其不能再外部创建实例
private Singleton (){}
}

这种方式很简单,这也就是网上提到的饿汉式的单例模式。

现在回到刚刚那两个核心问题来看看这个饿汉式的单例模式。

  1. 这是线程安全的
  2. 不是懒加载的

这种方式简单,其实就简答在一开始就创建了对象,所以就是线程安全的,多个对象用getInstance方法,都是调用同一个对象,不会出现线程安全的问题。

懒汉式

懒汉式其实就是使用了懒加载的。现在来看看最简单的一种写法。

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

因为使用了懒加载,即直到要使用该类了才会创建实例,但是就是因为懒加载,所以出现了线程安全的问题。

这其实不难理解,单个线程该类不会出现任何的问题,但是在多线程环境下,当多个线程同时进入到判断语句,此时都是null,那就会导致多个线程会创建多个实例,那就不是只有一个实例了,这就出现了线程不安全的问题。

于是为了实现线程安全就可以改成下面这样。

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

直接在getInstance方法上增加synchronized关键字,就能保证只有一个方法进入到该线程,从而就能保证线程的安全。

双检锁

上面的线程安全的懒汉式已经解决了之前提到的两个问题,懒加载和线程安全。

但是在方法上增加synchronized,会显得加锁粒度太重,我们只需要在创建对象的时候加锁就好了,何必在方法上加锁呢?所以就能再次优化。

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

但仔细思考之后,就会发现这段代码虽然降低了加锁粒度,但是并没有保证线程安全,因为也是存在有多个线程同时通过了判断语句,每次封锁就只能封锁住创建的顺序而已,并不能保证只创建一个实例。为了解决这个问题,我们可以在为这段代码增加一个判断语句。

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

这就是双检锁的单例模式了,这段代码看上去已经很完美了,但是这其实并没有实现线程安全。

因为instance = new Singleton();这个创建对象的语句并不是原子性的操作,这个语句在底层其实分成了三步。

  1. 分配实例的内存地址
  2. 在该内存地址初始化实例对象
  3. instance的地址指向这个内存地址。

之前我们学习多线程的时候有提到过指令重排。这三个指令经过优化可能会重排,会导致执行出现以下几种顺序

  • 123,正常顺序

  • 132,结果与正常顺序一样的顺序

这个不正常的顺序在单线程虽然结果与正常顺序一样,但是在多线就会出现一些线程不安全的问题。

比如存在两个线程A和B调用了getInstance方法,就会出现这种情况。当线程A进入到同步代码块,并通过了第二个判断语句,进入到创建实例的阶段,经过指令重排,执行132这个顺序,当执行完3时,B也到了第一个判断语句,此时instance != null,所以就会直接返回instance,但是线程A还没执行初始化对象,所以这个内存地址其实还是没有被使用的,这样线程B拿到的实例就会是没有被初始化的空的内存地址。

解决这个问题也很简单,之前有提到过volatile是可以禁止指令重排的,所以为该实例加上volatile关键字就好了,所以代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private static volatile Singleton instance = null;
public static synchronized Singleton getInstance(){
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton(){};
}

这样即实现了懒加载,又实现了线程安全,而且封锁的粒度还小。

这种方法就一个缺点,写起来很麻烦,下面探讨一下更简单的写法。

静态内部类

1
2
3
4
5
6
7
8
9
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
private Singleton() {}
}

这种单例模式就使用到了静态内部类了,它是可以实现懒加载和线程安全的。因为静态内部类在调用的时候才会加载,也就会在调用到的时候才会创建对象,从而实现懒加载。静态内部类在类加载机制中也会实现线程安全,所以这种方法是比较简单的实现这两个问题的。

枚举

上面提到的方法其实都还存在一个问题,可以被反射机制所破坏。

下面我就使用双检索的方式创建一个单例,看看反射机制是否能破环该单例模式。

1
2
3
4
5
6
7
8
9
10
11
12
public class Test{
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Constructor constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);

Singleton demo1 = (Singleton) constructor.newInstance();
Singleton demo2 = (Singleton) constructor.newInstance();
System.out.println(demo1);
System.out.println(demo2);
System.out.println(demo1.equals(demo2));
}
}
1
2
3
com.yw.Singleton@1b6d3586
com.yw.Singleton@4554617c
false

内存地址并不一样,这并不是同一个对象,所以可以看出反射机制是能够破坏上面的方法的。这种情况基本都是人为破坏的,所以不怎么需要考虑,不过实在要解决也是有方法的,那就是使用枚举。

1
2
3
public enum SingletonEnum {
INSTANCE;
}

因为枚举是可以防止反射的,所以使用枚举可以防止别人使用反射,不过这样就和饿汉式差不多了,所以并不怎么推荐使用。

总结

因为反射破坏基本都是人为因素,所以不怎么需要考虑,所以单例模式一般使用双检锁或者是静态内部类的方法就好,如果懒加载并不是必须的,可以考虑使用饿汉式的单例模式。

单例模式的使用需要经过认真思考,滥用单例模式就是造成内存泄漏的原因之一,所以要在项目中使用单例模式,就需要确定该实例一直存在的开销是否要比使用完就销毁的开销要大。