写点什么

就这?彻底搞懂单例模式

作者:蝉沐风
  • 2021 年 11 月 10 日
  • 本文字数:8643 字

    阅读完需:约 28 分钟

就这?彻底搞懂单例模式

单例模式

有些对象我们只需要一个,比如线程池、ServletContext、ApplicationContext、 Windows 中的回收站,此时我们便可以用到单例模式。


单例模式就是确保一个类在任何情况下只有一个实例,并提供一个全局访问点

1. 饿汉式单例

/** * @author 蝉沐风 * 饿汉式单例 */public class HungrySingleton {    //类初始化的时候便进行对象实例化    private static final HungrySingleton hungrySingleton = new HungrySingleton();
private HungrySingleton() { }
public static HungrySingleton getInstance() { return hungrySingleton; }}
复制代码


优点:


  • 饿汉式单例是最简单的一种单例形式,它没有添加任何的锁,执行效率最高

  • 线程安全


缺点:


某些情况下,造成内存浪费,因为对象未被使用的情况下就会被初始化,如果一个项目中的类多达上千个,在项目启动的时候便开始初始化可能并不是我们想要的。

2. 简单的懒汉式单例

想解决饿汉式单例一开始就会进行对象的初始化的问题,一个很自然的想法就是当用户调用getInstance方法的时候再进行实例的创建,修改代码如下:


