深蓝黑背景封面:右侧大标题「五种单例,只有写对的才算数」,副标题「饿汉、懒汉、双重检查、静态内部类与枚举的安全谱系」;左侧原创示意图——五张卡片排成一列,前三张打着红叉或半勾,最后两张盖上绿色对勾印章;左上标注「面试经典 · Java 后端」,右上标注「Day 09 · 10-27」 图 1:本文封面,五种写法按安全性排队。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:手写一个线程安全的单例,说说几种写法的差别。

先把结论立住:**五种常见写法里,不加锁的懒汉式是错的,其余四种——饿汉、加锁懒汉、双重检查、静态内部类、枚举——都安全,差别在懒加载、性能与防破坏能力。**选型本质上是回答三个问题:要不要懒加载?能不能接受每次获取都抢锁?要不要防反射与序列化破坏?

**逐个过谱系。**饿汉式把实例放在静态字段里,类加载时就建好。Java 语言规范明确规定类的初始化过程由虚拟机负责同步,多线程抢初始化只会有一个线程执行,因此饿汉天然线程安全;代价是不用也先建,启动即占资源。不加锁的懒汉式是经典错误:两个线程可以同时通过空检查,各自 new 一个实例,单例直接破功。给它补上 synchronized 就安全了,但锁加在整个方法上,每次获取实例都抢锁,初始化完成后这些锁全是纯开销。双重检查把锁收进「第一次」里:先在锁外判一次空,进了锁再判一次,只有首次创建真正抢锁。静态内部类把实例挪进一个 holder 类,第一次调用 get 才触发 holder 的类初始化——又回到「类初始化由虚拟机同步」这条规范保证,于是免锁拿到懒加载。枚举最省事:语言层面兜底单例,反射和反序列化都建不出第二个实例。

双重检查为什么必须加 volatile?instance = new DoubleChecked() 不是一步,是「分配内存、初始化对象、引用指向内存」三步,编译器和处理器可能把后两步重排。不加 volatile 时,另一个线程可能在锁外读到「非空但尚未初始化完成」的半成品引用并直接使用。Java 内存模型的规则是:对 volatile 变量的写入与后续所有对它的读取之间建立同步关系,写之前的操作不得重排到写之后——volatile 同时解决了可见性与重排两个问题。去掉 volatile 的双重检查是面试里最经典的「看起来对」的错答案。

谱系对照加原理图:上方五列对照五种写法——饿汉式类加载时就建好实例,天然线程安全但启动即占资源;懒汉式用时才建,但并发下两个线程会同时通过空检查、建出两个实例;加锁懒汉每次获取都抢锁,安全但把并发度打到零;双重检查只在第一次抢锁,靠两次判空兼顾安全与性能;静态内部类利用类加载的线程安全特性实现懒加载;枚举由语言层面兜底,还能防反射与反序列化破坏。下方原理框解释双重检查为什么必须加 volatile:「分配内存、初始化对象、引用指向内存」三步可能被指令重排,别的线程可能看到一个「非空但尚未初始化完成」的半成品引用,volatile 禁止这种重排并保证可见性。底部提示:选型的本质是回答三件事——要不要懒加载、能不能接受加锁、要不要防反射与序列化破坏 图 2:五种写法谱系与双重检查的 volatile 原理。

「无锁懒汉真的会建出两个实例」不能光靠嘴说。下面这份 Java 程序让 32 个线程在同一声令下并发抢首次获取,统计各种写法实际产生了几个不同的实例,顺带用反射攻击枚举。用 JDK 17 编译运行(javac SingletonDemo.java && java SingletonDemo):

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;

public class SingletonDemo {

  // 写法一:饿汉式,类加载时建实例,天然线程安全
  static class Eager {
    private static final Eager INSTANCE = new Eager();
    private Eager() {}
    public static Eager get() { return INSTANCE; }
  }

  // 写法二:不加锁的懒汉式,并发下会建出多个实例
  static class UnsafeLazy {
    private static UnsafeLazy instance;
    private UnsafeLazy() {}
    public static UnsafeLazy get() {
      if (instance == null) {
        Thread.yield(); // 放大竞态窗口,让两个线程更容易同时通过空检查
        instance = new UnsafeLazy();
      }
      return instance;
    }
  }

