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


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

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

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

  • 相关阅读:
    今晚学到了2.2
    默默开始学英语了。
    VBScript连接数据库
    关于selenium截图
    Python异常处理try...except、raise
    Django中contenttype的应用
    Django Rest Framework
    scrapy信号扩展
    scrapy_redis使用
    Twisted模块
  • 原文地址:https://www.cnblogs.com/helloworld114/p/6511085.html
Copyright © 2020-2023  润新知