细读Java单例模式

简介: 细读Java单例模式

Java中的单例模式看似是一个很简单的设计模式,但事实上,我们可以整出各种各样的“幺蛾子”。单例模式有着不同的实现方式,也很难找到完美的方式。今天我就来分享一下,单例模式的几种常用实现模式以及存在的问题。

之前我写过文章讲解单例模式,不过那个是最简单的方式,还漏掉了许多的情况,这里我们就来详细地学习学习,这里还是以“一个店只能有一个老板”为例,创建老板类单例。

1,常规实现方式

(1) 饿汉式

这个就是上一篇博客讲的方法,也是最简单的实现方法:

packagecom.example.singleinstance.eager;
importlombok.Getter;
importlombok.Setter;
/*** 饿汉式单例模式*/@Getter@SetterpublicclassMaster {
/*** 名字*/privateStringname;
/*** 唯一单例*/privatestaticMasterinstance=newMaster();
/*** 私有化构造器*/privateMaster() {
   }
/*** 获取老板唯一单例** @return 老板唯一单例*/publicstaticMastergetInstance() {
returninstance;
   }
}

可见饿汉式单例模式之所以叫饿汉式,是因为这种单例模式在类加载的时候就初始化了唯一单例了

这种方式的优缺点也很明显:

  • 优点:执行效率高,绝对线程安全
  • 缺点:有可能用不着该单例,但是它无论如何都初始化了,可能会“占着茅坑不拉屎”,浪费内存

那么如果要改善性能,我们需要进行一些修改。

(2) 懒汉式

懒汉式单例模式就是当外部访问该单例的时候,才会初始化:

packagecom.example.singleinstance.lazy;
importlombok.Getter;
importlombok.Setter;
/*** 懒汉式单例模式*/@Getter@SetterpublicclassMaster {
/*** 名字*/privateStringname;
/*** 唯一单例,先不初始化*/privatestaticMasterinstance=null;
/*** 私有化构造器*/privateMaster() {
   }
/*** 获取老板唯一单例** @return 老板唯一单例*/publicstaticMastergetInstance() {
// 若没有初始化,则初始化一下if (instance==null) {
instance=newMaster();
      }
returninstance;
   }
}

可见我们先不初始化单例,在要调用的时候,判断是否为null,如果是说明是第一次调用,则初始化一下,否则就返回单例。

2,想办法破解单例模式

(1) 多线程破坏单例模式

懒汉式单例模式确实优化了性能,但是并非是线程安全的。假设有n个线程在极短的时间同时访问该单例的getInstance方法,那有可能会有多余一个线程同时判断该单例为null导致最后初始化出多个Master实例。

我们实例化单例的时候就打印输出一下单例的地址,修改getInstance如下:

publicstaticMastergetInstance() {
// 若没有初始化,则初始化一下if (instance==null) {
instance=newMaster();
System.out.println(instance);
   }
returninstance;
}

然后新建两个线程,利用IDEA的线程调试模式,干预线程的执行顺序,来模拟出两个线程同时执行到的情况:

for (inti=0; i<2; i++) {
newThread(() -> {
Mastermaster=Master.getInstance();
   }).start();
}

在这里打断点,并右键断点-线程模式:

网络异常,图片无法展示
|

网络异常,图片无法展示
|

执行调试,可以在调试控制台这里手动切换线程,控制线程运行:

网络异常,图片无法展示
|

使用步入按钮(F5),先让0号线程进入if语句,到达实例化这里停下:

网络异常,图片无法展示
|

切换到1号线程,也让1号线程进入if语句,到达实例化这里停下:

网络异常,图片无法展示
|

最后让两个线程执行完,可以看见控制台输出了两个不同的地址:

网络异常,图片无法展示
|

可见,懒汉式也不完全是线程安全的。

这时,我们可以给getInstance方法上锁,实现线程安全:

publicsynchronizedstaticMastergetInstance() {
// 若没有初始化,则初始化一下if (instance==null) {
instance=newMaster();
   }
