1、引言
最近在看Holis
大神写的《深入理解Java核心技术》一书的时候,看到反射和序列化章节的时候, 分别都提到反射和序列化两种方式可以破坏单例,然后紧接着又看到了枚举enum
方式实现单例在《Effective Java》 一书中被称为是单例的最佳实现方法,正好就借着这个机会, 总结回顾一下单例的实现,以及单例是如何被破坏等知识。
2、单例模式的实现的几种方式
单例的实现方式有很多种,如饿汉式单例、懒汉式单例、枚举方式等等。下面我们对这些单例的实现依次展示一下。
2.1、实现方式一、饿汉式
- 优点:
- 类加载到内存后,就实例化一个单例bean
- 通过
JVM
来保证线程安全(因为 jvm保证每个类加载到内存中的时候只load一次)
- 缺点:
- 不管是否用到, 类在装载的时候都会实例化出来单例bean, 因此可能会造成资源浪费。
public class SingletonDemo01 {
/**
* 定义一个静态实例
* */
private static final SingletonDemo01 INSTANCE = new SingletonDemo01();
private SingletonDemo01(){};
public static SingletonDemo01 getInstance(){
return INSTANCE;
}
public void m(){
System.out.println("m");
}
public static void main(String[] args) {
SingletonDemo01 instance01 = SingletonDemo01.getInstance();
SingletonDemo01 instance02 = SingletonDemo01.getInstance();
// 比较两个实例的地址是否相同
System.out.println(instance01==instance02);
}
}
2.2、实现方式二、通过静态代码块实现饿汉式单例
public class SingletonDemo02 {
/**
* 定义一个静态实例
* */
private static final SingletonDemo02 INSTANCE;
// 使用静态语句块实现的的 初始化
static {
INSTANCE = new SingletonDemo02();
}
private SingletonDemo02(){};
public static SingletonDemo02 getInstance(){
return INSTANCE;
}
public void m(){
System.out.println("m");
}
public static void main(String[] args) {
SingletonDemo02 instance01 = SingletonDemo02.getInstance();
SingletonDemo02 instance02 = SingletonDemo02.getInstance();
// 比较两个实例的地址是否相同
System.out.println(instance01==instance02);
}
}
2.3、实现方式三、懒汉式
- 优点:解决了上面饿汉式单例,资源浪费的问题, 实现了按需加载,需要的时候再实例化这个单例bean
- 缺点:带来了线程安全问题,多线程访问的时候会有问题。
public class SingletonDemo03 {
// 定义一个INSTANCE 常量, 不进行初始化,等到用的时候初始化
private static SingletonDemo03 INSTANCE;
private SingletonDemo03(){
}
public static SingletonDemo03 getInstance(){
if(INSTANCE == null){
try {
Thread.sleep(1);
}catch (InterruptedException e){
e.printStackTrace();
}
INSTANCE = new SingletonDemo03();
}
return INSTANCE;
}
public void m(){
System.out.println("m");
}
public static void main(String[] args) {
// 这里我们模拟了100个线程访问这个单例
for (int i = 0; i < 100; i++) {
// lambda 表达式
new Thread(() ->
System.out.println(SingletonDemo03.getInstance().hashCode())
).start();
// 等价于下面的匿名内部类写法
// new Thread(new Runnable() {
// @Override
// public void run() {
// System.out.println(SingletonDemo03.getInstance().hashCode());
// }
// }).start();
}
}
}
分析原因:从我个人理解来看,当刚开始第一个线程进来的时候, 此时INSTANCE == NULL
, 由于逻辑操作是耗时操作, 我们这边模拟的需要一秒钟的时候, 此时另外一个线程进来了,而此时第一个线程的单例还没有创建出来, 所以另外的其他线程也认为INSTANCE==NULL
,也会创建一个新的单例对象, 所以就会存在线程安全性问题,从而导致单例被破坏了。
2.4、实现方式四、同步代码块 + 懒汉式
- 优点:
- 解决了按需加载的问题
- 解决了多线程环境下线程安全的问题
- 缺点:
- 多线程访问的效率收到了影响,相当于排队依次执行了。
public class SingletonDemo04 {
private static SingletonDemo04 INSTANCE;
private SingletonDemo04() {
}
// 这里是会用了同步代码块,对getInstance 方法进行同步处理, 这样就解决的单例bean多线程的线程安全问题
public static synchronized SingletonDemo04 getInstance() {
if (INSTANCE == null) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new SingletonDemo04();
}
return INSTANCE;
}
public void m() {
System.out.println("m");
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> System.out.println(SingletonDemo04.getInstance().hashCode() )).start();
}
}
}
2.5、实现方式五、试图通过减小同步代码块的方式 实现懒汉式单例
public class SingletonDemo05 {
private static SingletonDemo05 INSTANCE;
private SingletonDemo05(){}
public static SingletonDemo05 getInstance(){
if(INSTANCE == null){
synchronized (SingletonDemo05.class){
try{
Thread.sleep(1);
}catch (InterruptedException e){
e.printStackTrace();
}
INSTANCE = new SingletonDemo05();
}
}
return INSTANCE;
}
public void m(){
System.out.println("m");
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> System.out.println(SingletonDemo05.getInstance().hashCode())).start();
}
}
}
上述的方式, 也无法解决单例线程安全问题。
2.6、实现方式六、通过volatile
修饰以及同步代码实现单例---》 推荐使用
public class SingletonDemo06 {
// 加上volatile 是因为, 如果不加上会在没有初始化的时候就返回INSTANCE。(这里面会涉及到指令重排的问题)
private static volatile SingletonDemo06 INSTANCE;
private SingletonDemo06() {
}
/**
* 双重判断instance 是否等于 null
*/
public static SingletonDemo06 getInstance() {
if (INSTANCE == null) {
synchronized (SingletonDemo06.class) {
if (INSTANCE == null) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
INSTANCE = new SingletonDemo06();
}
}
}
return INSTANCE;
}
public void m() {
System.out.println("m");
}
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
new Thread(() -> System.out.println(SingletonDemo06.getInstance().hashCode())).start();
}
}
}
2.7、实现方式六、通过静态内部类的方式现在单例
public class SingletonDemo07 {
private SingletonDemo07() {
}
/**
* 静态内部类
* 当我们只加载 SingletonDemo07 的时候, SingletonDemo07Holder 是不会被加载的
* */
private static class SingletonDemo07Holder {
private final static SingletonDemo07 INSTANCE = new SingletonDemo07();
}
public static SingletonDemo07 getInstance(){
return SingletonDemo07Holder.INSTANCE;
}
public void m(){
System.out.println("m");
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> System.out.println(SingletonDemo07.getInstance().hashCode())).start();
}
}
}
2.8、实现方式八、通过枚举方式实现单例
/**
* 通过枚举类实现的单例
* <p>
* 该方式不仅解决了线程同步的问题, 也还可以防止反序列化
* --枚举单例不会被反序列化的原因是枚举类没有构造方法 ,反编译过后enum 是一个 abstractClass
* effective java 中 推荐写法
*
* @Author:qzk
* @Date: 2022/3/7 8:30 下午
*/
public enum SingletonDemo08 {
INSTANCE;
public void m() {
System.out.println("m");
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> System.out.println(SingletonDemo08.INSTANCE.hashCode())).start();
}
}
}
3、反射方式破坏单例
单例模式
Singleton Pattern
是 Java中的设计模式之一,这种类型的设计模式属于创建行模式。在设计模式中,对单例模式的定义为:
保证一个类仅有一个实例,并提供一个访问它的全局访问点(
getInstance()
)
3.1、反射如何破坏单例
下面我们就借用一种比较常见的单例创建方式-双重校验锁,去研究一下反射是如何破坏单例的。
// 双重校验锁实现单例
public class Singleton{
// volatile 用来修饰会被不同线程方式和修改的变量, 保证 可见性,和 有序性(volatie 是因为其本省包含 禁止指令重排序的语义)
private static volatile Singleton INSTANCE;
private Singleton(){
}
public static Singleton getInstance(){
if(INSTANCE == null ){
synchronized (Singleton.class){
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 以上代码可以保证
Singleton
类的对象只有一个,其做法就是将Singleton
设置为私有, 并且在getInstance()
方法中做并发控制。 - 我们知道单例的实现,是将构造函数设置为私有,在类的内部构造出一个单例对象,并通过开放访问单例接口的方式实现单例的访问。
- 而恰恰, 反射可以再运行期获取并调用一个类的方法, 包括私有方法,所以我们可以通过反射创建一个新对象。
// 通过反射的方式创建一个新的Singleton 对象
public class ReflectDestroySingleton {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Singleton instance1 = Singleton.getInstance();
// 通过反射获取构造函数
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
// 将构造函数设置为可访问类型
constructor.setAccessible(true);
// 调用构造函数的newInstance创建一个对象
Singleton instance2 = constructor.newInstance();
System.out.println(instance1 == instance2);
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
通过
setAccessible(true)
, 在使用反射对象时可以取消访问限制检查,是的私有的构造函数能够被访问。
3.2、如果避免反射破坏的单例
- 我们其实只需要改造一下上面的构造函数, 使得私有的构造器函数在反射调用的时候识别对象是够被创建过即可:
// 双重校验锁实现单例
public class Singleton{
// volatile 用来修饰会被不同线程方式和修改的变量, 保证 可见性,和 有序性(volatie 是因为其本省包含 禁止指令重排序的语义)
private static volatile Singleton INSTANCE;
private Singleton(){
if(INSTANCE != null) {
throw new RuntimeException("单例对象只允许创建一次,且已存在");
}
}
public static Singleton getInstance(){
if(INSTANCE == null ){
synchronized (Singleton.class){
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
4、序列化方式破坏单例
前面我们介绍了,反射技术是如何破坏单例模式,其实通过序列化 + 反序列化技术也能四线对单例模式的破坏。假设我们使用传统的双重校验锁的方式定义一个单例
4.1、序列化方式破坏单例
- 双重校验锁实现单例
// 双重校验锁实现单例
public class Singleton implements Serializable{
// volatile 用来修饰会被不同线程方式和修改的变量, 保证 可见性,和 有序性(volatie 是因为其本省包含 禁止指令重排序的语义)
private static volatile Singleton INSTANCE;
private Singleton(){
}
public static Singleton getInstance(){
if(INSTANCE == null ){
synchronized (Singleton.class){
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 下面我们尝试通过序列化/反序列化的技术来操作上面的单例类,先将其对象序列化写入文件,再反序列化成一个Java对象。
public class SerializationDestroySingleton {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 写入文件
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("tempFile"));
objectOutputStream.writeObject(Singleton.getInstance());
// 读取文件
File file = new File("tempFile");
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton)objectInputStream.readObject();
Singleton singletonInstance = Singleton.getInstance();
System.out.println(newInstance == singletonInstance);
System.out.println(newInstance.hashCode());
System.out.println(singletonInstance.hashCode());
}
}
输出的结果为false,说明:
通过对Singleton 进行序列化与反序列化得到的对象是一个新的对象, 这就破坏了单例性了。
下面再讲如何解决这个问题的时候, 我们先深入分析一下为什么会这样子?在反序列化过程中到底发生了什么?
- 对象的序列化是通过
ObjectInputStream
和ObjectOutputStream
实现的,带着上面我们提出来的问题, 下面我们分析ObjectInputStream
的readObject
方法的执行情况。
- 下面重点看一下
readOrdinaryObject
方法。
private Object readOrdinaryObject(boolean unshared)
throws IOException
{
// 此处省略部分代码
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
// 此处省略部分代码
if (obj != null &&
handles.lookupException(passHandle) == null &&
desc.hasReadResolveMethod())
{
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
// Filter the replacement object
if (rep != null) {
if (rep.getClass().isArray()) {
filterCheck(rep.getClass(), Array.getLength(rep));
} else {
filterCheck(rep.getClass(), -1);
}
}
handles.setObject(passHandle, obj = rep);
}
}
return obj;
}
我们先分析第一部分:
Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}
isInstantiable
方法- 如果一个
serializable/externalizable
的类可以再运行时被实例化,那么该方法就返回true
。
- 如果一个
desc.newInstance()
方法- 该方法通过反射的方式调用无参构造方法新建对个对象。
至此,我们就可以解释为什么序列化可以破坏单例了。
答案: 序列化会通过反射调用无参构造方法创建一个新对象。
4.2、如何防止序列化破坏单例
这里我们先给出方案, 再具体分析原理
方案: 只要在 Singleton 类中定义一个
readResolve
方法就可以解决问题了。
// 双重校验锁实现单例
public class Singleton implements Serializable{
// volatile 用来修饰会被不同线程方式和修改的变量, 保证 可见性,和 有序性(volatie 是因为其本省包含 禁止指令重排序的语义)
private static volatile Singleton INSTANCE;
private Singleton(){
}
public static Singleton getInstance(){
if(INSTANCE == null ){
synchronized (Singleton.class){
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
public Object readResolve(){
return INSTANCE;
}
}
- 运行测试类
// 为了编写方便,这里忽略看了关闭流操作即删除文件的操作, 真正编码过程中千万不要忘记
public class SerializationDestroySingleton {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 写入文件
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("tempFile"));
objectOutputStream.writeObject(Singleton.getInstance());
// 读取文件
File file = new File("tempFile");
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton)objectInputStream.readObject();
Singleton singletonInstance = Singleton.getInstance();
System.out.println(newInstance == singletonInstance);
System.out.println(newInstance.hashCode());
System.out.println(singletonInstance.hashCode());
}
}
- 由此可以,确实问题得到了解决, 那么为什么解决了呢? 下面我们继续分析
readOrdinaryObject
方法中的第二段代码
if (obj != null &&
handles.lookupException(passHandle) == null &&
//hasReadResolveMethoad() 如果实现了 serializable 或者 externalizable接口的类中包含readResolve 方法。
desc.hasReadResolveMethod())
{
// 通过反射调用 ReadResolve 方法
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
// Filter the replacement object
if (rep != null) {
if (rep.getClass().isArray()) {
filterCheck(rep.getClass(), Array.getLength(rep));
} else {
filterCheck(rep.getClass(), -1);
}
}
handles.setObject(passHandle, obj = rep);
}
}
在
Singleton
中敌营了readResolve
方法, 并在该方法中指定要返回的对象的生成策略,就可以防止单例模式被破坏。
5、为什么枚举方式实现单例模式是最好的?
作为 23中设计模式中最常用的设计模式,单例模式并没有想想的那么简单。因为在设计单例时需要考虑很多问题,比如线程安全问题,序列化对象单例模式的破坏等。
关于那种单例模式的写法最好的讨论, 在
StackOverflow
中有个关于What is an efficient way to implement a singleton pattern in Java?
的讨论, 得票率最高的回答是 : 使用枚举。回答者,引用 Joshua Bloch 在 《Effective Java》 中明确表达的观点:
使用枚举实现的按理的方式虽然还没被广泛采用, 但是单元素的枚举类型已经成为实现 Singleton 的最佳方式。
我们在简单对比 使用双重校验锁 和使用枚举的方式就可以看出, 考虑到线程安全问题,上面的"双重锁校验"的代码就显得非常臃肿,这也是因为大部分代码都在保证线程安全,并且在线程安全和锁粒度之间做权衡。但是双重锁校验的方式还是有其他问题就是无法解决反序列化会破坏单例模式的问题。
而枚举相对于上述的方法来说就可以解决线程安全问题的同时,解决其他问题。
5.1、枚举可以解决线程安全问题
其实,并不是使用枚举就不需要保证线程安全,只不过线程安全的保证不需要我们关系, 也就是说,其实在 底层还是做了线程安全方面的保证的。这部分就要从枚举的实现去理解了。
定义枚举使用的 enum
和 class
一样, 都是java的一个关键字。我们通过反编译定义好的枚举,我们就能发现, 其实枚举经过javac的编译之后, 就会被转换成 public final class T extend Enum
的定义。
public final class T extends Enum{
// 省略部分内容
public static final T SPRING;
public static final T SUMMER;
public static final T AUTUMN;
public static final T WINTER;
private static final T ENUM$VALUES[];
static{
SPRING = new T("SPRING",0);
SUMMER = new T("SUMMER",1);
AUTUMN = new T("AUTUMN",2);
WINTER = new T("WINTER",3);
ENUM$VALUES = (new T[]{
SPRING,SUMMER,AUTUMN,WINTER
})
}
}
小结:
static
修饰的属性,会在类被加载之后被初始化, 当一个java类第一次被真正使用时 静态资源被初始化, Java类的加载和初始化过程都是线程安全的,因为虚拟机在加载枚举的类时, 会使用Classloader
的loadClass
方法,而这个方法使用同步代码块保证了线程安全。 所以,创建一个enum 类型是线程安全的。
5.2、枚举可以解决反序列化会破坏单例模式的问题
普通的Java类的反序列化的过程中, 会通过反射调用类的默认构造函数来初始化对象。
所以, 即使单例中的构造函数式私有的,也会被反射破坏。
由于反序列化后的对象是从新 new重来的, 所以就破坏了单例模式。
枚举的反序列化, 和普通对象的反序列化不太一样,并不是通过反射实现的,所以也就不会发生由于反序列化导致的单例破坏问题。
在序列化时Java 仅将枚举对象的name属性输出到结果中, 反序列化时则是通过
java.lang.Enum
的ValueOf
方法根据名字查找内聚对象的。 同时,编译器是不允许任何这种序列化机制的定制的, 因此 禁用了 wiriteObject、readObject、readObjetcNoData、writeReplace、readResolve 等方法。
public static <T extends Enum<T>> T valueOf(Class<T> enumType,String name){
// 调用 enumType的 enumConstantDirectory() 方法返回的集合map中获取name的枚举对象,如果不存在就破处异常。
T result = enumType.enumConstantDirectory().get(name);
if(result != null){
return result;
}
if(name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum const " + enumType + "." + name;
)
}
- 再跟踪
enumConstantDirectory
方法, 就会发现到最后以反射的方式调用enumType
这个类型的values()
静态方法,也就是我们上面看到的编译器创建的那个方法,然后用返回结果填充enumType
这个Class
对象中的enumConstantDirectory
属性。
5.3、为什么针对枚举类型的序列化做特殊的约定?
这其实和枚举得特性有关系的, 根据java规范的规定, 每一个枚举类型及其定义的枚举变量在JVM 中都是唯一的(单例性)。也就是说,每一个枚举项在JVM中都是单例的。
但是之前我们说过,序列化 + 反序列化 是可以破坏单例模式的, 所以为了保证枚举对象的唯一性,Java就针对枚举做出了如前说述的特殊规定了。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Blazor Hybrid适配到HarmonyOS系统
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· 分享4款.NET开源、免费、实用的商城系统
· 解决跨域问题的这6种方案,真香!
· 一套基于 Material Design 规范实现的 Blazor 和 Razor 通用组件库