该文章转载自「开发者圆桌」微信公众号,一个关于开发者入门、进阶、踩坑的公众号

你真的会写单例模式吗?_单例模式

单例模式可能是大家经常接触和使用的一个设计模式,你可能会这么写:

public class Test {

    private static Test instance;

    private Test() {

    }

    public static Test getInstance(){

        if(instance==null){//1:A线程执行

            instance=new Test();//2:B线程执行

        }

        return instance;

    }

}


上面代码大家应该都知道,所谓的线程不安全的懒汉单例写法。在Test类中,假设A线程执行代码1的同时,B线程执行代码2,此时,线程A可能看到instance引用的对象还没有初始化,导致被new多次。


你可能会说,线程不安全,我可以对getInstance()方法做同步处理保证安全啊,比如下面这样的写法:

 public class Test {

private static Test instance;

private Test() {

}

public synchronized static Test getInstance(){

    if(instance==null){

instance=new Test();

    }

    return instance;

}

}


这样的写法是保证了线程安全,但是由于getInstance()方法做了同步处理,synchronized将导致性能开销。如getInstance()方法被多个线程频繁调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个方案将能够提供令人满意的性能。


那么,有没有更优雅的方案呢?前人的智慧是伟大的,在早期的JVM中,synchronized存在巨大的性能开销,因此,人们想出了一个“聪明”的技巧--双重检查锁定。人们通过双重检查锁定来降低同步的开销,代码如下:

public class Test { //1

    private static Test instance; //2

    private Test() {

    }

    public static Test getInstance() { //3

        if (instance == null) { //4:第一次检查

            synchronized (Test.class) { //5:加锁

                if (instance == null) //6:第二次检查

                    instance = new Test(); //7

            } //8

        } //9

        return instance; //10

    } //11

}


如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。


坑1:指令重排问题


双重检查锁定看起来似乎很完美,这种写法是不是绝对安全呢?从语义角度来看,并没有什么问题,但是其实还是有坑。为什么呢?第7行代码可分解为如下的3行伪代码:

memory=allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance=memory; //3:设置instance指向刚分配的内存地址


伪代码中的2和3之间,可能会被重排序「在一些JIT编译器上,这种重排序是真实发生的」,2和3之间重排序之后的执行时序如下:

memory=allocate(); //1:分配对象的内存空间

instance=memory; //3:设置instance指向刚分配的内存地址,注意此时对象还没有被初始化

ctorInstance(memory); //2:初始化对象


回到示例代码第7行,如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化完成,进而导致异常的出现。


在知晓问题发生的根源之后,我们可以想出两个办法解决:一是不允许2和3重排序;二是允许2和3重排序,但不允许其他线程“看到”这个重排序。


基于volatile的解决方案,不允许2和3重排序


解决这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。


volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。volatile关键字就可以从语义上解决这个问题。


注意,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。


jdk1.5以后的版本「当然目前主流JDK版本已然是jdk1.5后续版本了,注意一下即可」,对于前面的基于双重检查锁定的方案,只需要做一点小的修改,就可以实现线程安全的延迟初始化,示例代码如下:

public class Test {

    private volatile static Test instance;

    private Test() {

    }

    public static Test getInstance() {

        if (instance == null) {

            synchronized (Test.class) {

                if (instance == null)

                    instance = new Test();//instance为volatile,现在没问题了

            }

        }

        return instance;

    }

}


当声明对象的引用为volatile后,前面伪代码谈到的2和3之间的重排序,在多线程环境中将会被禁止。


基于类初始化的解决方案,允许2和3重排序,但不允许其他线程“看到”这个重排序


JVM在类的初始化阶段「即在Class被加载后,且被线程使用之前」,会执行类的初始化。在执行类的初始化期间,JVM会去获取多个线程对同一个类的初始化。基于这个特性,实现的示例代码如下:

public class Test {

    private Test() {

    }

    private static class InstanceHolder {

        public static Test instance = new Test();

    }

    public static Test getInstance() {

        return InstanceHolder.instance; //这里将导致InstanceHolder类被初始化

    }

}


这个方案的本质是允许前面伪代码谈到的2和3重排序,但不允许其他线程“看到”这个重排序。在Test示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能会在同一时刻调用getInstance()方法来初始化IInstanceHolder类)。Java语言规定,对于每一个类和接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。


坑2:序列化与反射问题


但是,上面提到的所有实现方式都有两个共同的缺点:

1.都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。


2.可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。


当然,还有一种更加优雅的方法来实现单例模式,那就是枚举写法:

public enum Singleton {

    INSTANCE;

    private String name;

    public String getName(){

        return name;

    }

    public void setName(String name){

        this.name = name;

    }

}

调用时的伪代码:

Singleton.INSTANCE.getName();


使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,Effective Java推荐尽可能地使用枚举来实现单例。


总结


代码没有一劳永逸的写法,只有在特定条件下最合适的写法。在不同的平台、不同的开发环境(尤其是jdk版本)下,自然有不同的最优解或者说较优解。


比如枚举,虽然Effective Java中推荐使用,但是在Android平台上却是不被推荐的。在这篇Android Training中明确指出:Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.


再比如双重检查锁法,不能在jdk1.5之前使用,而在Android平台上使用就比较放心了(一般Android都是jdk1.6以上了,不仅修正了volatile的语义问题,还加入了不少锁优化,使得多线程同步的开销降低不少)。


最后,不管采取何种方案,请时刻牢记单例的三大要点:

  1. 线程安全

  2. 延迟加载

  3. 序列化与反序列化安全