Java 单例模式
在 Java 中,单例模式(Singleton)用于保证某个类在整个应用中只有一个实例,并提供全局访问点
根据线程安全和实例化时机,可以分为懒汉式、饿汉式以及更高级的实现方式
1. 懒汉式单例(Lazy Initialization)
1.1 懒汉式,线程不安全
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
-
特点:延迟初始化,只在第一次调用
getInstance()
时创建实例 -
缺点:多线程环境下可能产生多个实例,需要加锁
1.2 懒汉式,线程安全(同步方法)
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
-
特点:
synchronized
保证同一时间只有一个线程执行getInstance()
-
缺点:同步整个方法效率低,99%情况下不需要同步
1.3 仅锁定实例化部分
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
问题:两个线程同时进入 if (instance == null)
,第一个线程创建实例后,第二个线程没有再次判断就可能重复实例化
1.4 双重检查锁定(Double-Check Locking)
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) { // 同步块
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
问题分析
- 可见性问题
- Java 内存模型(JMM)中,每个线程有自己的工作内存,修改共享变量可能不会立即被其他线程看到
synchronized
保证:- 进入同步块时,会从主内存刷新最新值到工作内存
- 退出同步块时,会将修改写回主内存
- 因此,线程二在进入同步块时可以看到线程一创建的 Singleton 实例,可见性问题得以解决
- 这里的“看到最新的值”指的是线程二在同步块内读取
instance
时,能够获取线程一已经创建好的对象,而不是旧的 null 或半初始化状态(假设使用 volatile 或 JDK 1.5+)
- 无序性问题(JVM 内存模型导致)
new Singleton()
并非原子操作,可分为:- 分配内存
- 初始化对象
- 赋值给变量
- 在 JVM 1.4 及以前,为了性能优化,步骤可能被重排为:
- 分配内存
- 将
instance
指向内存 - 初始化对象
- 风险:
- 线程 T1 执行到步骤 2(
instance
指向内存) - 线程 T2 进入第一次检查
if (instance == null)
,发现instance != null
- T2 直接使用
instance
,此时对象尚未初始化 → 程序可能崩溃或行为异常
- 线程 T1 执行到步骤 2(
总结:双重检查锁定失败的根本原因是 JVM 内存模型允许无序写入,而不是锁本身或线程切换问题
解决方案
-
使用
volatile
(JDK 1.5+)-
保证写入有序:先初始化对象,再赋值给变量
private volatile static Singleton instance = null;
-
-
使用静态内部类 (IODH)
- JVM 保证类加载初始化时线程安全
- 使用枚举
- JVM 保证枚举实例线程安全且唯一
1.5 使用 volatile
避免无序性
public class Singleton {
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
-
JDK 1.5+:
volatile
可以保证new
操作的有序性,避免双重检查锁定的问题 -
JDK 1.4 及以前:
volatile
只保证可见性,但无法保证有序性
1.6 双重检查锁定适用场景
- 对基本类型(long, double)适用
- 对引用类型,必须使用
volatile
或其他同步手段
private int count;
public int getCount() {
if (count == 0) {
synchronized(this) {
if (count == 0) {
count = computeCount(); // 耗时计算
}
}
}
return count;
}
2. 饿汉式单例(Eager Initialization)
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
-
特点:在类加载时即创建实例,不需要同步
-
优点:简单、线程安全
-
缺点:提前占用资源,延迟加载不可用
2.1 Initialization on Demand Holder (IODH)
public class Singleton {
private static class SingletonHolder {
static Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.instance;
}
}
- 原理:内部静态类只有在被引用时才加载
- 优点:延迟加载 + 天然线程安全
2.2 枚举方式(Enum Singleton)
private enum EnumSingleton {
INSTANCE;
private Singleton singleton;
private EnumSingleton() {
singleton = new Singleton();
}
public Singleton getInstance() {
return singleton;
}
}
- 优点:JVM 保证枚举实例绝对唯一且线程安全,避免反射破坏单例
3. JVM 内存模型改进
- JSR-133:Java 内存模型改进后,
volatile
可以保证双重检查锁定正常工作
private volatile Resource resource;
public Resource getResource() {
if (resource == null) {
synchronized(this) {
if (resource == null) {
resource = new Resource();
}
}
}
return resource;
}
- 总结:双重检查锁定失败的原因在于 JVM 内存模型允许无序写入,而非 JVM Bug
4. 总结与建议
- 懒汉式适用于单线程或延迟加载场景,但多线程下实现复杂
- 饿汉式最简单安全,适合 JVM 特性
- IODH 和枚举是现代 Java 推荐方式,兼顾延迟加载和线程安全
- 选择单例实现方式时:应根据应用场景、资源占用和线程要求进行选择,而非盲目追求懒加载或花哨的同步方式
JSR-133
1. JSR-133 背景
- JVM 1.4 及以前的 Java 内存模型(JMM)允许指令重排和无序写入,即便使用
synchronized
,也无法保证双重检查锁定中的new
操作有序 - 这导致了 DCL 失败问题:一个线程可能看到
instance
已经非 null,但对象尚未初始化
双重检查锁定失败的核心是“写操作无序”和“共享变量的可见性”问题
2. JSR-133 的改进
JSR-133 是 Java 内存模型的一个重要修改,于 JDK 1.5 引入,主要改进包括:
2.1 引入 happens-before
语义
- 定义了线程之间操作的顺序规则
- 核心原则:
- 解锁先行发生于后续的锁定:同步块释放锁的操作先行发生于其他线程获取相同锁
- 写操作先行发生于随后的 volatile 读:对
volatile
的写入操作,先行发生于后续读取同一个volatile
变量的操作
通过这些规则,JMM 明确了同步块和 volatile 变量的可见性和有序性保证
2.2 volatile
的增强
在 JDK 1.5 之前,volatile
只能保证可见性,不保证写入顺序
JSR-133 改进后:
- 保证有序性:
volatile
写操作不会与前面的普通写操作重排volatile
读操作不会与后面的普通读操作重排
- 这样,
new Singleton()
中:- 对象初始化完成后,才将引用写入
volatile instance
- 避免线程看到“半初始化对象”
- 对象初始化完成后,才将引用写入
2.3 synchronized 的保证
- synchronized 的锁机制仍然存在,增加了happens-before 保证:
- 锁释放 → 写回主存 → 其他线程获取锁 → 读取最新值
- 因此,即使多个线程同时访问同步块,也能看到最新值并避免半初始化
3. JDK 中双重检查锁定正确写法
结合 JSR-133 和 volatile,有序且线程安全的 DCL 实现:
public class Singleton {
private volatile static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) { // 锁住初始化
if (instance == null) { // 第二次检查
instance = new Singleton(); // 初始化 + 写入 volatile
}
}
}
return instance;
}
}
-
volatile
:保证初始化顺序不会被重排 -
synchronized:保证多线程同时访问的互斥和可见性
-
JDK 1.5+ JVM 内存模型(JSR-133)使得双重检查锁定在现代 Java 中有效
问题 | 1.4 及以前 | JDK 1.5+(JSR-133) |
---|---|---|
可见性 | synchronized 可见性保证有限 | synchronized + volatile 保证可见性 |
有序性 | new 操作可能重排 → 半初始化 | volatile + happens-before → 顺序保证 |
双重检查锁定 | 可能失败 | 正确可用 |
JSR-133 的 happens-before 语义在 JVM 层(HotSpot)实现
1. 背景:Java 内存模型与 C++ 层
- Java 内存模型(JMM)是规范,规定了 可见性、原子性、顺序性
- JVM 的 HotSpot 实现需要把这些语义映射到 底层硬件指令 和 操作系统提供的同步原语
- JVM HotSpot 是用 C++ 写的,所以所有同步行为最终由 C++ 层实现
2. happens-before 语义在 JVM 中的映射
JSR-133 中的 happens-before 规则包括:
- 锁释放 happens-before 同步块锁定
- volatile 写 happens-before 后续 volatile 读
- 程序顺序内的前操作 happens-before 后操作
在 C++ 层,HotSpot 通过 内存屏障(Memory Barrier / Fence) 和 锁实现 来保证这些语义
2.1 synchronized 的实现(锁)
- HotSpot 的 synchronized 关键字最终对应 对象头的 Monitor(Object Monitor):
monitorenter
→ 获取对象锁monitorexit
→ 释放对象锁
// 简化示意
void JVM_MonitorExit(Monitor* mon) {
// flush write buffer → 确保对共享变量的修改写回主存
memory_barrier();
// 实际释放锁
mon->unlock();
}
void JVM_MonitorEnter(Monitor* mon) {
// 获取锁之前,刷新缓存 → 读取最新值
memory_barrier();
mon->lock();
}
效果:
- 释放锁:保证锁内写操作对其他线程可见(写屏障)
- 获取锁:保证同步块内读取到的是主内存最新值(读屏障)
2.2 volatile 的实现
Java 层:
private volatile int x;
x = 42; // 写
int y = x; // 读
HotSpot C++ 层:
- 写 volatile → 使用 StoreStore + StoreLoad 内存屏障
- StoreStore barrier:保证前面的写在写入 volatile 之前完成
- StoreLoad barrier:保证后续读不会重排到 volatile 写之前
- 读 volatile → 使用 LoadLoad + LoadStore barrier
- LoadLoad barrier:保证前面的读完成
- LoadStore barrier:保证后续写不会重排到 volatile 读之前
实际 CPU 指令:
- x86:通常用
mfence
或lock
前缀 - ARM:用
dmb
/dsb
指令
2.3 总结 JVM 层映射
JMM 概念 | JVM HotSpot C++ 实现 | 原理 |
---|---|---|
synchronized 获取锁 | MonitorEnter + memory_barrier() | 读屏障,保证看到主内存最新值 |
synchronized 释放锁 | MonitorExit + memory_barrier() | 写屏障,保证修改写回主内存 |
volatile 写 | StoreStore + StoreLoad barrier | 保证写操作顺序,写入主内存可见 |
volatile 读 | LoadLoad + LoadStore barrier | 保证读操作顺序,读取主内存最新值 |
程序顺序内操作(单线程) | 编译器指令顺序 + CPU 顺序保证 | 保证单线程内顺序性 |
3. 双重检查锁定(DCL)在 JVM 层安全的实现
- 线程一执行:
instance = new Singleton()
- C++ 层会插入内存屏障,保证初始化完成后才写入 volatile 引用
- 线程二执行:
- 第一次检查
if (instance == null)
- 进入 synchronized 时,会从主内存刷新最新
instance
- 进入 synchronized 时,会从主内存刷新最新
- 第二次检查
if (instance == null)
- 读到的是完整初始化对象
- 第一次检查
- 这样 DCL 就在硬件和 JVM 层都保证了安全