  // 写法三:整个方法加锁的懒汉式,安全但每次获取都抢锁
  static class SyncLazy {
    private static SyncLazy instance;
    private SyncLazy() {}
    public static synchronized SyncLazy get() {
      if (instance == null) instance = new SyncLazy();
      return instance;
    }
  }

  // 写法四:双重检查,只有第一次抢锁;volatile 禁止指令重排并保证可见性
  static class DoubleChecked {
    private static volatile DoubleChecked instance;
    private DoubleChecked() {}
    public static DoubleChecked get() {
      if (instance == null) {
        synchronized (DoubleChecked.class) {
          if (instance == null) instance = new DoubleChecked();
        }
      }
      return instance;
    }
  }

  // 写法五:静态内部类,利用类初始化的线程安全特性实现懒加载
  static class Holder {
    private Holder() {}
    private static class Inner { private static final Holder INSTANCE = new Holder(); }
    public static Holder get() { return Inner.INSTANCE; }
  }

  // 写法六:枚举,语言层面兜底,反射也建不出第二个实例
  enum EnumSingleton { INSTANCE }

  static <T> int race(java.util.function.Supplier<T> getter, int threads) throws Exception {
    Set<T> seen = ConcurrentHashMap.newKeySet();
    CountDownLatch ready = new CountDownLatch(threads);
    CountDownLatch go = new CountDownLatch(1);
    Thread[] pool = new Thread[threads];
    for (int i = 0; i < threads; i++) {
      pool[i] = new Thread(() -> {
        ready.countDown();
        try { go.await(); } catch (InterruptedException e) { return; }
        seen.add(getter.get());
      });
      pool[i].start();
    }
    ready.await();
    go.countDown(); // 所有线程同一起跑线并发抢首次获取
    for (Thread t : pool) t.join();
    return seen.size();
  }

  public static void main(String[] args) throws Exception {
    System.out.println("饿汉式并发获取,不同实例数:" + race(Eager::get, 32));
    System.out.println("无锁懒汉并发获取,不同实例数:" + race(UnsafeLazy::get, 32));
    System.out.println("加锁懒汉并发获取,不同实例数:" + race(SyncLazy::get, 32));
    System.out.println("双重检查并发获取,不同实例数:" + race(DoubleChecked::get, 32));
    System.out.println("静态内部类并发获取,不同实例数:" + race(Holder::get, 32));
    System.out.println("枚举并发获取,不同实例数:" + race(() -> EnumSingleton.INSTANCE, 32));
    try {
      EnumSingleton.class.getDeclaredConstructor(String.class, int.class).newInstance("X", 1);
      System.out.println("反射攻击枚举:成功(出事了)");
    } catch (Exception e) {
      System.out.println("反射攻击枚举:被拒绝(" + e.getClass().getSimpleName() + ")");
    }
  }
}

实测输出:

饿汉式并发获取,不同实例数:1
无锁懒汉并发获取,不同实例数:2
加锁懒汉并发获取,不同实例数:1
双重检查并发获取,不同实例数:1
静态内部类并发获取,不同实例数:1
枚举并发获取,不同实例数:1
反射攻击枚举:被拒绝(IllegalArgumentException)

无锁懒汉在 32 线程并发下真实地产出了两个实例——空检查的竞态不是理论风险。其余写法全部收敛到一个实例,反射攻击枚举被虚拟机直接拒绝。

**常见误区。**一是双重检查漏 volatile,理由如上;二是以为「锁了方法就万事大吉」,没意识到初始化完成后每次获取都在付锁钱;三是用普通内部类代替静态内部类,持有外部类引用还失去懒加载语义;四是以为序列化安全不用管——普通单例 readResolve 不写,反序列化会悄悄造出新实例,枚举则天然免疫。

AI 工程里模型句柄、连接池、配置中心客户端都是典型单例场景,错误的写法在高并发初始化时会建出多个实例,直接放大显存与连接开销。回到面试现场,答题节奏:先背出五种写法的安全谱系,再讲透双重检查的 volatile,最后按「懒加载、锁开销、防破坏」三问给选型建议。你在生产里见过单例被写成双例的事故吗?欢迎分享。