多线程环境下的单例:高性能单例实现、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,可能拿到半初始化的对象。
  • 双重检查:第一次检查避免不必要的加锁,第二次检查保证线程安全。
个人经验: 我习惯在需要频繁获取实例、但创建开销大的场景用DCL。比如数据库连接池、配置管理器。性能比直接加synchronized方法高一个数量级。

三、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失败说明实例已经创建好了,下次循环直接返回。

避坑指南: 我曾经在某个高并发网关里用CAS实现单例,结果发现CPU飙升。排查后发现是自旋次数太多,而且实例创建逻辑里有耗时操作。记住:CAS适合创建逻辑非常轻量的场景,如果构造函数里有数据库查询、网络请求,还是老老实实用DCL吧。

四、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里的对象就一直活着。

最后一个小建议: 用ThreadLocal时,记得在finally块里调用remove()方法清理。这是血的教训换来的经验。
多线程单例实现方案对比 单例模式 双重检查锁定 CAS无锁方案 静态内部类 ThreadLocal单例 volatile + 双重检查 首次加锁,后续无锁 适合通用场景 注意指令重排序 AtomicReference 完全无锁 适合高并发创建 创建逻辑要轻量 类加载机制保证 延迟加载 代码最简洁 推荐首选 线程级别单例 每个线程独立实例 适合线程不安全对象 注意内存泄漏 选择建议 简单场景用静态内部类 → 高并发用DCL → 无锁追求用CAS → 线程隔离用ThreadLocal