你真的会写单例模式吗?

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

单例模式多是你们常常接触和使用的一个设计模式,你可能会这么写:缓存

public class Test {安全

    private static Test instance;微信

    private Test() {多线程

    }并发

    public static Test getInstance(){性能

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

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

        }this

        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. 序列化与反序列化安全

相关文章
相关标签/搜索