多线程环境下的单例:高性能单例实现、CAS实现单例、ThreadLocal单例
单例模式,说白了就是保证一个类只有一个实例。但在多线程环境下,这事就没那么简单了。我早年做支付系统时,就遇到过因为单例没处理好,导致线上服务OOM的惨案。嗯,今天咱们就来聊聊多线程下的单例到底该怎么玩。
一、经典单例的线程安全问题
先看一个最基础的懒汉式单例:
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这段代码在单线程下没问题。但多线程并发调用时,两个线程可能同时进入if (instance == null),然后各自创建实例。这就违背了单例的初衷。
二、高性能单例实现:双重检查锁定(DCL)
解决线程安全问题,最直接的想法是加锁。但加锁有性能开销,我们能不能只在第一次创建时加锁?
public class Singleton {
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对象不是原子操作,分为分配内存、初始化、赋值三步。没有volatile,可能拿到半初始化的对象。
- 双重检查:第一次检查避免不必要的加锁,第二次检查保证线程安全。
三、CAS实现单例:无锁化方案
DCL虽然好,但synchronized毕竟是重量级锁。有没有更轻量的方式?CAS(Compare And Swap)可以做到无锁并发。
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE =
new AtomicReference<>();
private Singleton() {}
public static Singleton getInstance() {
for (;;) {
Singleton instance = INSTANCE.get();
if (instance != null) {
return instance;
}
instance = new Singleton();
if (INSTANCE.compareAndSet(null, instance)) {
return instance;
}
// CAS失败,说明其他线程已经创建了实例,重试
}
}
}
CAS的原理很简单:比较当前值是否为null,如果是则更新为新实例。这个操作是CPU级别的原子指令,比锁轻量得多。
| 方案 | 首次创建 | 后续获取 | 适用场景 |
|---|---|---|---|
| synchronized方法 | 慢(锁竞争) | 慢(每次加锁) | 不推荐 |
| DCL | 中等 | 极快(无锁) | 通用场景 |
| CAS | 快(无锁) | 极快(无锁) | 高并发创建 |
| 静态内部类 | 快(类加载机制) | 极快 | 简单场景 |
你想想看,CAS方案在并发创建时,多个线程同时尝试CAS,只有一个成功,其他线程自旋重试。这个自旋开销其实很小,因为CAS失败说明实例已经创建好了,下次循环直接返回。
四、ThreadLocal单例:线程级别的单例
有时候我们需要的不是全局单例,而是每个线程一个实例。比如数据库连接、SimpleDateFormat(线程不安全)。这时候ThreadLocal就派上用场了。
public class ThreadLocalSingleton {
private static final ThreadLocal<ThreadLocalSingleton> INSTANCE =
ThreadLocal.withInitial(ThreadLocalSingleton::new);
private ThreadLocalSingleton() {
// 初始化逻辑
}
public static ThreadLocalSingleton getInstance() {
return INSTANCE.get();
}
}
ThreadLocal的原理:每个线程内部维护一个ThreadLocalMap,key是ThreadLocal对象,value是实例。所以每个线程拿到的都是自己的实例。
- 全局单例:整个JVM只有一个实例
- ThreadLocal单例:每个线程一个实例,线程内唯一
我记得有一次做交易系统,需要给每个请求分配一个唯一ID。如果用全局单例加锁生成,性能很差。后来改用ThreadLocal,每个线程维护自己的ID生成器,完全无锁,性能直接翻倍。
五、四种方案对比总结
说了这么多,到底该用哪种?我个人的选择逻辑是这样的:
- 简单场景:用静态内部类,代码最简洁
- 高并发获取:用DCL,兼顾性能和线程安全
- 高并发创建:用CAS,无锁化更轻量
- 线程隔离需求:用ThreadLocal,每个线程独立实例
嗯,这里要注意:没有银弹。每种方案都有适用边界。我见过有人把ThreadLocal当全局单例用,结果内存泄漏了——因为线程池里的线程不会销毁,ThreadLocal里的对象就一直活着。