单例模式详解
1. 什么是单例模式
定义
单例模式是指在内存中有且只创建一次对象的设计模式,当在程序中可以被多次使用,且每次都是同一个对象其作用相同。
作用
防止频繁地创建对象使内存飙升
让所有需要调用的地方都共享这一单例对象
2. 单例模式的类型
- 懒汉式:在真正需要使用对象时才去创建该单例类对象
- 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用
懒汉式
在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象,否则先执行实例化操作。
代码
public class LazyMan{
private LazyMan(){
}
private static LazyMan lazyMan;
public static LazyMan getInstance(){
if(lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}
}
饿汉式
饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即在编码时就已经指明要创建的对象,不需要等到被调用时再去创建。
代码
public class HungryMan{
private HungryMan(){
}
private final static HungryMan hungryMan = new HungryMan();
public static HungryMan getInstance(){
return hungryMan;
}
}
注意上面的代码在第5行已经实例化好了一个HungryMan对象在内存中,不会有多个HungryMan对象实例存在
类在加载时会在堆内存中创建一个HungryMan对象,当类被卸载时,HungryMan对象也随之消亡了。
3. 讨论
懒汉式如何保证只创建一个对象?
如果两个线程同时判断singleton为空,那么它们都会去实例化一个Singleton对象,这就变成双例了。所以,我们要解决的是线程安全问题。
1. 线程安全
synchronized 或 Lock
代码
public static LazyMan getInstance(){
synchronized(LazyMan.class){
if(lazyMan == null){
lazyMan = new LazyMan();
}
}
return lazyMan;
}
// 或
public synchronized static LazyMan getInstance(){
if(lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}
问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。
优化性能,目标是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例
Double Check(双重校验) + Lock(加锁)
public static LazyMan getInstance(){
// 线程A和线程B同时看到lazyMan = null,如果不为null,则直接返回lazyMan
if(LazyMan == null){
// 线程A或线程B获得该锁进行初始化
synchronized(LazyMan.class){
// 其中一个线程进入该分支,另外一个线程则不会进入该分支
if(lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
解决了并发安全+性能低效问题
2. 指令重排
通过JVM的学习,我们知道
使用volatile防止指令重排
创建一个对象,在JVM中会经过三步:
(1)为singleton分配内存空间
(2)初始化singleton对象
(3)将singleton指向分配好的内存空间
指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能
使用volatile关键字可以防止指令重排序,其原理较为复杂,这篇博客不打算展开,可以这样理解:使用volatile关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了
public class LazyMan{
private volatile static LazyMan lazyMan;
private LazyMan(){
}
public static LazyMan getInstance(){
if(lazyMan == null){
synchronized(LazyMan.class){
if(lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
破坏懒汉式单例与饿汉式单例
无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。
反射
public class TestSingle {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
// 通过反射获取属性doif
Field doif = LazyMan.class.getDeclaredField("doif");
//可获取属性
doif.setAccessible(true);
// 获取类的显式构造器
Constructor<LazyMan> constructor = LazyMan.class.getDeclaredConstructor(null);
// 可访问私有构造器
constructor.setAccessible(true);
LazyMan lazyMan1 = constructor.newInstance();
// 设置属性 doif 为 false
doif.set(lazyMan1,false);
LazyMan lazyMan2 = constructor.newInstance();
System.out.println(lazyMan1);
System.out.println(lazyMan2);
}
}
// 懒汉式单例
public class LazyMan {
private static boolean doif = false;
private LazyMan(){
synchronized (LazyMan.class){
if (doif == false){
doif = true;
}else {
throw new RuntimeException("不要试图使用反射破坏异常");
}
}
}
private volatile static LazyMan lazyMan;
public static LazyMan getInstance(){
if(lazyMan == null){
synchronized (LazyMan.class){
if(lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
通过测试可知即使添加属性也可以通过反射破坏懒汉式单例
即利用反射,强制访问类的私有构造器,去创建另一个对象
序列化与反序列化
// 序列化与反序列化
public static void testIO() throws IOException, ClassNotFoundException{
// 创建输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("LazyMan.file"));
// 将单例对象写到文件中
oos.writeObject(LazyMan.getInstance());
// 从文件中读取单例对象
File file = new File("LazyMan.file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
LazyMan newInstance = (LazyMan) ois.readObject();
// 判断是否是同一个对象
System.out.println(newInstance == LazyMan.getInstance()); // false
}
两个对象地址不相等的原因是:readObject() 方法读入对象时,它必定会返回一个新的对象实例,必然指向新的内存地址。
解决
我们已经掌握了懒汉式与饿汉式的常见写法了,在《大话设计模式》中的单例模式章节也止步于此。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法,话不多说,学完下面,真的不虚面试官考你了。
在 JDK 1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举
public Enum Singleton{
INSTANCE;
public Singleton Singleton(){
return INSTANCE;
}
}
// 枚举类型单例模式
public static void testEnum() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Singleton t1 = Singleton.INSTANCE;
// java.lang.NoSuchMethodException
Constructor<Singleton> d = Singleton.class.getDeclaredConstructor(String.class,int.class);
d.setAccessible(true);
// Cannot reflectively create enum objects
Singleton t2 = d.newInstance();
System.out.println("t1和t2的地址是否相同:"+ t1);
System.out.println("t1和t2的地址是否相同:"+ t2);
}
面试小亮点
尝试利用反射破坏,发现出现java.lang.NoSuchMethodException
异常,没有空参构造方法
枚举类型的最终反编译源码:发现空参构造方法带String s,int i
详细代码
// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3)
// Source File Name: EnumSingle.java
package com.kuang.single;
public final class EnumSingle extends Enum
{
public static EnumSingle[] values()
{
return (EnumSingle[])$VALUES.clone();
}
public static EnumSingle valueOf(String name)
{
return (EnumSingle)Enum.valueOf(com/kuang/single/EnumSingle, name);
}
private EnumSingle(String s, int i)
{
super(s, i);
}
public EnumSingle getInstance()
{
return INSTANCE;
}
public static final EnumSingle INSTANCE;
private static final EnumSingle $VALUES[];
static
{
INSTANCE = new EnumSingle("INSTANCE", 0);
$VALUES = (new EnumSingle[] {
INSTANCE
});
}
需要思考:使用枚举实现单例模式的优势在哪里?
我们从最直观的地方入手,第一眼看到这几行代码,就会感觉到“少”,没错,就是少,虽然这优势有些牵强,但写的代码越少,越不容易出错。
优势1:代码对比饿汉式与懒汉式来说,更加地简洁
其次,既然是实现单例模式,那这种写法必定满足单例模式的要求,而且使用枚举实现时,没有做任何额外的处理。
优势2:它不需要做任何额外的操作去保证对象单一性与线程安全性
我写了一段测试代码放在下面,这一段代码可以证明程序启动时仅会创建一个 Singleton 对象,且是线程安全的。
我们可以简单地理解枚举实现单例的过程:在程序启动时,会调用Singleton的空参构造器,实例化好一个Singleton对象赋给INSTANCE,之后再也不会实例化
优势3:使用枚举可以防止调用者使用反射、序列化与反序列化机制强制生成多个单例对象,破坏单例模式。
防破坏的原理如下:
(1)防反射
枚举类默认继承了 Enum 类,在利用反射调用 newInstance() 时,会判断该类是否是一个枚举类,如果是,则抛出异常。
(2)防止反序列化创建多个枚举对象
在读入Singleton对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的类型和变量名输出到文件中,在读入文件反序列化成对象时,利用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。
所以,在序列化和反序列化的过程中,只是写出和读入了枚举类型和名字,没有任何关于对象的操作。
小总结:
(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象
(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象
(3)枚举类不需要关注线程安全、破坏单例和性能问题,因为其创建对象的时机与饿汉式单例有异曲同工之妙。
总结
单例模式常见的写法有两种:懒汉式、饿汉式
(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题
(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。
(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;
(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题
(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加volatile关键字防止指令重排序
(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Blazor Hybrid适配到HarmonyOS系统
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· 分享4款.NET开源、免费、实用的商城系统
· 解决跨域问题的这6种方案,真香!
· 一套基于 Material Design 规范实现的 Blazor 和 Razor 通用组件库