单例模式All in One

单例模式或许是最简单的设计模式之一,也很常见,但其实现还有许多需要注意的地方。

What

顾名思义,单例模式在整个JVM的运行期间为单例的类只创建一个实例。
因此不难想象有两点需要保证:

  1. 单例的创建不能任由代码随意执行,因此单例类的构造方法应该是private的。
  2. 单例的创建应该只有一次,时机应该在第一次调用该单例对象的方法前。

创建方式

从实例创建的时机来划分,单例模式有两种创建方式。

饿汉

所谓饿汉式指的是在类加载阶段就将单例类的实例创建出来,不管以后有没有用到,优点是在以后的JVM运行期不会再有创建实例的动作,因此基本可以保证单例的唯一性;缺点是当没有用到时会造成浪费。
实现饿汉式的单例很简单,只需持有静态的自身单例类对象,限制构造方法,并提供静态的实例获取方法即可,同时最好用final修饰该单例对象。

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

懒汉

与饿汉相对,懒汉模式只有当调用该单例的方法时,才涉及到该实例的创建,即延迟加载。优点是由于按需创建实例,不会造成浪费;缺点是难以控制单例唯一性,因为需保证只有第一次获取实例时才创建实例,在多线程环境下需特殊处理。
针对懒汉式单例的唯一性控制,通常有如下几种方式。

方法锁

我们可以用synchronized关键字修饰获取单例实例的静态方法,这样所有获取单例对象的线程都处于竞争态,每次只有一个线程进入该方法,其他线程将等待锁的释放,因此只有第一个拿到该锁的方法会创建实例。

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

但是用synchronized修饰方法的粒度实在是太大了,可能会造成效率低下。

判断锁

那么如果把锁的粒度下降,把锁下移到方法内的块中会不会好点?下面这种实现看起来很自然:

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

初看似乎没毛病,但仔细推敲就能发现问题:

  1. T1进入判断,获取锁,创建一个实例并将INSTANCE引用指向该对象。
  2. T2同时进入判断,等待T1释放锁后获取锁,也创建一个实例,将INSTANCE指向新对象。
  3. T3稍后进行判断,发现INSTANCE不为空,直接返回T2创建的实例。
    由此可见,只有一次判断内的锁并不能保证单例,因此我们需要对其进行一些小修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
 class Singleton {
private static volatile Singleton INSTANCE;
private Singleton(){}
public static Singleton getInstance() {
if(null == INSTANCE) {
synchronized (Singleton.cl-ass) {
if(null == INSTANCE) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

这样的话,上述情况就没问题了:

  1. T1进入判断,获取锁,创建一个实例并将INSTANCE引用指向该对象。
  2. T2同时进入判断,等待T1释放锁后获取锁,这时进行第二次判断,直接返回不为空的INSTANCE。
  3. T3稍后进行判断,发现INSTANCE不为空,直接返回T1创建的实例。

至于为什么要使用volatile关键字,可以参考线程同步之volatile,注意volatile只有在Java 5+才算完善。

静态内部类

当一个类中包含静态内部类时,由于两个类没有继承关系,因此在加载外部类时,并不会加载内部类,只有当调用其方法时才会加载,而JVM的类加载能保证线程安全,因此可以用静态内部类实现懒汉式单例。

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

反射大魔王

然而,不管是饿汉也好,懒汉也罢,都将体验被反射大魔王支配的恐惧,让我们看看反射如何一招秒全场。

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
Singleton s1 = Singleton.getInstance();
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton s2 = constructor.newInstance();
System.out.println(s1 == s2);
} catch (Exception e) {
e.printStackTrace();
}
}

可以观察到,s1和s2指向不同的对象。
只需要通过反射将构造方法的访问权限放开,锁、静态内部类之流全部失效。

枚举守护神

当然,反射也不是万能的,可以采取一些措施来防范,如给实例化添加次数记录,超过1次则抛出异常。但这些方法都需要添加代码逻辑去处理,如果用枚举来实现单例,可以利用JVM的机制去抵御破坏。
枚举 Enum其实是一个抽象类,当程序代码中声明一个枚举其实继承了Enum
如下是利用枚举实现单例模式。

1
2
3
4
public enum Singleton {
INSTANCE;
private Singleton(){}
}

这个方式是饿汉式的,在类加载阶段就会创建单例;同时由于在类加载阶段就创建了对象,又能保证线程安全。
使用之前的反射手段尝试改变构造方法权限,调用constructornewInstance方法时,首先没有无参构造方法,只有带两个参数的构造方法(String.class, int.class)(即name和ordinal属性,分别对应枚举名字及在枚举中的位置);其次调用时,如发现是枚举类则抛出IllegalArgumentException异常,这是由Java的语言规则限制的。至此,枚举单例成功抵御了反射直接调用构造方法的进攻。