单例模式的8种写法:从懒汉到枚举,DCL双重检查锁的volatile为什么必不可少
单例模式的8种写法:从懒汉到枚举,DCL双重检查锁的volatile为什么必不可少
适读人群:中高级Java开发者 | 阅读时长:约20分钟 | 模式类型:创建型
开篇故事
那是2019年,我在一家做金融风控系统的公司,线上突然出现了一个诡异的NPE。日志里的堆栈指向一个配置管理类——RiskConfigManager,而这个类是我们自己实现的"单例"。我加了引号,因为它并不是一个真正意义上的单例。
排查了半天,发现问题出在这里:两个线程几乎同时调用了 getInstance() 方法,各自判断实例为 null,然后各自 new 了一个对象。其中一个线程创建的实例在初始化完成之前,另一个线程就开始使用它了,因为JVM的指令重排序,导致对象的引用已经赋值,但内部字段还未完成初始化,NPE就这么来了。
当时我们用的是最朴素的懒汉写法,没有任何同步措施。这个事故让我痛定思痛,把单例模式从头研究了一遍,把所有的写法、坑点、适用场景全部梳理清楚。后来在团队内部做了一次分享,反响很好。今天把这些内容整理成文章,希望能帮到同样踩过这个坑的朋友。
一、模式动机:为什么需要单例模式
单例模式(Singleton Pattern)的核心诉求只有一个:保证一个类在整个JVM进程生命周期内只有一个实例,并提供一个全局访问点。
听起来简单,但要在多线程环境下保证这一点,并且同时兼顾性能,远没有想象中容易。
在实际工程中,以下这些场景天然适合单例:
- 配置中心客户端:与配置中心的长连接,全局只需要一个。比如我们项目里的 Nacos
ConfigService,每次new一个会新建 TCP 连接,资源浪费极大。 - 数据库连接池:
HikariDataSource、DruidDataSource,如果随意new,每次都会创建一批底层连接,系统崩溃只是时间问题。 - 线程池:
ExecutorService用于处理异步任务,如果每个业务方法里都Executors.newFixedThreadPool(),不用多久线程数就会撑爆。 - 全局序列号生成器:用于订单号、流水号等,必须是单实例,否则会出现重号。
- Spring容器本身:
ApplicationContext就是一个单例,整个应用共用一个容器。
单例的本质是一种全局状态管理,核心挑战在于:在懒加载(延迟初始化)与线程安全之间找到最优解。
二、模式结构
结构非常简单,难点全在实现细节上。
三、Spring/框架源码中的实现分析
Spring 对单例的实现是教科书级别的案例,值得深入研究。
3.1 DefaultSingletonBeanRegistry 的三级缓存
Spring 的单例 Bean 管理在 DefaultSingletonBeanRegistry 中,它维护了三个 Map:
// 一级缓存:存放完全初始化好的单例Bean
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
// 二级缓存:存放早期暴露的单例Bean(已实例化但未完成属性注入)
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
// 三级缓存:存放单例Bean的ObjectFactory,用于解决循环依赖
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);关键方法 getSingleton 的核心逻辑:
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
// 先从一级缓存取
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
// 再从二级缓存取
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
synchronized (this.singletonObjects) {
// 双重检查
singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null) {
// 从三级缓存取ObjectFactory并创建
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
this.earlySingletonObjects.put(beanName, singletonObject);
this.singletonFactories.remove(beanName);
}
}
}
}
}
}
return singletonObject;
}这里有几个值得注意的设计:
singletonObjects使用的是ConcurrentHashMap,而不是普通HashMap + synchronized,读操作无锁。- 加锁的粒度是
singletonObjects对象本身,而不是方法级别的synchronized,锁粒度更细。 - 加锁后再次双重检查,确保在获取锁的等待过程中其他线程没有完成初始化。
3.2 @Scope("singleton") 的默认行为
Spring Bean 默认就是单例,背后的保证就是上面的 DefaultSingletonBeanRegistry。这让开发者大多数时候不需要手动实现单例,但这也埋下了一个常见陷阱:在单例 Bean 中注入了 prototype 作用域的 Bean,导致 prototype Bean 实际上只被创建一次,失去了 prototype 的意义。
四、生产级代码实现:8种写法逐一分析
写法一:饿汉式(最简单,推荐生产使用)
public class ConfigCenterClient {
// JVM类加载时就创建,天然线程安全
private static final ConfigCenterClient INSTANCE = new ConfigCenterClient();
private final String serverAddr;
private final NacosConfigService configService;
private ConfigCenterClient() {
this.serverAddr = System.getProperty("nacos.server.addr", "localhost:8848");
try {
Properties properties = new Properties();
properties.setProperty("serverAddr", this.serverAddr);
this.configService = (NacosConfigService) NacosFactory.createConfigService(properties);
} catch (NacosException e) {
throw new RuntimeException("ConfigCenter init failed", e);
}
}
public static ConfigCenterClient getInstance() {
return INSTANCE;
}
public String getConfig(String dataId, String group) {
try {
return configService.getConfig(dataId, group, 5000);
} catch (NacosException e) {
throw new RuntimeException("Get config failed: " + dataId, e);
}
}
}优点:实现简单,利用类加载机制保证线程安全,没有任何性能开销。
缺点:应用启动时就初始化,如果初始化耗时(比如建立网络连接),会拖慢启动速度。如果配置参数依赖其他组件,可能会遇到初始化顺序问题。
写法二:懒汉式(线程不安全,禁止在生产使用)
// 反例!禁止在多线程环境使用
public class LazyUnsafeSingleton {
private static LazyUnsafeSingleton instance;
private LazyUnsafeSingleton() {}
public static LazyUnsafeSingleton getInstance() {
if (instance == null) { // 线程A和线程B可能同时通过这个判断
instance = new LazyUnsafeSingleton(); // 各自创建了一个实例
}
return instance;
}
}这就是开篇故事里的写法,在单线程环境下完全没问题,一旦有并发就会出现多实例。绝对不能用在生产环境。
写法三:同步方法懒汉式(线程安全但性能差)
public class SynchronizedLazySingleton {
private static SynchronizedLazySingleton instance;
private SynchronizedLazySingleton() {}
// 整个方法加锁,并发性能极差
public static synchronized SynchronizedLazySingleton getInstance() {
if (instance == null) {
instance = new SynchronizedLazySingleton();
}
return instance;
}
}问题:getInstance() 方法被 synchronized 修饰,每次调用都需要竞争锁,即使实例已经创建完毕。在高并发场景下,这个方法会成为严重的性能瓶颈。测试数据表明,这种写法在 100 线程并发下,吞吐量会下降 70% 以上。
写法四:DCL 双重检查锁(推荐用于需要懒加载的场景)
这是面试最常考的写法,也是我见过最多人写错的写法。
public class DatabaseConnectionPool {
// volatile 是关键!不能省略!
private static volatile DatabaseConnectionPool instance;
private final HikariDataSource dataSource;
private DatabaseConnectionPool() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getProperty("db.url"));
config.setUsername(System.getProperty("db.username"));
config.setPassword(System.getProperty("db.password"));
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
this.dataSource = new HikariDataSource(config);
}
public static DatabaseConnectionPool getInstance() {
if (instance == null) { // 第一次检查:无锁快速判断
synchronized (DatabaseConnectionPool.class) {
if (instance == null) { // 第二次检查:获取锁后再判断
instance = new DatabaseConnectionPool(); // 此处有指令重排序风险
}
}
}
return instance;
}
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}为什么 volatile 必不可少?
instance = new DatabaseConnectionPool() 这行代码并非原子操作,JVM会将其分解为三步:
memory = allocate():分配内存空间init(memory):调用构造函数,初始化对象instance = memory:将引用赋值给instance变量
问题在于,JVM和CPU为了提升性能,允许对步骤2和步骤3进行指令重排序,实际执行顺序可能是 1 → 3 → 2。
如果重排序发生:
- 线程A执行到步骤3(
instance已指向内存地址,但对象还未初始化完成) - 线程B执行第一次检查,发现
instance != null,直接返回这个半初始化的对象 - 线程B使用这个对象时,内部字段还是默认值(null、0等),NPE或逻辑错误就来了
volatile 关键字的作用是禁止指令重排序(通过内存屏障实现),确保步骤2一定在步骤3之前完成。此外,volatile 还保证了可见性,确保一个线程对 instance 的修改对其他线程立即可见。
写法五:静态内部类(Holder模式,优雅的懒加载)
public class GlobalSequenceGenerator {
private final AtomicLong sequence = new AtomicLong(System.currentTimeMillis() * 1000);
private GlobalSequenceGenerator() {
// 假设这里需要与数据库通信获取初始序号
initSequenceFromDatabase();
}
private void initSequenceFromDatabase() {
// 与DB交互,获取当前最大序号,避免重启后重号
// ... 实际代码略
}
// 静态内部类:只有在首次被引用时才会加载,利用类加载机制保证线程安全
private static class Holder {
private static final GlobalSequenceGenerator INSTANCE = new GlobalSequenceGenerator();
}
public static GlobalSequenceGenerator getInstance() {
return Holder.INSTANCE;
}
public long nextId() {
return sequence.incrementAndGet();
}
public String nextOrderNo() {
return "ORD" + System.currentTimeMillis() + nextId() % 10000;
}
}这是我个人最推荐的懒加载单例写法,原因如下:
- 利用JVM类加载机制(ClassLoader的
<clinit>方法被JVM保证线程安全)实现懒加载,完全不需要手动同步。 Holder内部类只有在getInstance()被调用时才会被加载,实现了真正的延迟初始化。- 没有性能开销,没有
volatile的内存屏障,读操作零开销。 - 代码简洁,不容易写错。
写法六:枚举单例(Effective Java 推荐,防序列化攻击)
public enum MetricsCollector {
INSTANCE;
private final MeterRegistry registry;
private final Map<String, Counter> counterCache = new ConcurrentHashMap<>();
MetricsCollector() {
// 枚举的构造函数在类加载时被调用,且只调用一次
this.registry = new SimpleMeterRegistry();
// 实际项目中会接入Prometheus
// this.registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
}
public void increment(String metricName, String... tags) {
counterCache.computeIfAbsent(metricName, name ->
Counter.builder(name)
.tags(tags)
.register(registry)
).increment();
}
public double getCount(String metricName) {
Counter counter = counterCache.get(metricName);
return counter == null ? 0.0 : counter.count();
}
}
// 使用方式
// MetricsCollector.INSTANCE.increment("order.create.count", "region", "beijing");枚举单例的最大优势是天然防止反射攻击和序列化攻击:
- 反射攻击:对枚举类型调用
Constructor.newInstance()会直接抛出IllegalArgumentException: Cannot reflectively create enum objects。 - 序列化攻击:枚举的序列化机制由JVM保证,反序列化时会返回同一个枚举实例,不会创建新对象。普通单例类即使实现了
Serializable,也需要额外的readResolve()方法才能防止序列化攻击。
写法七:容器式单例(框架级实现)
/**
* 模拟Spring BeanFactory的单例容器实现
* 适用于需要管理多种类型单例的场景
*/
public class SingletonRegistry {
private final ConcurrentHashMap<String, Object> registry = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Object> creationLocks = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
public <T> T getSingleton(String name, Supplier<T> factory) {
// 先尝试从缓存获取(无锁路径,高性能)
Object instance = registry.get(name);
if (instance != null) {
return (T) instance;
}
// 使用细粒度锁,每个beanName一把锁,避免全局锁竞争
Object lock = creationLocks.computeIfAbsent(name, k -> new Object());
synchronized (lock) {
// 双重检查
instance = registry.get(name);
if (instance == null) {
instance = factory.get();
registry.put(name, instance);
}
}
return (T) instance;
}
public boolean containsSingleton(String name) {
return registry.containsKey(name);
}
public void removeSingleton(String name) {
registry.remove(name);
creationLocks.remove(name);
}
}这种写法在框架开发中非常常见,比 Spring 的实现还要简化了很多,但核心思路是一致的。
写法八:基于 CAS 的无锁单例
public class CasBasedSingleton {
private static final AtomicReference<CasBasedSingleton> INSTANCE_REF = new AtomicReference<>();
private CasBasedSingleton() {
// 初始化工作
}
public static CasBasedSingleton getInstance() {
CasBasedSingleton instance = INSTANCE_REF.get();
if (instance != null) {
return instance;
}
// CAS竞争创建实例,可能创建多个但只有一个会被设置成功
CasBasedSingleton newInstance = new CasBasedSingleton();
if (INSTANCE_REF.compareAndSet(null, newInstance)) {
return newInstance;
}
// CAS失败说明其他线程已经设置了实例,返回已有的
return INSTANCE_REF.get();
}
}注意:这种写法有一个问题——构造函数可能会被多个线程各自调用一次,只有一个线程的结果会被保留,其他线程创建的对象会被丢弃。如果构造函数有副作用(比如建立数据库连接),这种写法会造成资源泄漏。所以这种写法只适用于构造无副作用的轻量级单例。
综合对比
| 写法 | 线程安全 | 懒加载 | 性能 | 防序列化攻击 | 推荐度 |
|---|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 极高 | 否 | ★★★★☆ |
| 懒汉(不安全) | 否 | 是 | 极高 | 否 | 禁止使用 |
| 同步方法 | 是 | 是 | 差 | 否 | ★☆☆☆☆ |
| DCL + volatile | 是 | 是 | 高 | 否 | ★★★★☆ |
| 静态内部类 | 是 | 是 | 极高 | 否 | ★★★★★ |
| 枚举 | 是 | 否 | 极高 | 是 | ★★★★★ |
| 容器式 | 是 | 是 | 高 | 否 | ★★★★☆ |
| CAS无锁 | 是* | 是 | 高 | 否 | ★★★☆☆ |
五、与相关模式的对比与选型
单例 vs 静态类
很多人会问:既然单例就是一个全局访问点,为什么不直接用静态方法和静态字段?
区别在于:
- 多态性:单例可以实现接口,可以被继承(虽然通常不会),而静态类无法实现真正的多态。
- 可测试性:单例可以通过接口 Mock,而静态方法很难被测试替换。
- 延迟初始化:静态类的静态字段在类加载时就初始化,而单例可以控制初始化时机。
- 序列化:单例对象可以序列化传输,静态类的状态无法被序列化。
在 Spring 生态下,大多数"全局服务"都用 Spring 管理的单例 Bean,而不是手写单例。手写单例通常出现在以下场景:必须在 Spring 容器之外使用,或者需要在 static 上下文中访问。
单例 vs 享元模式
享元模式也是"共享实例",但两者的侧重点不同:
- 单例:一个类只有一个实例,强调"唯一性"。
- 享元:对象池,强调"可复用的多个实例",比如数据库连接池有多个连接对象,但这些连接对象在不使用时被池管理并共享。
六、踩坑实录
坑一:反射破坏单例
有一次做代码审查,发现一个测试用例里用反射调用了私有构造函数,导致单例被破坏,测试结果完全不可信:
// 这段代码会破坏普通单例
Class<ConfigCenterClient> clazz = ConfigCenterClient.class;
Constructor<ConfigCenterClient> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
ConfigCenterClient instance2 = constructor.newInstance(); // 创建了第二个实例!解决方案:在构造函数中加防御逻辑:
private DatabaseConnectionPool() {
if (Holder.INSTANCE != null) {
throw new IllegalStateException("Singleton already created, use getInstance()");
}
// ... 正常初始化
}或者直接使用枚举,枚举天然防反射攻击。
坑二:类加载器导致的多实例
在 OSGI 框架或者自定义类加载器的环境下(比如 Tomcat 热部署),同一个 .class 文件可能被多个不同的类加载器加载,导致出现多个"单例"实例。这个坑我在做中台系统时踩过,一个插件模块有自己的类加载器,里面的单例和主应用里的单例是不同的对象,导致共享状态出现混乱。
解决方案:将需要全局唯一的类放到父类加载器加载的模块中,或者通过 JNDI/Spring 容器等统一注册机制来管理。
坑三:DCL 写法忘了 volatile
这个坑太经典了,我在代码评审中见过不止三次。没有 volatile 的 DCL 在 Java 1.4 及以下是有问题的(JSR 133 之前的内存模型不禁止这种重排序)。即使在现代 Java 版本中,理论上在某些极端情况下仍然可能出问题,最重要的是代码意图不清晰——一旦有人看到 DCL 写法却没有 volatile,要么是他不懂,要么是这段代码有 bug,两种可能都不好。
正确做法就是一定要加 volatile,这是 Java 内存模型的明确要求,不是可选项。
坑四:单例持有大量状态导致 OOM
有一个上报统计数据的单例服务,里面有一个 Map 缓存了每个接口的调用次数。本来设计是定期清理,但定时任务因为一个 bug 停掉了,缓存的 key 越来越多(接口 URL 作为 key,URL 中带有动态路径参数),最终导致 OOM。
教训:单例的生命周期和 JVM 一样长,它持有的任何对象都不会被 GC 回收(除非你主动清理)。设计单例时要格外注意内存管理,避免无界缓存,避免持有不必要的大对象引用。
坑五:Spring 环境下手写单例与 Spring Bean 并存
有一次接手旧代码,发现有一个类既实现了手写单例(有 getInstance() 方法),又被 @Component 注解标记。结果就是两套代码路径:Spring 注入的地方用的是 Spring 管理的 Bean,手动调用的地方用的是 getInstance() 返回的实例,两者是不同的对象,状态完全不同步,查了两天 bug 才找到根因。
解决原则:在 Spring 环境下,要么用 Spring 管理,要么手写单例,两者不能混用。如果确实需要在静态上下文中访问 Spring Bean,可以通过 ApplicationContext 工具类来获取,而不是在 Bean 上叠加手写单例。
七、总结
单例模式看似简单,实则暗藏玄机。15年下来,我给出以下选型建议:
在 Spring 环境下:优先让 Spring 管理,不要手写单例。Spring 的
@Scope("singleton")和DefaultSingletonBeanRegistry已经把一切都处理好了。必须手写单例时:
- 不需要懒加载 → 饿汉式或枚举
- 需要懒加载 → 静态内部类(Holder 模式)
- 需要防序列化/反射攻击 → 枚举
- 需要更灵活的控制 → DCL + volatile
DCL 的
volatile是必须的:不是加了synchronized就万事大吉,指令重排序问题是 JVM 层面的,synchronized解决的是可见性和原子性,volatile解决的是有序性。两者缺一不可。单例不是银弹:全局状态会增加代码耦合,降低可测试性。在能用依赖注入的地方,优先用依赖注入,不要为了方便访问就到处手写单例。