returninstance;
}

这样,确实是线程安全了,但是总归是上了锁,对程序的性能会有一定的影响,那难道就没有好一点的方法了吗?

我们可以从类的初始化的角度想一下,我们可以借助内部类来解决这些问题。在Java中,内部类是延时加载的,也就是说你用它它就加载,不用就不加载,不受外部类的影响。利用内部类的这个特性,我们是否能够把单例放在内部类里面呢?我们来试一下子:

packagecom.example.singleinstance.lazy;
importlombok.Getter;
importlombok.Setter;
/*** 懒汉式内部类法单例模式*/@Getter@SetterpublicclassMaster {
/*** 名字*/privateStringname;
/*** 私有化构造器*/privateMaster() {
   }
/*** 获取老板唯一单例,final使得该方法不允许被重写或者重载** @return 老板唯一单例*/publicstaticfinalMastergetInstance() {
// 返回结果之前,会先加载内部类returnInnerMaster.INSTANCE;
   }
/*** 老板类的内部类,没有用到它就不会加载*/privatestaticclassInnerMaster {
privatestaticfinalMasterINSTANCE=newMaster();
   }
}

这种方式完美地解决了饿汉式单例模式的内存问题,和上锁的性能问题。内部类一定是会在方法调用之前初始化,并且它永远只会初始化一次(一个类无法被加载多次),因此避免了线程安全问题。

(2) 反射破坏单例模式

构造器确实被私有化了,但是利用Java的反射机制,仍然可以访问其构造器:

// 利用反射获取构造方法,并设定可访问Constructorconstructor=Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Mastermaster1= (Master) constructor.newInstance();
Mastermaster2= (Master) constructor.newInstance();
System.out.println(master1==master2);

网络异常,图片无法展示
|

可见即使是私有化了构造器,我们仍然还是可以把它new个两下,得到两个实例,违背了单例模式的基本原则。

解决这个问题也不难,我们在构造器里面做点功夫即可:

packagecom.example.singleinstance.lazy;
importlombok.Getter;
importlombok.Setter;
/*** 懒汉式内部类法单例模式*/@Getter@SetterpublicclassMaster {
/*** 名字*/privateStringname;
/*** 私有化构造器*/privateMaster() {
if (InnerMaster.INSTANCE!=null) {
thrownewRuntimeException("不允许创建多个实例!");
      }
   }
/*** 获取老板唯一单例,final使得该方法不允许被重写或者重载** @return 老板唯一单例*/publicstaticfinalMastergetInstance() {
// 返回结果之前,会先加载内部类returnInnerMaster.INSTANCE;
   }
/*** 老板类的内部类,没有用到它就不会加载*/privatestaticclassInnerMaster {
privatestaticfinalMasterINSTANCE=newMaster();
   }
}

再次运行上述代码:

网络异常,图片无法展示
|

好了,到这里我们也更进一步地明白了:一个类被加载时,其内部类不会被加载;而这个类被使用到时,其内部类才会被加载。

这里注意加载使用的区别。应用程序启动时,每个类都会被加载,而你调用这个类用于实例化或者调用其方法的时候,才叫使用这个类。

(3) 序列化破坏单例模式

有时候我们需要把对象序列化并在网络上传输,然后反序列化。大家都知道,反序列化的对象并非是原有的对象,这也破坏了单例模式的原则。

首先让Master类使用Serializable接口,然后作如下测试:

// 序列化ByteArrayOutputStreambos=newByteArrayOutputStream();
ObjectOutputStreamoos=newObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化ByteArrayInputStreambis=newByteArrayInputStream(bos.toByteArray());
ObjectInputStreamois=newObjectInputStream(bis);
Mastermaster= (Master) ois.readObject();
System.out.println(master==Master.getInstance());

网络异常,图片无法展示
|

可见,利用序列化法破坏了单例。

其实,我们只需要在Master类中增加一个readResolve方法即可:

