前言
单例,顾名思义,指某一个类只有一个实例。
可能有人会有疑问:我们耗费时间去写一个类,结果这个类就产生一个对象,这么做值得吗?而且通过程序员之间的约定或全局变量就能做到的事情,为什么需要一个特定的设计模式去设计它?带着这些疑问,我们先来了解它存在的必要性。
必要性
有些对象其实我们只需要一个,比如线程池、缓存、对话框、处理偏好设置、注册表等的对象、日志对象,充当打印机、显卡等设备的驱动程序的对象。事实上,这些对象只能有一个实例,如果制造出多个实例,就会导致许多问题产生。比如有一个注册表设置的对象,如果它有多个拷贝,会将设置搞得一团乱,而我们可以通过单例,确保程序中使用的全局资源只有一份。
无论是通过全局变量还是程序员之间的约定都可以做到单例,但是却无法确保只能有一个实例,而且单例模式是一种更好的方式,为什么不选择去使用它呢?以下主要通过介绍其不同的实现方式来帮助我们更好地理解它。
实现方式
方式一
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
简单剖析一下上述代码:
- 为了防止开发人员通过new创建类的多个对象,将构造方法设为私有的,因此只有在类内部的成员或方法才可以访问,确保在类外部不会再创建该类的实例
- 而外部需要获取该类的实例,就必须由类的内部提供,所以类提供了一个getInstance()方法提供该类的实例(public:外部调用;static:外部没有实例可以调用该类的方法,只能由类调用)
- 如果getInstance()的作用仅仅是提供该类的实例,那么仅用
return new Singleton()
就可以实现,但是这样每调用一次该方法就会产生一个新的实例,与单例违背,所以我们需要用一个属性来保存一个唯一实例,当调用getInstance()方法,就返回该实例。 - 按照上述方式,可以在声明instance的时候就进行初始化,即
private static Singleton instance = new Singleton();
,但这样的话又回到了上面提及的全局变量实现的缺点,无法延迟对象的创建。聚焦上面代码中getInstance()的实现,如果实例为空,表示还没有创建实例,就创建一个返回;如果实例已经存在,就直接返回。如果我们不需要这个实例,它就永远不会产生,实现了"延迟实例化",故而常被称为懒汉式(线程不安全)单例模式。 - 所谓线程不安全,是指当多线程并发执行时,有可能导致产生多个实例,比如有两个线程A、B,它们同时调用getInstance()方法,且先后执行判断
if (instance == null)
,这时实例为null,所以两个线程都会为instance创建实例,从而导致产生了两个对象。
针对这种情况,我们当然可以选择在初始化instance的时候便创建实例,以此保证线程安全,而这是以牺牲延迟实例化为代价的,这就是很常见的饿汉式单例模式,实现见方式二。
方式二
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() { }
public static Singleton getInstance() {
return instance;
}
}
对于方式一提及的线程不安全问题,除了牺牲延迟实例化,另一种常用的解决办法是将getInstance()方法变成同步方法,即通过添加synchronized关键字,迫使每个线程在进入方法之前,要先等候其它线程离开此方法,也就是说,不会有两个线程同时进入这个方法,这也就是常称为懒汉式(线程安全)的单例模式。
方式三
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
上述解决多线程的办法,看起来比较简单有效,但是又会带来性能降低的问题:在每次执行此方法,都要同步,而实际情况是,只有第一次执行此方法时,才真正需要同步,一旦实例创建好后,就不再需要同步这个方法了,以后每次同步所付出的代价都是多余的。那么针对这个缺点,我们很容易想到的解决方法是:既然只有第一次执行这个方法需要同步,那么就先执行判断,再同步,于是有了方式三。
方式四
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
上面提到的先判断后同步的方法实现了,但是会发现,它竟然又重现了方式一的问题:只要两个线程都进入了判断if (instance == null)
,然后两个线程会前后创建新的实例,同样导致产生了两个对象的情况。那么结合方式二和方式三,我们希望只有在第一次创建该类实例的时候会进行同步,进入同步之后又需要再确认实例是否已经被其它线程创建好了,故而“双重检查加锁”的实现方式就出现了。
方式五
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static synchronized Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
上述实现方式看起来已经很“完美”了,但这仅仅是我们的的错觉,我们得到的对象很有可能不是一个完整的对象,原因如下:
正确创建对象的过程可以分解为以下三行伪代码:
memory = allocate(); \step1:分配对象的内存空间
ctorInstance(memory); \step2:初始化对象
instance = memory; \step3:让instance指向存放对象的内存空间
由于Java的指令重排,有可能会导致step2和step3的执行顺序颠倒,试想一下,当线程1执行完step1和step3,还未执行step2,这时候instance不为空,只是对象还未未初始化,如果此时线程2调用getInstance()方法,那结果将是得到一个非空但不完整的对象!!
这当然不是我们想要的结果,那有没有什么方法可以禁止指令优化重排?有的,关键字volatile能够做到!(遗憾的是只有Java 5及之后版本才能使用)
方式六
public class Singleton {
private volatile static Singleton instance;
private Singleton() { }
public static synchronized Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
方式五提到双重检查加锁这个名词时特意打了双引号,实际上我们通常提到的双重检查加锁一般指的都是方式六。
方式选择
以上六种实现方式各有各自的优缺点,总结起来,推荐使用的是方式二(饿汉式)、方式三(懒汉式(线程安全))、方式六(双重检查加锁),以下针对什么情况下选择哪种方式简单做一个总结。
- 方式二(饿汉式):在一定需要用到该类的实例,即不会造成资源浪费的情况下使用。
- 方式三(懒汉式(线程安全)):这是一种保证可行的最直接做法,对于没有性能考虑的情况下可以使用。
- 方式六(双重检查加锁):Java 5及以上版本,对性能有所要求时使用。
除以上提及的六种方式,还有静态内部类、枚举等实现方式,本文中就不再介绍。
要点总结
看到这里相信你对单例模式已经较为了解了,再回顾一下要点,巩固一下你所学到的单例模式。
- 单例模式确保程序中的一个类最多只有一个实例。
- 单例模式也是提供访问这个实例的全局点。
- 在Java中实现单例模式需要私有构造方法、一个静态方法和一个静态变量。
- 确定在性能和资源上的限制,然后选择适当的方法来实现单例模式。
- 如果使用多个类加载器,可能导致单例失效而产生多个实例(同一个类被不同类加载器加载,会导致多个单例共存,因此需要自行指定同一个类加载器)
- 如果使用JVM 1.2或之前的版本,必须建立单例注册表,以免垃圾收集器将单例回收(一个单例是有本单例类引用它本身)
参考文章
- <<Heard First设计模式>>
- java单例模式中双重检查锁的问题