昨天,有位小伙伴在评论区留言,希望我分享一些设计模式相关的面试题。设计模式本身是很抽象的,但是在很多面试中又经常被问到,很多小伙伴其实都能答得上,但是又不知道怎么样回答才能让面试官满意,往往越简单的知识越能够体现出核心竞争力。
今天,我给大家分享一个简单又不简单的单例模式,希望能够帮助到大家。先来看单例模式的定义。
关于单例模式的定义,官方原文是这样描述的:
Ensure a class has only one instance,and provide a global point of access to it.
大致意思是,确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。
单例模式的写法相信只要是程序员应该都会,也很非常简单,这里我就不一一列举了。今天,我要重点要给大家分析的是,在Java中,哪些单例对象是最有可能被破坏的。
我把可能出现单例被破坏的情况,一共归纳为五种,分别为多线程破坏单例、指令重排破坏单例、克隆破坏单例、反序列化破坏单例、反射破坏单例。
下面我详细分析一下每种情况并给出解决方案:
第一种:多线程破坏单例
在多线程环境下,线程的时间片是由CPU自由分配的,具有随机性,而单例对象作为共享资源可能会同时被多个线程同时操作,从而导致同时创建多个对象。当然,这种情况只出现在懒汉式单例中。如果是饿汉式单例,在线程启动前就被初始化了,不存在线程再创建对象的情况。
如果懒汉式单例出现多线程破坏的情况,我给出以下两种解决方案:
1、改为DCL双重检查锁的写法。
2、使用静态内部类的写法,性能更高。
第二种:指令重排破坏单例
指令重排也可能导致懒汉式单例被破坏。来看这样一句代码:
instance = new Singleton();
看似简单的一段赋值语句:instance = new Singleton();
其实JVM内部已经被转换为多条执行指令:
memory = allocate(); 分配对象的内存空间指令
ctorInstance(memory); 初始化对象
instance = memory; 将已分配存地址赋值给对象引用
1、分配对象的内存空间指令,调用allocate()方法分配内存。
2、调用ctorInstance()方法初始化对象
3、将已分配存地址赋值给对象引用
但是经过重排序后,执行顺序可能是这样的:
memory = allocate(); 分配对象的内存空间指令
instance = memory; 将已分配存地址赋值给对象引用
ctorInstance(memory); 初始化对象
1、分配对象的内存空间指令
2、设置instance指向刚分配的内存地址
3、初始化对象
我们可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化的指令被排在了后面,在线程 T1 初始化完成这段内存之前,线程T2 虽然进不去同步代码块,但是在同步代码块之前的判断就会发现 instance 不为空,此时线程T2 获得 instance 对象,如果直接使用就可能发生错误。
如果出现这种情况,我该如何解决呢?只需要在成员变量前加volatile,保证所有线程的可见性就可以了。
private static volatile Singleton instance = null;
第三种:克隆破坏单例
在Java中,所有的类就继承自Object,也就是说所有的类都实现了clone()方法。如果是深clone(),每次都会重新创建新的实例。那如果我们定义的是单例对象,岂不是也可调用clone()方法来反复创建新的实例呢?确实,这种情况是有可能发生的。为了避免发生这样结果,我们可以在单例对象中重写clone() 方法,将单例自身的引用作为返回值。这样,就能避免这种情况发生。
第四种:反序列化破坏单例
我们将Java对象序列化以后,对象通常会被持久化到磁盘或者数据库。如果我们要再次加载到内存,就需要将持久化的内容反序列化成Java对象。反序列化是基于字节码来操作的,我们要序列化以前的内容进行反序列化到内存,就需要重新分配内存,也就是说,要重新创建对象。那如果要反序列化的对象恰恰是单例对象,我们该怎么办呢?
我告诉大家一种解决方案,在反序列的过程中,Java API会调用readResolve()方法,可以通过获取readResolve()方法的返回值覆盖反序列化创建的对象。
因此,只需要重写readResolve()方法,将返回值设置为已经存在的单例对象,就可以保证反序列化以后的对象是同一个了。之后再将反序列化后的对象中的值,克隆到单例对象中。
第五种:反射破坏单例
以上讲的所有单例情况都有可能被反射破坏。因为Java中的反射机制是可以拿到对象的私有的构造方法,也就是说,反射可以任意调用私有构造方法创建单例对象。当然,没有人会故意这样做,但是如果出现意外的情况,该如何处理呢?我推荐大家两种解决方案,
第一种方案是在所有的构造方法中第一行代码进行判断,检查单例对象是否已经被创建,如果已经被创建,则抛出异常。这样,构造方法将会被终止调用,也就无法创建新的实例。
第二种方案,将单例的实现方式改为枚举式单例,因为在JDK源码层面规定了,不允许反射访问枚举。
最后总结一下:
1、在所有单例写法中,如果程序不是太复杂,单例对象又不多,推荐使用饿汉式单例。
2、但如果经常发生多线程并发情况下,推荐使用静态内部类和枚举式单例,我的《设计模式就该这样学》这本书中,也推荐这样的写法。