packagecom.example.singleinstance.lazy;
importlombok.Getter;
importlombok.Setter;
importjava.io.Serializable;
/*** 懒汉式内部类法单例模式*/@Getter@SetterpublicclassMasterimplementsSerializable {
/*** 名字*/privateStringname;
/*** 私有化构造器*/privateMaster() {
if (InnerMaster.INSTANCE!=null) {
thrownewRuntimeException("不允许创建多个实例!");
      }
   }
/*** 获取老板唯一单例,final使得该方法不允许被重写或者重载** @return 老板唯一单例*/publicstaticfinalMastergetInstance() {
// 返回结果之前,会先加载内部类returnInnerMaster.INSTANCE;
   }
privateObjectreadResolve() {
returnInnerMaster.INSTANCE;
   }
/*** 老板类的内部类,没有用到它就不会加载*/privatestaticclassInnerMaster {
privatestaticfinalMasterINSTANCE=newMaster();
   }
}

再次运行:

网络异常,图片无法展示
|

这看起来非常神奇:为什么加这个方法就可以了呢?事实上这和ObjectInputStream类的执行逻辑有关。大家可以去研究一下JDK源码就知道了,再次不再过多赘述。

但事实上,这种方法确实保证只返回了一个单例,但是内存中其实还是有多个单例。

当然,肯定有更好的方法。

3,注册式单例模式

顾名思义,注册式单例模式就是把实例先注册到一个地方,获取的时候根据标识符获取。

通常有下列两种方式实现。

(1) 【推荐】枚举式单例模式

利用枚举实现单例模式,也就是把单例类写成枚举类,我们修改Master类如下:

packagecom.example.singleinstance.enumerate;
importlombok.Getter;
importlombok.Setter;
@GetterpublicenumMaster {
/*** 老板类唯一单例*/INSTANCE;
/*** 名字*/@SetterprivateStringname;
/*** 获取老板类唯一实例** @return 老板类唯一实例*/publicstaticMastergetInstance() {
returnINSTANCE;
   }
}

大家都知道:枚举类中的每一个枚举相当于就是这个枚举类的实例,并且枚举类中也可以写成员变量和方法

那枚举类中的枚举是不是单例呢?我们来试一下子。

a. 尝试使用反射破坏

Constructorconstructor=Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Mastermaster= (Master) constructor.newInstance();

结果:

网络异常,图片无法展示
|

可见反射机制找不到枚举类的构造器,这是因为枚举类的构造方法是protected的:

网络异常,图片无法展示
|

b. 尝试使用序列化破坏

// 序列化ByteArrayOutputStreambos=newByteArrayOutputStream();
ObjectOutputStreamoos=newObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化ByteArrayInputStreambis=newByteArrayInputStream(bos.toByteArray());
ObjectInputStreamois=newObjectInputStream(bis);
Mastermaster= (Master) ois.readObject();
System.out.println(master==Master.getInstance());

结果:

网络异常,图片无法展示
|

这也是利用JDK的反序列化机制,也就是说枚举类型其实是通过类名和类对象找到一个唯一的对象,不会被类加载器加载多次

这也可见:枚举值天生就是单例的,非常契合单例模式思想。

(2) 容器式单例

我们还可以使用Map专门做一个单例容器,把实例都放进去:

packagecom.example.singleinstance;
importjava.util.Map;
importjava.util.concurrent.ConcurrentHashMap;
/*** 单例容器*/publicclassSingleContainer {
privateSingleContainer() {
   }
// 存放所有单例的容器,键为类的全限定名,值为对应单实例privatestaticMap<String, Object>container=newConcurrentHashMap<>();
/*** 获取对应类的单实例,不存在则创建** @param className 类的全限定名* @return 单实例*/publicsynchronizedstaticObjectgetInstance(StringclassName) throwsException {
if (!container.containsKey(className)) {
Objectinstance=Class.forName(className).getConstructor().newInstance();
container.put(className, instance);
returninstance;
      }
returncontainer.get(className);
   }
}

