前言
在GoF的23种设计模式中,单例模式是比较简单的一种。然而,有时候越是简单的东西越容易出现问题。下面就单例设计模式详细的探讨一下。
所谓单例模式,简单来说,就是在整个应用中保证类只有一个实例存在。这个类的实例只提供了一个全局变量,用处相当广泛,比如保存全局数据,实现全局性的操作等。
分析
1. 最简单的实现 —— 饿汉式
首先,能够想到的最简单的实现是,把类的构造函数写成private的,从而保证别的类不能实例化此类,然后在类中提供一个静态的实例并能够返回给使用者。这样,使用者就可以通过这个引用使用到这个类的实例了。
public class SingletonClass {
private static SingletonClass instance = new SingletonClass();
public static SingletonClass getInstance() {
return instance;
}
private SingletonClass() {
}
}
外部使用者如果需要使用SingletonClass的实例,只能通过getInstance()方法,并且它的构造方法是private的,这样就保证了只能有一个对象存在。
2. 性能优化 —— lazy loaded 懒汉式
上面的代码虽然简单,但是有一个问题——无论这个类是否被使用,都会创建一个instance对象。如果这个创建过程很耗时,比如需要连接10000次jdbc实例连接或者10000多个模版实例,并且这个类还并不一定会被使用,那么这个创建过程就是无用的。
为了解决这个问题,我们想到了新的解决方案:
public class SingletonClass {
private static SingletonClass instance = null;
public static SingletonClass getInstance() {
if(instance == null) {
instance = new SingletonClass();
}
return instance;
}
private SingletonClass() {
}
}
代码的变化有1处——把instance初始化为null,直到第一次使用的时候通过判断是否为null来创建对象。
我们来想象一下这个过程。要使用SingletonClass,调用getInstance()方法。第一次的时候发现instance是null,然后就新建一个对象,返回出去;第二次再使用的时候,因为这个instance是static的,所以已经不是null了,因此不会再创建对象,直接将其返回。
这个过程就成为lazy loaded,也就是延迟加载——直到使用的时候才进行加载。
3. 同步
上面的代码很清楚,也很简单。然而就像那句名言:“80%的错误都是由20%代码优化引起的”。单线程下,这段代码没有什么问题,可是如果是多线程,麻烦就来了。我们来分析一下:
线程1希望使用SingletonClass,调用getInstance()方法。因为是第一次调用,1就发现instance是null的,于是它开始创建实例,就在这个时候,CPU发生时间片切换(或者被抢夺执行),线程2开始执行,它要使用SingletonClass,调用getInstance()方法,同样检测到instance是null——注意,这是在1检测完之后切换的,也就是说1并没有来得及创建对象——因此2开始创建。2创建完成后,cpu切换到1继续执行,因为它已经检测完了,所以1不会再检测一遍,它会直接创建对象。这样,线程1和2各自拥有一个SingletonClass的对象——单例失败!
解决的方法也很简单,那就是加锁:
public class SingletonClass {
private static SingletonClass instance = null;
public synchronized static SingletonClass getInstance() {
if(instance == null) {
instance = new SingletonClass();
}
return instance;
}
private SingletonClass() {
}
}
4. 又是性能问题
上面的代码又是很清楚很简单的,然而,简单的东西往往不够理想。理想的东西往往不够简单,这就是生活。这段代码毫无疑问存在性能的问题——synchronized修饰的同步块可是要比一般的代码段慢上几倍的!如果存在很多次getInstance()的调用,那性能问题就不得不考虑了!
让我们来分析一下,究竟是整个方法都必须加锁,还是仅仅其中某一句加锁就足够了?我们为什么要加锁呢?分析一下出现lazy loaded的那种情形的原因。原因就是检测null的操作和创建对象的操作分离了。如果这两个操作能够原子地进行,那么单例就已经保证了。于是,我们开始修改代码:
public class SingletonClass {
private static SingletonClass instance = null;
public static SingletonClass getInstance() {
if (instance == null) {
synchronized (SingletonClass.class) {
if (instance == null) {
instance = new SingletonClass();
}
}
}
return instance;
}
private SingletonClass() {
}
}
还有问题吗?首先判断instance是不是为null,如果为null,加锁初始化;如果不为null,直接返回instance。
这就是double-checked locking设计实现单例模式。但是还有问题。。。。。。
5. JMM中
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
下面来想一下,创建一个变量需要哪些步骤呢?一个是申请一块内存,调用构造方法进行初始化操作,另一个是分配一个指针指向这块内存。这两个操作谁在前谁在后呢?JMM规范并没有规定。(可能重排序)那么就存在这么一种情况,JVM是先开辟出一块内存,然后把指针指向这块内存,最后调用构造方法进行初始化。
线程1开始创建SingletonClass的实例,此时线程2调用了getInstance()方法,首先判断instance是否为null。按照我们上面所说的内存模型,1已经把instance指向了那块内存,只是还没有调用构造方法,因此2检测到instance不为null,于是直接把instance返回了——问题出现了,尽管instance不为null,但它并没有构造完成,就像一套房子已经给了你钥匙,但你并不能住进去,因为里面还是毛坯房。此时,如果2在1将instance构造完成之前就是用了这个实例,程序就会出现错误了!
5. 最终解决方案
在JDK 5之后,Java使用了新的内存模型。volatile关键字有了明确的语义——在JDK1.5之前,volatile是个关键字,但是并没有明确的规定其用途——被volatile修饰的写变量不能和之前的读写代码调整,读变量不能和之后的读写代码调整!因此,只要我们简单的把instance加上volatile关键字就可以了。
public class SingletonClass {
private volatile static SingletonClass instance = null;
public static SingletonClass getInstance() {
if (instance == null) {
synchronized (SingletonClass.class) {
if(instance == null) {
instance = new SingletonClass();
}
}
}
return instance;
}
private SingletonClass() {
}
}