单例模式是保证一个类的实例有且只有一个,在需要控制资源(如数据库连接池),或资源共享(如有状态的工具类)的场景中比较适用。如果让我们写一个单例实现,估计绝大部分人都觉得自己没问题,但如果需要实现一个比较完美的单例,可能并没有你想象中简单。本文以主人公小雨的一次面试为背景,循序渐进地讨论如何实现一个较为“完美”的单例。本文人物与场景皆为虚构,如有雷同,纯属捏造。

小雨计算机专业毕业三年,对设计模式略有涉猎,能写一些简单的实现,掌握一些基本的JVM知识。在某次面试中,面试官要求现场写代码:请写一个你认为比较“完美”的单例。

简单的单例实现

凭借着对单例的理解与印象,小雨写出了下面的代码

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

    public static final Singleton getInstance(){
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

写完后小雨审视了一遍,总觉得有点太简单了,离“完美”貌似还相差甚远。对,在多线程并发环境下,这个实现就玩不转了,如果两个线程同时调用 getInstance() 方法,同时执行到了 if 判断,则两边都认为 instance 实例为空,都会实例化一个 Singleton 对象,就会导致至少产生两个实例了,小雨心想。嗯,需要解决多线程并发环境下的同步问题,保证单例的线程安全。

线程安全的单例

一提到并发同步问题,小雨就想到了锁。加个锁还不简单,synchronized 搞起,

public class Singleton {
    private static Singleton instance;

    private Singleton(){}

    public synchronized static final Singleton getInstance(){
        if(instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

小雨再次审视了一遍,发现貌似每次 getInstance() 被调用时,其它线程必须等待这个线程调用完才能执行(因为有锁锁住了嘛),但是加锁其实是想避免多个线程同时执行实例化操作导致产生多个实例,在单例被实例化后,后续调用 getInstance() 直接返回就行了,每次都加锁释放锁造成了不必要的开销。

经过一阵思索与回想之后,小雨记起了曾经看过一个叫 Double-Checked Locking 的东东,双重检查锁,嗯,再优化一下,

public class Singleton {
    private static volatile Singleton instance;

    private Singleton(){}

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

单例在完成第一次实例化,后续再调用 getInstance() 先判空,如果不为空则直接返回,如果为空,就算两个线程同时判断为空,在同步块中还做了一次双重检查,可以确保只会实例化一次,省去了不必要的加锁开销,同时也保证了线程安全。并且令小雨感到自我满足的是他基于对JVM的一些了解加上了 volatile 关键字来避免实例化时由于指令重排序优化可能导致的问题,真是画龙点睛之笔啊。 简直——完美!

Tips: volatile关键字的语义

  1. 保证变量对所有线程的可见性。对变量写值的时候JMM(Java内存模型)会将当前线程的工作内存值刷新到主内存,读的时候JMM会从主内存读取变量的值而不是从工作内存读取,确保一个变量值被一个线程更新后,另一个线程能立即读取到更新后的值。
  2. 禁止指令重排序优化。JVM在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,使用 volatile 可以禁止进行指令重排序优化。

JVM创建一个新的实例时,主要需三步:

  1. 分配内存
  2. 初始化构造器
  3. 将对象引用指向分配的内存地址

如果一个线程在实例化时JVM做了指令重排,比如先执行了1,再执行3,最后执行2,则另一个线程可能获取到一个还没有完成初始化的对象引用,调用时可能导致问题,使用volatile可以禁止指令重排,避免这种问题。

小雨将答案交给面试官,面试官瞄了一眼说道:“基本可用了,但如果我用反射直接调用这个类的构造函数,是不是就不能保证单例了。” 小雨挠挠头,对哦,如果使用反射就可以在运行时改变单例构造器的可见性,直接调用构造器来创建一个新的实例了,比如通过下面这段代码

 Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
 constructor.setAccessible(true);
 Singleton singleton = constructor.newInstance();

小雨再次陷入了思考。

反射安全的单例

怎么避免反射破坏单例呢,或许可以加一个静态变量来控制,让构造器只有从 getInstance() 内部调用才有效,不通过 getInstance() 直接调用则抛出异常,小雨按这个思路做了一番改造,

public class Singleton {
    private static volatile Singleton instance;
    private static boolean flag = false;

    private Singleton(){
        synchronized (Singleton.class) {
            if (flag) {
                flag = false;
            } else {
                throw new RuntimeException("Please use getInstance() method to get the single instance.");
            }
        }

    }

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    flag = true;
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

使用静态变量 flag 来控制,只有从 getInstance() 调用构造器才能正常实例化,否则抛出异常。但马上小雨就发现了存在的问题:既然可以通过反射来调用构造器,那么也可以通过反射来改变 flag 的值,这样苦心设置的 flag 控制逻辑不就被打破了吗。看来也没那么“完美”。虽然并不那么完美,但也一定程度上规避了使用反射直接调用构造器的场景,并且貌似也想不出更好的办法了,于是小雨提交了答案。

面试官露出迷之微笑:“想法挺好,反射的问题基本解决了,但如果我序列化这个单例对象,然后再反序列化出来一个对象,这两个对象还一样吗,还能保证单例吗。如果不能,怎么解决这个问题?”

SerializationSafeSingleton s1 = SerializationSafeSingleton.getInstance();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(s1);
oos.close();

ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
SerializationSafeSingleton s2 = (SerializationSafeSingleton) ois.readObject();
ois.close();

s1 == s2 吗? 答案是否,如何解决呢。

序列化安全的单例

小雨思考了一会,想起了曾经学习序列化知识时接触的 readResolve() 方法,该方法在ObjectInputStream已经读取一个对象并在准备返回前调用,可以用来控制反序列化时直接返回一个对象,替换从流中读取的对象,于是在前面实现的基础上,小雨添加了一个 readResolve() 方法,

public class Singleton {
    private static volatile Singleton instance;
    private static boolean flag = false;

    private Singleton(){
        synchronized (Singleton.class) {
            if (flag) {
                flag = false;
            } else {
                throw new RuntimeException("Please use getInstance() method to get the single instance.");
            }
        }

    }

    public static final Singleton getInstance(){
        if(instance == null) {
            synchronized (Singleton.class){
                if(instance == null) {
                    flag = true;
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    /**
     * 该方法代替了从流中读取对象
     * @return
     */
    private Object readResolve(){
        return getInstance();
    }
}
    

通过几个步骤的逐步改造优化,小雨完成了一个基本具备线程安全、反射安全、序列化安全的单例实现,心想这下应该足够完美了吧。面试官脸上继续保持着迷之微笑:“这个实现看起来还是显得有点复杂,并且也不能完全解决反射安全的问题,想想看还有其它实现方案吗。”

其它方案

小雨反复思考,前面的实现是通过加锁来实现线程安全,除此之外,还可以通过类的加载机制来实现线程安全——类的静态属性只会在第一次加载类时初始化,并且在初始化的过程中,JVM是不允许其它线程来访问的,于是又写出了下面两个版本

1.静态初始化版本

public class Singleton {
    private static final Singleton instance = new Singleton();

    private Singleton(){}

    public static final Singleton getInstance() {
        return instance;
    }
}

该版本借助JVM的类加载机制,本身线程安全,但只要 Singleton 类的某个静态对象(方法或属性)被访问,就会造成实例的初始化,而该实例可能根本不会被用到,造成资源浪费,另一方面也存在反射与序列化的安全性问题,也需要进行相应的处理。

2.静态内部类版本

public class Singleton {
    private Singleton(){}

    public static final Singleton getInstance() {
        return SingletonHolder.instance;
    }

    private static class SingletonHolder {
        private static final Singleton instance = new Singleton();
    }
}

该版本只有在调用 getInstance() 才会进行实例化,即延迟加载,避免资源浪费的问题,同时也能保障线程安全,但是同样存在反射与序列化的安全性问题,需要相应处理。

这貌似跟前面版本的复杂性差不多啊,依然都需要解决反射与安全性的问题,小雨心想,有没有一种既简单又能避免这些问题的方案呢。

“完美”方案

一阵苦思冥想之后,小雨突然脑中灵光闪现,枚举!(这也是《Effective Java》的作者推荐的方式啊)

public enum Singleton {
    INSTANCE;

    public void func(){
        ...
    }
}

可以直接通过 Singleton.INSTANCE 来引用单例,非常简单的实现,并且既是线程安全的,同时也能应对反射与序列化的问题,面试官想要的估计就是它了吧。小雨再次提交了答案,这一次,面试官脸上的迷之微笑逐渐消失了……

Tips:为什么枚举是线程、反射、序列化安全的?

  1. 枚举实际是通过一个继承自Enum的final类来实现(通过反编译class文件可看到具体实现),在static代码块中对其成员进行初始化,因此借助类加载机制来保障其线程安全
  2. 枚举是不支持通过反射实例化的,在Constructor类的newInstance方法中可看到
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");
  1. 枚举在序列化的时候仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。并且,编译器是不允许任何对这种序列化机制的定制的,禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。枚举通过这种机制保障了序列化安全。

总结

枚举方案近乎“完美”,但实际中,大部分情况下,我们使用双重检查锁方案或静态内部类方案基本都能满足我们的场景并能很好地运行。并且方案从来没有“完美”,只有更好或更合适。本文只是从单例实现的不断演进的过程中,了解或回顾如反射、序列化、线程安全、Java内存模型(volatile语义)、JVM类加载机制、JVM指令重排序优化等方面的知识,同时也是启示我们在设计或实现的过程中,多从各个角度思考,尽可能全面地考虑问题。或者,在相关面试中能更好地迎合面试官的“完美”期望。


作者:雨歌,一枚仍在学习路上的IT老兵
欢迎关注作者公众号:半路雨歌,一起学习成长
qrcode

版权声明:本文为spec-dog原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/spec-dog/p/12892899.html