这种方式看起来也很高级,不过也会产生线程问题。

4,总结

可见单例模式看起来简单,事实上要想写一个严谨、滴水不漏的单例模式还是很难的。

日常开发,推荐使用基于内部类的懒汉式单例模式或者是枚举式单例模式。将两者示例代码拎出来如下:

基于内部类的懒汉式单例模式:

packagecom.example.singleinstance.lazy;
importlombok.Getter;
importlombok.Setter;
/*** 懒汉式内部类法单例模式*/@Getter@SetterpublicclassMaster {
/*** 名字*/privateStringname;
/*** 私有化构造器*/privateMaster() {
if (InnerMaster.INSTANCE!=null) {
thrownewRuntimeException("不允许创建多个实例!");
      }
   }
/*** 获取老板唯一单例,final使得该方法不允许被重写或者重载** @return 老板唯一单例*/publicstaticfinalMastergetInstance() {
// 返回结果之前,会先加载内部类returnInnerMaster.INSTANCE;
   }
privateObjectreadResolve() {
returnInnerMaster.INSTANCE;
   }
/*** 老板类的内部类,没有用到它就不会加载*/privatestaticclassInnerMaster {
privatestaticfinalMasterINSTANCE=newMaster();
   }
}

枚举式单例模式:

packagecom.example.singleinstance.enumerate;
importlombok.Getter;
importlombok.Setter;
@GetterpublicenumMaster {
/*** 老板类唯一单例*/INSTANCE;
/*** 名字*/@SetterprivateStringname;
/*** 获取老板类唯一实例** @return 老板类唯一实例*/publicstaticMastergetInstance() {
returnINSTANCE;
   }
}

示例仓库地址

相关文章
|
3月前
|
Java 编译器
单例模式---JAVA
“饿汉”模式 “懒汉”模式
99 1
|
17天前
|
SQL 设计模式 安全
Java单例模式几种写法以及代码案例拿来直接使用
Java单例模式几种写法以及代码案例拿来直接使用
31 0
|
5天前
|
设计模式 存储 Java
Java设计模式:解释一下单例模式(Singleton Pattern)。
`Singleton Pattern`是Java中的创建型设计模式,确保类只有一个实例并提供全局访问点。它通过私有化构造函数,用静态方法返回唯一的实例。类内静态变量存储此实例,对外仅通过静态方法访问。
12 1
|
1月前
|
设计模式 安全 Java
Java设计模式之单例模式
在软件工程中,单例模式是一种常用的设计模式,其核心目标是确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。Java作为一门广泛使用的编程语言,实现单例模式是面试和实际开发中的常见需求。
66 9
Java设计模式之单例模式
|
2月前
|
设计模式 Java 安全
[Java]单例模式
本篇文章主要阐述单例模式的基础实现,重心在于如何解决单例模式中的“线程安全”问题。不涉及理论。 如果文中阐述不全或不对的,多多交流。
80 0
[Java]单例模式
|
2月前
|
Java
JAVA基础--内部类和静态内部类、单例模式---静态内部类剖析
JAVA--内部类和静态内部类、单例模式---静态内部类剖析
38 9
|
2月前
|
存储 缓存 Java
Java volatile关键字-单例模式的双重锁为什么要加volatile
Java volatile关键字--单例模式的双重锁为什么要加volatile
51 10
|
3月前
|
设计模式 安全 Java
Java设计模式—单例模式的实现方式和使用场景
那么为什么要有单例模式呢?这是因为有的对象的创建和销毁开销比较大,比如数据库的连接对象。所以我们就可以使用单例模式来对这些对象进行复用,从而避免频繁创建对象而造成大量的资源开销。
54 1
|
3月前
|
设计模式 安全 Java
Java线程面试题:如何在 Java 中实现线程安全的单例模式?
Java线程面试题:如何在 Java 中实现线程安全的单例模式?
26 0
|
4月前
|
Java
Java两种单例模式
Java两种单例模式