前言
单例模式应该是设计模式中最容易理解也是用得最多的一种模式了,同时也是面试的时候最常被问到的模式。单例模式的作用就是确保在任何情况下都只有一个实例对象,并提供一个全局的访问点,理解起来并不难,但是要实现一个接近“完美”的单例模式却绝非易事。本文将介绍在Java中如何优雅地实现单例模式,并对比各种实现方式的优缺点,希望诸位在看完之后能对单例模式有更深入的理解。
一、单例模式的基础
单例模式的定义是确保某个类在任何情况下都只有一个实例,并且需要提供一个全局的访问点供调用者访问该实例的一种模式。要确保任何情况下都只有一个实例,则我们需要把创建对象的权限“收回来”或者进行限制,在Java中创建对象最常见的方法就是通过构造方法创建了,因此要做到限制创建对象的权限,就必须将构造方法私有化。此外又要提供一个全局的访问点,则可以通过public static方法或变量暴露该单例,供外部系统进行访问。所以单例模式必不可少的2点就是:
-
构造方法私有化
-
提供public static变量或方法暴露单例对象
大家很容易想到的就是既然构造方法私有化,那么也就是说构造方法只有在类内部才能访问到,那我们直接在类内部调用构造方法new一个对象出来再通过public static方法暴露出去不就可以了。没错,这也是我们接下来要讲的单例模式的第一种也是最简单的一种实现方式:饿汉模式。
二、饿汉模式
public class Singleton {
private static Singleton singleton=new Singleton();
private Singleton(){
}
public static Singleton getSingleton() {
return singleton;
}
}
所谓“饿汉模式”,顾名思义就是饥饿难耐,迫不及待地创建出实例对象。“饿汉模式”的缺点是不能实现懒加载,即当该类被JVM加载的时候,该单例对象就被创建出来了。如果我们要实现懒加载该怎么办呢?有的同学可能很快就会想到那就当第一次调用getSingleton()方法时再创建出来不就得了。这也是接下来将要介绍的“懒汉模式”。
三、懒汉模式
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static Singleton getSingleton() {
if(singleton==null){
singleton=new Singleton();
}
return singleton;
}
}
“懒汉模式”的特点就是在需要的时候才创建对象,这样可以做到起到延迟加载的效果。但是上述“懒汉模式”有一个致命的缺点是线程不安全,也就是说在多线程的情况下可能会创建不止一个对象。大家可以设想这样一种情形,假设线程A执行到
if(singleton==null)
这行代码时,singleton变量还未实例化,这时候
singleton==null
返回true,并且线程A执行到这一步的时候让出了CPU给线程B,线程B完整地执行了上述方法再让出CPU,因为线程B执行的时候singleton变量还未实例化,所以线程B会创建一个singleton对象。线程B让出CPU后线程A继续往下执行,这个时候线程A执行的下一句代码是
singleton=new Singleton();
这时候线程A也会创建一个singleton对象。在这种情况下一共创建了2个对象,因此说上述饿汉模式是线程不安全的。如果要实现线程安全的单例模式,有些同学很快就想到了synchronized关键字。接下来就我们就使用synchronized关键字实现线程安全的“懒汉模式”。
四、线程安全的“懒汉模式”
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public synchronized static Singleton getSingleton() {
if(singleton==null){
singleton=new Singleton();
}
return singleton;
}
}
线程安全的“懒汉模式”非常简单,就是在getSingleton()方法上加了个synchronized关键字。这样一来就能实现线程安全的“懒汉模式”了。但是这种方法还有一个缺点就是当并发量大时性能不高,因为锁是加在方法上的,意味着所有线程都要排队获取singleton对象,因此性能不高。那么有没有其他办法可以提高性能呢?当然是有的。我们再次分析上述代码,可以发现,其实只有当创建对象的时候才需要加锁,也就是这行代码
singleton=new Singleton();
需要加锁,其他代码是可以不加锁的,如果我们在创建对象的时候再加锁,而不是在整个方法上加锁,那么性能自然就提高了。接下来将介绍基于“双重校验”的“懒汉模式”。
五、基于“双重校验”的“懒汉模式”
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static Singleton getSingleton(){
if(singleton==null){
synchronized (Singleton.class){
if(singleton==null){
singleton=new Singleton();
}
}
}
return singleton;
}
}
在“双重校验”的实现方式中,只有在创建singleton对象的时候需要加锁,后续线程再调用该方法时,因为singleton对象不为null,所以该方法直接返回singleton对象,不需要进入同步块,故而这种实现方案在高并发情况下性能比较高。有同学可能会有疑问,为什么需要做双重检验呢?明明在同步块外面已经对singleton对象是否为空做了判断,为何在同步块内部还需要再判断一次呢?之所以这样做,是为了防止在并发的情况下初始化了多个singleton实例。同样考虑有2个线程的情况,假如线程A第一次调用该方法时执行到以下代码
if(singleton==null)
的时候就让出了CPU,接着线程B也调用该方法,因为此时singleton对象还未实例化,假设线程B完整地执行完该方法,初始化了一次singleton对象,然后线程A继续执行,假如这个时候不对singleton再做一次非空判断而是直接实例化singleton对象的话,则singleton对象会被实例化2次。因此才需要做双重校验以防止singleton对象被实例化多次。
然而,上述“双重检验”仍然是有漏洞的。在某些情况下当singleton不等于null时,可能singleton引用的对象还未完成初始化。产生问题的原因是指令重排序。《Java并发编程的艺术》一书提到,上述
singleton=new Singleton();
这行代码可以分解成以下三行伪代码:
memory=allocate(); //1.分配对象的内存空间
constructInstance(memory); //2.初始化对象
singleton= memory; //3.设置singleton指向刚分配的内存地址
上述的三行代码的第2跟第3行代码可能发生重排序。2跟3重排序后,初始化的过程如下:
memory=allocate(); //1.分配对象的内存空间
singleton= memory; //3.设置singleton指向刚分配的内存地址。这时候
//对象还未初始化完成!
constructInstance(memory); //2.初始化对象
上述重排序在单线程情况下不会有什么问题,但是在多线程的情况下就有可能使得某些线程访问到未初始化完成的对象。假设多线程情况下线程的执行时序如下(以下图表摘自《Java并发编程的艺术》一书):
则线程B访问到的对象并没有完成初始化。
因此上述“双重检验”的实现是有问题的,那么有没有其他办法避免上述问题呢?当然有。笔者将在后续文章为大家详细介绍。