ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

初识单例模式

初识单例模式

文章目录

    • 场景通点
    • 定义
      • 实现思路
    • 六种 Java 实现
      • 饿汉式
      • 懒汉式
      • synchronized 方法
      • 双重检查锁 Double Check Lock + Volatile
      • 静态内部类 Singleton Holder
      • 枚举单例
    • 单例运用场景
    • 破解单例模式
    • 参考

场景通点

  • 资源昂贵:数据库连接池、线程池、日志组件,只需要一份全局对象,频繁 new 会导致资源浪费。
  • 全局一致性:配置中心、缓存目录、全局计数器等需要强唯一,避免状态错乱。
  • 集中管理:方便做“生命周期”管控(初始化、销毁)。
  • 日志、配置中心、线程池、连接池、注册表、全局序列号生成器等 无状态或少状态、需要唯一性的组件
  • 资源较重而复用频繁

定义

保证一个类只有一个实例,并提供一个全局访问点

  • “只有一个”➜ 私有构造器 + 类级别的持有者(静态字段)
  • “全局访问”➜ 公共静态方法 (getInstance())

实现思路

  • 静态化实例对象, 让实例对象与 Class 对象互相绑定, 通过 Class 类对象就可以直接访问
  • 私有化构造方法, 禁止通过构造方法创建多个实例 —— 最重要的一步
  • 提供一个公共的静态方法, 用来返回这个类的唯一实例

六种 Java 实现

#代码量懒加载线程安全可靠程度说明
1饿汉式(Eager)✖✔★★★类加载即实例化,简单直观,无法延迟加载。
2懒汉式(Lazy-非线程安全)✔✖★只示范学习,生产别用。
3synchronized 方法✔✔★★简单但性能差,锁整个方法。
4DCL + volatile✔✔★★★双重检查锁 (Double-Checked Locking);JDK 5+ 才完全安全。
5静态内部类✔✔★★★★JVM 类加载天生线程安全,推荐。
6枚举单例✖✔★★★★★反序列化 / 反射天然防护,极简,强烈推荐。

饿汉式

public final class EagerSingleton {private static final EagerSingleton INSTANCE = new EagerSingleton();private EagerSingleton() {}public static EagerSingleton getInstance() { return INSTANCE; }
}
  • 优点:实现最简单、天然线程安全

Java 的语义包证了在引用这个字段之前并不会初始化它, 并且访问这个字段的任何线程都将看到初始化这个字段所产生的所有写入操作.

  • 缺点:类加载就占用内存;若实例创建开销大或很少用到,会浪费。

为什么饿汉式中类加载占内存?

步骤发生位置说明
① 类加载(Loading)ClassLoader把 .class 字节流读进内存,创建 Class 对象,本身几乎不耗多少内存。
② 链接 → 初始化JVM 执行 链接(验证、准备、解析)后,进入 初始化 阶段所有 static 字段 在 <clinit> 方法里按源代码顺序赋值。
饿汉式把单例对象定义成 private static final XXX INSTANCE = new XXX();
—— 这一行在 初始化阶段立即 new 对象
③ 对象驻留Java 堆无论业务代码是否真的使用过 getInstance(),对象已被创建并常驻堆中,直到类被卸载或进程结束。
  • “占内存”指的是 单例实例 已经分配在堆里,而不是 Class 元数据。
  • 若实例本身很大(例如预加载 MB 级的配置或字典),但应用启动后很久才用到,就属于“浪费”。
  • 饿汉式是典型的以空间换时间思想的实现: 不用判断就直接创建, 但创建之后如果不使用这个实例, 就造成了空间的浪费. 虽然只是一个类实例, 但如果是体积比较大的类, 这样的消耗也不容忽视.

懒汉式

线程不安全

public final class LazySingletonUnsafe {private static LazySingletonUnsafe instance;private LazySingletonUnsafe() {}public static LazySingletonUnsafe getInstance() {if (instance == null) {                      // ①instance = new LazySingletonUnsafe();    // ②}return instance;}
}
  • 优点:节省空间, 用到的时候再创建实例对象

为什么多线程下会出问题?

竞态点:假设 T1 与 T2 同时进入方法,instance 仍为 null

  • T1 通过检查(①)进入 ②,开始执行 new,尚未完成。
  • T2 同样看到 instance == null,也执行 new。
  • 结果:生成两个实例,违背单例约束

问题原因:没有同步手段(锁、volatile + CAS 等)来保证检查与创建的 原子性

测试案例:

final class LazySingleton {private static LazySingleton instance = null;private LazySingleton() {}public static LazySingleton getInstance() {if (instance == null) {instance = new LazySingleton();}return instance;}
}public class demo {public static void main(String[] args) {Set<String> instanceSet = Collections.synchronizedSet(new HashSet<>());for (int i = 0; i < 1000; i++) {new Thread(() -> {instanceSet.add(LazySingleton.getInstance().toString());}).start();}for (String instance : instanceSet) {System.out.println(instance);}}
}

输出结果:

如果输出的结果中有 2 个或 2 个以上的对象, 就足以说明在并发访问的过程中出现了线程安全问题

LazySingleton@668916a0
LazySingleton@c23df88

synchronized 方法

这样的做法对所有线程的访问都会进行同步操作, 有很严重的性能问题

public final class SynchronizedSingleton {private static SynchronizedSingleton instance;private SynchronizedSingleton() {}public static synchronized SynchronizedSingleton getInstance() {if (instance == null) {instance = new SynchronizedSingleton();}return instance;}
}

双重检查锁 Double Check Lock + Volatile

public final class DCLSingleton {private static volatile DCLSingleton instance;private DCLSingleton() {}public static DCLSingleton getInstance() {// 先判断实例是否存在if (instance == null) {// 加锁创建实例synchronized (DCLSingleton.class) {// 再次判断, 因为可能出现某个线程拿了锁之后, 还没来得及执行初始化就释放了锁,// 而此时其他的线程拿到了锁又执行到此处 ==> 这些线程都会创建一个实例, 从而创建多个实例对象if (instance == null) {instance = new DCLSingleton();}}}return instance;}
}
  • 为什么要双重检查
  • volatile 关键词有什么作用

使用双重检查的原因:

  • 第一次检查:绝大部分时间单例已存在,快速返回,避免进入 synchronized,性能开销 ≈0
  • 第二次检查:只有在第一次判断为 null、并且当前线程拿到锁时才进入;此时仍需再判一次,防止 “T1 创建 →T2 等锁 →T2 再创建” 的并发漏洞。(就是刚才懒汉式导致的问题)

volatile 关键词:

  • 可见性,保证线程中对这个变量所做的任何写入操作对其他线程都是即时可见的,写入 instance 对所有线程立刻可见
  • 禁止 JVM 指令重排:对象创建过程实际上分三步(并不是原子性操作)
a. 分配内存,在堆内存中, 为新的实例开辟空间
b. 调用构造器初始化
c. 将引用赋给变量 (instance = address)

CPU 和编译器可能把 b、c 重排成 c→b。可能发生以下情况:

  • T1 执行到 c,引用已非 null,但对象尚未初始化;
  • T2 读取到“非 null”便返回,使用到的是 半成品对象。

volatile 在 Java 内存模型中加了写后读屏障,保证初始化完成先于赋值,彻底避免重排风险。

静态内部类 Singleton Holder

只有在第一次调用 getInstance() 时才被加载,JVM 类加载保证线程安全 ➜ 懒加载 + 免锁

public final class HolderSingleton {private HolderSingleton() {}private static class Holder {private static final HolderSingleton INSTANCE = new HolderSingleton();}public static HolderSingleton getInstance() {return Holder.INSTANCE;}
}

JVM 记载类的时候有以下步骤:① 加载 -> ② 验证 -> ③ 准备 -> ④ 解析 -> ⑤ 初始化

JVM 在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类(SingletonHolder)的属性/方法被调用时才会被加载, 并初始化其静态属性(instance)

为什么会将创建实例放在静态内部?

核心机制:Initialization-on-demand holder idiom

  • 懒加载
    • 外部类 HolderSingleton 被加载时,并不会立即加载 Holder
    • 只有首次调用 getInstance(),JVM 才会解析对 Holder.INSTANCE 的主动使用,从而 加载并初始化 Holder,此时才 new 实例
  • 线程安全(不需要锁)
    • JVM 对 类初始化 有“互斥保证”:同一个类的 <clinit> 在多线程环境中只会执行一次,且执行期间其他线程会被阻塞。
    • 实例创建天然是原子且线程安全的
  • 零额外开销
    • 不需要 synchronized、不需要 volatile;除第一次触发外,没有任何同步损耗

具体原因:静态内部类利用了 类的按需加载 + 初始化互斥,同时满足“懒加载 + 线程安全 + 高性能”

枚举单例

public enum EnumSingleton {INSTANCE;// 可添加字段/方法private final Map<String, String> cache = new ConcurrentHashMap<>();public void put(String k, String v) { cache.put(k, v); }public String get(String k) { return cache.get(k); }
}
  • JVM 保证 序列化安全:枚举反序列化时不会新建实例。
  • 防反射:任何试图通过 Constructor.newInstance 创建都会抛 IllegalArgumentException
  • 代码最少,可自然支持 switch

单例运用场景

框架/库场景实现方式
JDKjava.lang.Runtime饿汉式
Log4j / LogbackLoggerContext懒加载 + 双检锁
Spring默认 Bean Scope = singleton容器级单例,非 GoF 模式
MyBatisSqlSessionFactoryBuilder ➜ SqlSessionFactory通常一个全局实例
HikariCPHikariPool 内部维护线程安全单例连接池

破解单例模式

  • 除枚举方式外, 其他方法都会通过反射的方式破坏单例,因此可以在构造方法中进行判断 —— 若已有实例, 则阻止生成新的实例
private Singleton() throws Exception {if (instance != null) {throw new Exception("Singleton already initialized, 此类是单例类, 不允许生成新对象, 请通过getInstance()获取本类对象");}
}
  • 如果单例类实现了序列化接口 Serializable, 就可以通过反序列化破坏单例,因此可以不实现序列化接口, 或者重写反序列化方法 readResolve()
// 反序列化时直接返回当前实例
public Object readResolve() {return instance;
}
  • Object#clone() 方法也会破坏单例, 即使你没有实现 Cloneable 接口 —— 因为 clone()方法是 Object 类中的,需要重写方法并抛出异常

参考

  • 设计模式 - Java 中单例模式的 6 种写法及优缺点对比 - 瘦风 - 博客园
返回列表