/** * @author 蝉沐风 * 饿汉式单例 */public class LazySimpleSingleton {    private static LazySimpleSingleton instance;
private LazySimpleSingleton() { }
public static LazySimpleSingleton getInstance() { // 如果实例不存在,则进行初始化 if (instance == null) { instance = new LazySimpleSingleton(); } return instance; }}
复制代码


上述代码在单线程下能够完美运行,但是在多线程下存在安全隐患。大家可以使用 IDEA 进行手动控制线程执行顺序来跟踪内存变化,下面我用图解的形式进行多线程下 3 种情形的说明。


情形 1:



每个线程依次执行 getInstance 方法,得到的结果正是我们所期望的


情形 2:



此种情形下,该种写法的单例模式会出现多线程安全问题,得到两个完全不同的对象


情形 3:



该种情形下,虽然表面上最终得到的对象是同一个,但是在底层上其实是生成了 2 个对象,只不过是后者覆盖了前者,不符合单例模式绝对只有一个实例的要求。

3. 升级的懒汉式单例

/** * @author 蝉沐风 * 饿汉式单例-同步锁 */public class LazySynchronizedSingleton {    private static LazySynchronizedSingleton instance;
private LazySynchronizedSingleton() { } //添加synchronized关键字 public synchronized static LazySynchronizedSingleton getInstance() { if (instance == null) { instance = new LazySynchronizedSingleton(); } return instance; }}
复制代码


升级之后的程序能完美地解决线程安全问题。


但是用synchronized加锁时,在线程数量较多的情况下,会导致大批线程阻塞,从而导致程序性能大幅下降


有没有一种形式,既能兼顾线程安全又能提升程序性能呢?有,这就是双重检查锁。

4. 双重检查锁

/** * @author 蝉沐风 * 双重检查锁 */public class LazyDoubleCheck {    // 需要添加 volatile 关键字    private volatile static LazyDoubleCheck instance;
private LazyDoubleCheck() { } public static LazyDoubleCheck getInstance() { //一重检查:检查实例,如果不存在,进入同步区块 if (instance == null) { synchronized (LazyDoubleCheck.class) { //双重检查:进入同步区块后,再检查一次,如果仍然是null,才创建实例 if (instance == null) { instance = new LazyDoubleCheck(); } } } return instance; }}
复制代码


第一重检查是为了确认 instance 是否已经被实例化,如果是,则无需再进入同步代码块,直接返回实例化对象,否则进入同步代码块进行创建,避免每次都排队进入同步代码块影响效率;


第二重检查是真正与实例的创建相关,如果instance未被实例化,则在此过程中被实例化。


双重检查锁版本的单例模式需要使用到volatile关键字,本文不对volatile关键字进行深入分析,之后会单独开一篇文章进行解释


但是,使用synchronized关键总归是要上锁的,对程序性能还是存在影响,下面介绍一种利用 Java 本身语法特性来实现的一种单例写法。

5. 静态内部类实现单例

/** * @author 蝉沐风 * 静态内部类实现单例 */public class LazyStaticInnerClassSingleton {
private LazyStaticInnerClassSingleton() { }
public static final LazyStaticInnerClassSingleton getInstance() { return LazyHolder.LAZY; } // 静态内部类,未被使用时,是不会被加载的 private static class LazyHolder { private static final LazyStaticInnerClassSingleton LAZY = new LazyStaticInnerClassSingleton(); }}
复制代码


用静态内部类实现的单例本质上是一种懒汉式,因为在执行getInstance中的LazyHolder.LAZY语句之前,静态内部类并不会被加载。


这种方式既避免了饿汉式单例的内存浪费问题,又摆脱了synchronized关键字的性能问题,同时也不存在线程安全问题。




到此为止,我们介绍了 5 种单例写法(除去简单的懒汉式单例由于多线程问题无法用于生产中,其实只有 4 种),我们发现上述单例模式本质上都是将构造方法私有化,避免外部程序直接进行实例化来达到单例的目的。


那如果我们能够想办法获取到类的构造方法,或者将创建好的对象写入磁盘,然后多次加载到内存,是不是可以破坏上述所有的单例呢?


答案是肯定的,下面我们用反射序列化两种方法亲自毁灭我们一手搭建的单例。

6. 反射破坏单例

/** * @author 蝉沐风 * 利用反射破坏单例 */public class SingletonBrokenByReflect {
public static void main(String[] args) {
try { Class<?> clazz = LazyStaticInnerClassSingleton.class;
//通过反射弧获取类的私有构造方法 Constructor c = clazz.getDeclaredConstructor(null); //强制访问 c.setAccessible(true);
Object obj1 = c.newInstance(); Object obj2 = c.newInstance();
//输出false System.out.println(obj1 == obj2);
} catch (Exception e) { e.printStackTrace(); } }

}
复制代码


如此,我们便使用反射破坏了单例。现在我们以静态内部类单例为例,解决这个问题。


我们在构造方法中添加一些限制,一旦检测到对象已经被实例化,但是构造方法仍然被调用时直接抛出异常。


/** * @author 蝉沐风 * 静态内部类实现单例 */public class LazyStaticInnerClassSingleton {
private LazyStaticInnerClassSingleton() { if (LazyHolder.LAZY != null) { throw new RuntimeException("实例被重复创建"); } }
public static final LazyStaticInnerClassSingleton getInstance() { return LazyHolder.LAZY; }
// 静态内部类,未被使用时,是不会被加载的 private static class LazyHolder { private static final LazyStaticInnerClassSingleton LAZY = new LazyStaticInnerClassSingleton(); }}
复制代码

7. 序列化破坏单例

单例对象创建好之后,有时需要将对象序列化然后写入磁盘,在需要时从磁盘中读取对象并加载至内存,反序列化后的对象会重新分配内存,如果序列化的目标对象恰好是单例对象,就会破坏单例模式。


/** * @author 蝉沐风 * 可序列化的单例 */public class SeriableSingleton implements Serializable {    //类初始化的时候便进行对象实例化    private static final SeriableSingleton hungrySingleton = new SeriableSingleton();
private SeriableSingleton() { }
public static SeriableSingleton getInstance() { return hungrySingleton; }}

/** * @author 蝉沐风 * 序列化破坏单例 */public class SingletonBrokenBySerializing {
public static void main(String[] args) { SeriableSingleton s1 = SeriableSingleton.getInstance(); SeriableSingleton s2 = null;
FileOutputStream fos = null; try { File file; fos = new FileOutputStream("SeriableSingleton.obj"); OutputStream out; ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(s1); oos.flush(); oos.close(); fos.close();
FileInputStream fis = new FileInputStream("SeriableSingleton.obj"); ObjectInputStream ois = new ObjectInputStream(fis); s2 = (SeriableSingleton) ois.readObject(); ois.close(); fis.close();

//输出为false System.out.println(s1 == s2); } catch (Exception e) {
} }

}
复制代码


从运行结果上看,反序列化和手动创建出来的对象是不一致的,违反了单例模式的初衷。


那到底如何保证在序列化的情况下也能够实现单例模式呢,其实很简单,只需要增加一个 readResolve 方法即可。


public class SeriableSingleton implements Serializable {    //类初始化的时候便进行对象实例化    private static final SeriableSingleton hungrySingleton = new SeriableSingleton();
private SeriableSingleton() { }
public static SeriableSingleton getInstance() { return hungrySingleton; }
//只需要添加这一个函数即可 private Object readResolve() { return hungrySingleton; }}
复制代码


实现的原理涉及到ObjectInputStream的源码,不属于本文的研究重点,如果读者需要,我可以另开一篇来进行讲解。

8. 注册式单例模式

8.1 枚举式单例模式

很多博客和文章的实现方式如下(文件名:EnumSingleObject.java)


/* * @author 蝉沐风 * 枚举式单例1 */public class EnumSingleObject {    private EnumSingleObject() {    }
enum SingletonEnum { INSTANCE;
private EnumSingleObject instance;
private SingletonEnum() { instance = new EnumSingleObject(); }
public EnumSingleObject getInstance() { return INSTANCE.instance; } }
//对外暴露一个获取EnumSingleObject对象的静态方法 public static EnumSingleObject getInstance() { return SingletonEnum.INSTANCE.getInstance(); }}
复制代码


枚举式的写法为什么可以实现我们的单例模式呢,我们首先使用javac EnumSingleObject.java生成EnumSingleObject.class文件,用反编译工具Jad在.class 所在的目录下执行 jad EnumSingleObject.class命令,得到EnumSingleObject.jad文件,代码如下


static final class EnumSingleObject$SingletonEnum extends Enum {
public static EnumSingleObject$SingletonEnum[] values() { return (EnumSingleObject$SingletonEnum[]) $VALUES.clone(); }
public static EnumSingleObject$SingletonEnum valueOf(String s) { return (EnumSingleObject$SingletonEnum) Enum.valueOf(com / chanmufeng / Singleton / registerSingleton / EnumSingleObject$SingletonEnum, s); }
public EnumSingleObject getInstance() { return INSTANCE.instance; }
public static final EnumSingleObject$SingletonEnum INSTANCE; private EnumSingleObject instance; private static final EnumSingleObject$SingletonEnum $VALUES[];
// 该static代码块是枚举写法能够实现单例模式的关键 static { INSTANCE = new EnumSingleObject$SingletonEnum("INSTANCE", 0); $VALUES = (new EnumSingleObject$SingletonEnum[]{ INSTANCE }); }
private EnumSingleObject$SingletonEnum(String s, int i) { super(s, i); instance = new EnumSingleObject(); }}
复制代码


其实,枚举式单例在静态代码块中就为INSTANCE进行了赋值,是一种饿汉式单例模式的体现,只不过这种饿汉式是 JDK 底层为我们做的操作,我们只是利用了 JDK 语法的特性罢了。


序列化能否破坏枚举式单例


//测试序列化能否破坏public static void main(String[] args) {        EnumSingleObject s1 = EnumSingleObject.getInstance();        EnumSingleObject s2 = null;
FileOutputStream fos = null; try { fos = new FileOutputStream("SeriableSingleton.obj"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(s1); oos.flush(); oos.close(); fos.close();
FileInputStream fis = new FileInputStream("SeriableSingleton.obj"); ObjectInputStream ois = new ObjectInputStream(fis); s2 = (EnumSingleObject) ois.readObject(); ois.close(); fis.close();

//输出为false System.out.println(s1 == s2); } catch (Exception e) { e.printStackTrace(); } }
复制代码


很遗憾,序列化依然会破坏枚举式单例EnumSingleObject


What???不是说枚举式单例非常的优雅吗?连 Effective Java 都推荐使用吗?


别急,接下来我们观察另一种写法


/** * @author 蝉沐风 * 枚举式单例2 */public enum EnumSingleObject2 {
INSTANCE;
private Object data;
public Object getData() { return data; }
public void setData(Object data) { this.data = data; }
public static EnumSingleObject2 getInstance() { return INSTANCE; }}
复制代码


我们再来进行序列化测试


public static void main(String[] args) {        EnumSingleObject2 s1 = EnumSingleObject2.getInstance();        s1.setData(new Object());        EnumSingleObject2 s2 = null;
FileOutputStream fos = null; try { fos = new FileOutputStream("SeriableSingleton.obj"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(s1); oos.flush(); oos.close(); fos.close();
FileInputStream fis = new FileInputStream("SeriableSingleton.obj"); ObjectInputStream ois = new ObjectInputStream(fis); s2 = (EnumSingleObject2) ois.readObject(); ois.close(); fis.close();

//输出为true System.out.println(s1 == s2); } catch (Exception e) { e.printStackTrace(); } }
复制代码


打印结果为true,说明枚举式单例 2 的写法可以防止序列化破坏。


而很多文章和博客用的往往是第 1 种写法,下面我们解释这两种写法的区别.


我们进入ObjectInputStream类的readObject()方法


public final Object readObject()        throws IOException, ClassNotFoundException    {       ...        try {            Object obj = readObject0(false);           ...            return obj;        } finally {           ...        }    }
复制代码


readObject()方法中又调用了readObject0()方法


private Object readObject0(boolean unshared) throws IOException {    ...    //枚举式单例1的程序会进入到这里    case TC_CLASS:      return readClass(unshared);    ...    //枚举式单例2的程序会进入到这里    case TC_ENUM:      return checkResolve(readEnum(unshared));   }
复制代码


我们先看一下readEnum()方法


private Enum<?> readEnum(boolean unshared) throws IOException {        ...        String name = readString(false);        Enum<?> result = null;        Class<?> cl = desc.forClass();        if (cl != null) {            try {                @SuppressWarnings("unchecked")                //!!!!这里是重点                Enum<?> en = Enum.valueOf((Class)cl, name);                result = en;            } catch (IllegalArgumentException ex) {              ...            }                   }        ...        return result;    }
复制代码


到这里我们发现,枚举类型其实通过类名和类对象找到唯一一个枚举对象,因此,枚举对象不会被类加载器加载多次。


readClass()并无此功能。


反射能否破坏枚举式单例


public static void main(String[] args) {
try {
Class<?> clazz = EnumSingleObject2.class;
//通过反射获取类的私有构造方法 Constructor c = clazz.getDeclaredConstructor(null); //强制访问 c.setAccessible(true);
Object obj1 = c.newInstance(); Object obj2 = c.newInstance();
//输出false System.out.println(obj1 == obj2);
} catch (Exception e) { e.printStackTrace(); } }
复制代码


运行结果如下



结果报了java.lang.NoSuchMethodException异常,原因是java.lang.Enum中没有无参的构造方法,我们查看java.lang.Enum的源码,只有下面一个构造函数


protected Enum(String name, int ordinal) {     this.name = name;     this.ordinal = ordinal;}
复制代码


我们改变一下反射构建的方式


public static void main(String[] args) {
try { Class<?> clazz = EnumSingleObject2.class;
//通过反射获取类的私有构造方法// Constructor c = clazz.getDeclaredConstructor(null); Constructor c = clazz.getDeclaredConstructor(String.class, int.class); //强制访问 c.setAccessible(true);
Object obj1 = c.newInstance(); Object obj2 = c.newInstance();
//输出false System.out.println(obj1 == obj2);
} catch (Exception e) { e.printStackTrace(); } }
复制代码


运行结果如下



Cannot reflectively create enum objects,即不能用反射来创建枚举对象,这是ConstructornewInstance()方法在源码上决定的,继续看


public T newInstance(Object ... initargs)        throws InstantiationException, IllegalAccessException,               IllegalArgumentException, InvocationTargetException    {        ...        if ((clazz.getModifiers() & Modifier.ENUM) != 0)            throw new IllegalArgumentException("Cannot reflectively create enum objects");        ConstructorAccessor ca = constructorAccessor;   // read volatile        if (ca == null) {            ca = acquireConstructorAccessor();        }        @SuppressWarnings("unchecked")        T inst = (T) ca.newInstance(initargs);        return inst;    }
复制代码


从源码中可以看出,newInstance()方法中做了强制性的判断,如果修饰符是Modifier.ENUM类型,则直接抛出异常。


最后介绍一种注册时单例的另一种写法:容器式单例

8.2 容器式单例模式

/** * @author 蝉沐风 * 容器式单例 */public class ContainerSingleton {    private static Map<String, Object> ioc = new ConcurrentHashMap<String, Object>();
public static Object getBean(String className) { synchronized (ioc) { if (!ioc.containsKey(className)) { Object obj = null; try { obj = Class.forName(className).newInstance(); ioc.put(className, obj); } catch (Exception e) { e.printStackTrace(); } return obj; } else { return ioc.get(className); } } }}
复制代码


容器式单例适合用于实例非常多的情况,Spring 中就使用了该种单例模式。

总结

单例模式可以保证内存中任何情况下只有一个实例,是最简单的一种设计模式,实现起来也很简单,但是实现方式比较多,涉及到的小细节也比较多,在面试中是一个高频面试点。


我是蝉沐风,一个让你沉迷于技术的讲述者,欢迎大家留言!



发布于: 2021 年 11 月 10 日阅读数: 8
用户头像

蝉沐风

关注

公众号【蝉沐风】 2021.05.14 加入

我是蝉沐风,一个让你沉迷于技术的讲述者

评论

发布
暂无评论
就这?彻底搞懂单例模式