本文目录导读:

这是一个很好的问题,简单直接的回答是:普通的懒汉式单例模式在多线程环境下是绝对不安全的。 但经过特定设计(如双重检查锁、静态内部类、枚举)的单例模式,则是线程安全的。
下面我们来拆解一下,为什么不安全,以及如何让它变得安全可靠。
为什么“普通”单例模式在多线程下不安全?
看一个最常见的“懒汉式”单例:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 线程A和B可能同时进入这里
instance = new Singleton(); // 非原子操作
}
return instance;
}
}
问题在于:
- 竞态条件:当线程A和线程B同时调用
getInstance()时,它们都可能看到instance == null,于是线程A和线程B都会执行new Singleton(),创建出两个不同的对象,单例被破坏。 - 指令重排序:更隐蔽的问题是,
instance = new Singleton()这一行代码在JVM中并不是一个原子操作,它大致分为三步:- 在堆内存中为
Singleton对象分配内存空间。 - 执行
Singleton的构造函数来初始化对象。 - 将
instance引用指向分配的内存地址。 由于JVM的指令重排序优化,第2步和第3步的执行顺序可能被颠倒,假设一个线程A执行了第1步和第3步(instance不为空,但对象尚未初始化),此时线程B调用getInstance(),发现instance != null,直接返回了一个尚未完成构造的、不完整的对象,这会导致程序崩溃或出现诡异错误。
- 在堆内存中为
如何实现“安全可靠”的多线程单例?
有几种经典的方法,它们的设计都为了解决上述的竞态和重排序问题。
饿汉式(Eager Initialization)
在类加载时就立即创建实例,由JVM的类加载机制保证线程安全。
public class Singleton {
// 类加载时,静态变量被初始化,JVM保证其线程安全
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
- 优点:实现简单,天生线程安全。
- 缺点:如果这个单例对象很大,且程序启动后可能很久都不会用到它,会造成资源浪费(启动慢)。
同步方法(Synchronized Method)
给 getInstance() 方法加锁,确保同一时间只有一个线程能进入。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
- 优点:简单易懂,线程安全。
- 缺点:性能极差,每次调用
getInstance()都会加锁,即使实例已经创建(后续所有调用都是只读操作),也必须等待锁释放,在高并发场景下,这会成为严重的性能瓶颈。
双重检查锁(Double-Checked Locking, DCL)—— 推荐
结合了“饿汉式”的性能和“同步方法”的延迟加载,核心是:只在第一次创建对象时加锁,后续只读访问无需加锁。
public class Singleton {
// 关键点:使用 volatile 禁止指令重排序
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;
}
}
-
为什么必须用
volatile? 前面提到new Singleton()存在指令重排序问题,加上volatile关键字后,保证了instance = new Singleton()操作的可见性和禁止指令重排序,即:一个线程对instance的写入(特别是完成对象的完整构造),对其他线程立即可见,线程B不会看到“半初始化”的对象。 -
优点:性能高,延迟加载,线程安全。
-
缺点:代码稍复杂,对
volatile的理解要求较高,JDK 1.5 之后才完全正确。
静态内部类(Static Inner Class)—— 最优雅,推荐
利用JVM的类加载机制保证线程安全,且实现了延迟加载。
public class Singleton {
private Singleton() {}
// 静态内部类,只有在被引用时才会被加载
private static class SingletonHolder {
// 类加载时创建实例,由JVM保证线程安全
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
- 原理:
Singleton类被加载时,SingletonHolder并不会被加载,只有当第一次调用getInstance()时,才会强制加载SingletonHolder类,并初始化INSTANCE,JVM在类初始化阶段会加锁,保证只有一个线程执行<clinit>方法,从而保证了线程安全。 - 优点:延迟加载、线程安全、性能高、代码简洁易懂。
- 缺点:无法避免反射或序列化破坏单例(可以通过实现
readResolve()方法解决)。
枚举(Enum)—— 最安全,但灵活性稍差
枚举是Java中实现单例的最佳实践,它天然防止了反射和序列化攻击。
public enum Singleton {
INSTANCE;
// 可以添加自己的方法
public void doSomething() {
System.out.println("Doing something...");
}
}
- 优点:
- 绝对线程安全:枚举的创建由JVM保证。
- 绝对防止反射攻击:枚举的构造函数在反射API中是被保护起来的。
- 绝对防止序列化/反序列化破坏:枚举的序列化机制有特殊处理,反序列化不会创建新对象。
- 简单:代码极其简洁。
- 缺点:
- 不适用于需要继承的场景(枚举不能继承其他类)。
- 无法实现懒加载(枚举实例在枚举类加载时就创建了,但通常枚举对象本身很小,这不是大问题)。
总结与建议
| 方法 | 线程安全 | 延迟加载 | 性能 | 推荐度 | 说明 |
|---|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 无锁,高 | ⭐⭐⭐ | 简单明了,但可能浪费资源。 |
| 同步方法 | 是 | 是 | 极差 | ⭐ | 不推荐用于生产环境。 |
| 双重检查锁 | 是 | 是 | 高 | ⭐⭐⭐⭐ | 技术含量高,需正确使用volatile。 |
| 静态内部类 | 是 | 是 | 高 | ⭐⭐⭐⭐⭐ | 最推荐,兼具性能与优雅。 |
| 枚举 | 是 | 否 | 无锁,高 | ⭐⭐⭐⭐⭐ | 最安全,防止反射和序列化攻击。 |
最终建议:
- 如果你需要延迟加载,且不想引入额外复杂性:首选 静态内部类 方式,它几乎可以满足99%的应用场景。
- 如果你的代码需要防止反射、序列化等攻击,追求绝对安全:首选 枚举 方式(如Spring、MyBatis等框架的许多单例就是这么实现的)。
- 如果对
volatile和JMM(Java内存模型)有深刻理解,且对性能有极致要求:可以使用双重检查锁,但务必加上volatile。 - 如果单例对象很小,且一定会被用到:直接用饿汉式,简单高效。
“安全可靠”的多线程单例在Java中是完全可实现的,关键在于选择正确的实现模式。 普通的懒汉式(不加锁、不加volatile)肯定不安全。