"手写一个单例模式,要考虑线程安全。"这句话,凡是做过几年 Java 开发的人应该都不陌生。它几乎是面试题库里的钉子户,笔试、机试、二面、三面,换个姿势反复出现。但说实话,能把这个问题讲透的人真不多。我前几年带团队的时候做过个小测试:让组里几个人现场手写一个线程安全的单例,结果只有少数人写出了双重检查锁,能说清楚 volatile 为什么不能省的,更是屈指可数。这个结果不算意外,单例的逻辑本身很简单,难的是它背后牵扯到的一串东西——类加载机制、指令重排序、内存可见性、反射和序列化的攻击面。这篇文章就沿着"线程安全"这条主线,把单例模式从最初级的写法到工程上推荐的写法完整过一遍,顺带把面试里那些追问点也拆开揉碎讲清楚。看完之后,你不仅写得出来,还能解释得明白。1. 单例模式到底解决了什么问题1.1 单例的本质是"全局唯一"单例模式的核心诉求一句话就能说完:保证一个类在 JVM 生命周期内,有且只有一个实例,并且提供一个全局访问点。它由三个基本要素组成:私有的构造函数,防止外部直接 new;一个保存实例的静态字段;一个公开的静态获取方法。这三样缺一不可。为什么要费这么大劲保证唯一?因为有些资源天生就应该是唯一的。比如一个应用里的配置管理器,如果每个调用方都各自 new 一份配置对象,改了一个地方的配置另一处不生效,那系统行为就完全失控了。再比如数据库连接池、线程池这类重量级对象,创建一次的成本可能高达几十毫秒甚至更高,如果反复创建,应用一启动就把资源耗光了。缓存管理器也是典型例子,全局只需要一份缓存数据,多个实例不仅浪费内存,还会出现同一份数据多个版本的混乱局面。我接手过的一个系统里就踩过类似坑。那是某核心服务在高峰期频繁 OOM,排查到最后发现罪魁祸首是一个负责读写本地缓存的对象,代码里完全没有单例约束,请求一多就疯狂 new,缓存对象遍地开花,内存直接被撑爆。所以说单例这个设计不是学院派的玩具,它真真实实关系到系统的稳定性。理解了"为什么需要唯一",后面讨论"怎么保证唯一"才有意义。1.2 哪些场景真的需要单例单例的适用场景,我总结下来大致有三类。第一类是资源管理型:连接池、线程池、网络客户端、文件句柄管理器,这类对象的创建成本高,并且天然要求全局共享。第二类是状态协调型:配置中心、注册中心、任务调度器、指标收集器,它们的核心作用是让所有模块读到同一个状态,多个实例会导致状态分裂,你改了一处配置,另一个实例还抱着旧配置不放。第三类是工具门面型:一些封装了底层能力的门面对象,比如统一的日志工厂、统一的 ID 生成器,虽然功能上可以做成纯静态方法,但做成单例后可以保留接口语义,方便将来替换实现和做单元测试。这里有个容易被混淆的概念要单独说一下:单例模式和纯静态工具方法不是一回事。静态方法类(比如各种 Utils)没有实例状态,本质上是一堆函数的集合;单例则是一个真正的对象,它可以实现接口、可以持有状态、可以被延迟初始化。当前主流的 IoC 容器默认以单例方式管理对象,其实就是把这个模式下沉到了容器层面,让框架替你保证唯一性。这一点搞清楚了,你就不会在写单例时纠结"为什么不直接写静态方法"这种问题。2. 线程不安全的根源:一次典型的事故现场2.1 经典懒汉式为什么「凉了」要说线程安全,得先从线程不安全的版本聊起。最朴素的懒汉式单例长这样:public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这个写法在单线程环境下没有任何问题:第一次调用时 instance 是 null,走进去创建对象,之后再进来直接返回。但一旦进入多线程环境,问题立刻暴露。两个线程 A 和 B 同时调用 getInstance,线程 A 判断 instance == null 为 true,正要执行 new 的时候,线程 B 也进来了,同样看到 instance 还是 null,于是也执行了 new。最终结果就是两个线程各拿到一个实例,单例的"单"字成了摆设。我把这个竞态拆开模拟一下:时刻线程 A线程 Binstance 状态t1判断 instance == null,结果为 truenullt2准备执行 new,但被操作系统挂起判断 instance == nullnullt3判断结果为 true,执行 new 并赋值指向对象 Bt4恢复执行,执行 new 并赋值被覆盖为对象 A看到没,两次判断都通过了,两个对象都被创建了,这就是经典的"check-then-act"竞态条件。竞态条件这个词听起来唬人,拆开其实就是"先检查、再行动",而检查与行动之间不是原子的,中间被打断就会出问题。程序员的直觉这时候会告诉我们:加锁就能解决。确实,加锁能解决一部分问题,但它解决不了全部,因为还有一个更隐蔽的坑藏在创建实例那行代码里。2.2 实例创建的三步操作与指令重排序隐患即便你侥幸只有单线程执行创建逻辑,代码里那句 instance = new Singleton() 也不是你想象中一步到位的操作。它在 JVM 层面大概要经过三步:给 Singleton 分配一块内存;调用构造函数初始化对象;把引用地址赋值给 instance 字段。问题就出在这三步的顺序上。JVM 和 CPU 出于性能考虑,允许在不影响单线程语义的前提下对指令进行重排序。在极端情况下,第三步"赋值"可能被提前到第二步"初始化"之前执行。这意味着 instance 变量不再为 null,但它指向的对象还没有完成构造。此刻如果有另一个线程进来,第一层判断发现 instance 不为 null,直接返回了一个半初始化的对象,后续使用这个对象调用方法,可能读取到默认值、空指针,甚至出现更诡异的运行时异常。这个隐患单靠加锁也解决不彻底,因为锁只能保证"临界区内互斥",管不到临界区外面的读操作。要彻底解决,需要靠 volatile 的内存屏障语义来禁止这种重排序。这个点留到讲双重检查锁的时候细说,这里先记住一个结论:instance = new Singleton() 不是原子操作,也不是天然有序操作,所有号称线程安全的单例实现,本质上都是在处理"创建"和"读取"之间的同步问题。3. 五种线程安全实现方案逐一拆解3.1 饿汉式:借类加载机制换安全第一种线程安全方案简单到让人怀疑它是不是作弊。既然创建实例有竞态问题,那干脆在类加载阶段就把实例创建好,多线程环境下大家拿到的永远是同一个对象:public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } }这段代码之所以线程安全,依赖的是 JVM 类加载机制的一条硬性保证:一个类只会被加载一次,类的初始化过程由 JVM 保证线程安全,多个线程同时触发类初始化时,只有一个线程会执行初始化代码,其余线程等待。static final 字段在类初始化时赋值,天然满足这个条件,所以饿汉式不需要任何额外的同步手段。饿汉式的短板也很明显:实例在类加载时就被创建了,哪怕你从头到尾没有调用过 getInstance,它也占据了内存。如果这个类的构造函数特别重,比如要建立网络连接、加载大文件,那么应用启动时间就会被拖慢。所以我个人的看法是:如果单例对象本身不重,且不介意随类加载提前创建,饿汉式完全够用,它是五种方案里代码最少、语义最直白、也最难写错的。提示:不要因为饿汉式简单就看不上它。在生产项目里,大量单例对象其实根本不需要懒加载,饿汉式反而避免了线程同步和反射攻击的大部分麻烦。3.2 方法级 synchronized:最直白但也有代价针对懒汉式的竞态问题,最直觉的修复方式就是给获取方法加锁:public class Singleton { private static Singleton instance; private Singleton() { } public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }synchronized 修饰静态方法,锁的是类对象本身,这样 getInstance 的调用就被串行化了,从"单例是否安全"的角度看,这个问题确实被解决了。但它的问题是拿到了"安全"的同时丢掉了"性能":每一次获取实例都要进入同步块,即使 instance 已经创建好了,后面进来的一百个线程依然要排队过锁。高并发场景下,getInstance 往往是高频调用,方法级加锁等于人为制造了一个全局热点,吞吐量会明显下降。我实测过一个直观的数字:在八线程并发持续调用 getInstance 的场景下,方法级 synchronized 的耗时通常比后面要讲的双重检查锁高出一个数量级。原因很简单,锁争用是全局性的,所有线程都必须等锁释放才能继续,而这个锁保护的临界区其实只在第一次创建实例时有意义。所以这个写法我一般只建议用于讲解演示、或者并发量极低的后台管理类应用,工程主流程上不推荐。3.3 双重检查锁 DCL:并发场景的主流答案双重检查锁(Double-Checked Locking)是方法级 synchronized 基础上的改良,核心思路是:只在第一次创建实例时才加锁,后续获取直接走无锁路径: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; } }代码看起来不复杂,但里面每个细节都有来头。外层 if 是第一次检查,目的是过滤掉"实例已经创建好"的绝大多数调用,让它们不进入锁,避免全局串行。加了锁之后,进入 synchronized 块的线程还要做第二次 if 检查,这是关键——因为可能有多个线程同时通过第一次检查,如果它们排队进入临界区,每个线程都执行一次 new,那还是会造成多个实例。第二层检查确保只有真正第一个进入的线程才会创建对象,后面的线程看到 instance 不为 null 就直接放弃了。然后就是 volatile。为什么没有它不行?回到前面说的指令重排序问题。没有 volatile 时,线程 A 进入临界区执行 new,由于 new 分三步且三步可能重排序,A 可能先把引用赋值给 instance,还没来得及调用构造函数。此时线程 B 在外层判断发现 instance 不为 null,直接返回并使用这个半成品对象,程序行为就不可预期了。volatile 提供了 happens-before 语义和内存屏障:它保证了对 instance 的写操作不会与构造函数内的操作发生重排序,同时也保证了写完之后,其他线程能立刻看到这个最新引用。一句话总结:volatile 在 DCL 里承担了"禁止重排序"和"保证可见性"双重职责,去掉它 DCL 就是一颗延时炸弹。这里还要替 DCL 说句公道话:在 Java 5 之前,volatile 的内存语义不够完善,DCL 确实存在争议,很多老文章说"DCL 不可用"也是源于那个年代。但 Java 5 之后内存模型被重写,volatile 的语义已经足够支撑 DCL 正确工作,现在写 DCL 是完全正当的,不要被历史包袱吓到。3.4 静态内部类 Holder:不锁也不饿的优雅方案如果你既想要懒加载,又不想要锁,静态内部类方案是公认最优雅的写法:public class Singleton { private Singleton() { } private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它的原理还是那套类加载机制,但做了个巧妙的封装。外层 Singleton 类加载时,静态内部类 Holder 并不会被加载,所以 INSTANCE 不会在这个阶段创建。首次调用 getInstance 时,代码访问了 Holder.INSTANCE,触发 Holder 的类初始化,JVM 保证这个初始化过程线程安全且只执行一次。于是懒加载有了,线程安全也有了,还没有任何显式加锁。这个方案我第一次看的时候也觉得像变魔术:为什么静态内部类的类初始化是线程安全的?原因是 JVM 规范对"类或接口的初始化"有明确约束:初始化过程会加一把内部锁,多个线程同时触发同一个类的初始化时,只有一个线程能真正执行初始化,其他线程阻塞等待。这个机制和饿汉式是同一个根基,只是通过内部类的加载时机把"创建实例"延后到了首次使用时。所以在工程实践里,如果项目没有特殊要求,我一般优先写 Holder 方案——代码简洁、语义清晰、性能接近无锁,面试官看了通常也会点头。3.5 枚举:对抗反射和序列化的终极形态前四种方案有个共同的软肋:它们都允许通过反射机制调用私有构造器来创建第二个实例,也都可能在实现 Serializable 接口后被序列化机制破坏唯一性。要彻底堵死这两个漏洞,终极方案是枚举:public enum Singleton { INSTANCE; // 这里写业务方法 public void doSomething() { } }枚举实现单例的线程安全,靠的还是 JVM 类加载和枚举本身的特性,这一点和其他方案一致。它真正的优势在于"先天的防御力":枚举类的构造函数在字节码层面就是 private 的,反射调用 newInstance 会直接抛异常;枚举类实现序列化接口时,JVM 有专门的序列化逻辑,反序列化阶段不会调用构造函数创建新实例,而是直接返回已有的枚举常量。换句话说,反射和序列化这两条攻击路径,枚举从根上就堵死了。当然枚举方案也不是万能钥匙。有些团队对枚举的写法不熟悉,觉得"用枚举做单例"不直观,代码评审时还得跟人解释;另外如果单例需要继承某个类,枚举也不行,因为枚举隐式继承了 java.lang.Enum。但从并发安全、反攻击、代码量三个维度综合看,枚举是公认最稳健的实现,很多资深开发者写单例时第一选择就是它。4. 方案选型对比与适用场景4.1 一张表看清五方案差异五种方案讲完了,我先把关键差异用一张表收拢起来,方便大家查阅。实现方案线程安全懒加载抗反射抗序列化并发性能代码复杂度饿汉式是否否需额外处理极高最低方法级 synchronized是是否需额外处理低低双重检查锁 DCL是是否需额外处理高中静态内部类 Holder是是否需额外处理高中枚举是是是是高最低补充一个细节:这里说的"抗序列化:否"不代表一定会出问题,而是说默认情况下有问题,需要你额外实现 readResolve 方法来补救。枚举是唯一一个什么都不用做就天然免疫的方案。抗反射同理,前四种方案都需要在构造函数里手动加一道防反射的检查,枚举是代码层面直接禁止。这张表平时放在手边,写代码前扫一眼,基本不会选错。4.2 不同场景下的选择建议选型这块,我给几条实操建议。第一,如果你是在面试场景,DCL 和 Holder 二选一写出来,并且把 volatile 的作用讲清楚,基本就稳了。面试官后面大概率会追问"为什么用 volatile""不用会怎样",这两个问题答好了,比写出五种方案更有说服力。第二,如果你是在业务项目里,单例对象不重、启动时可接受提前创建,直接上饿汉式,简单可靠,出问题的概率最低。很多时候追求懒加载其实是过度设计,为了省那几毫秒的启动时间,引入了额外的复杂性,不划算。第三,如果项目里频繁出现序列化场景,比如单例对象要存缓存、做 RPC 传输,优先考虑枚举,少写一段 readResolve 的防御代码。第四,如果单例的创建特别重、启动时不想加载、又不想加锁,Holder 方案是最优解。这里我想多说一句关于"选择恐惧"的建议:单例模式不是一个需要炫技的地方,选一个你百分百能解释清楚、能背下来的方案,比选一个"理论上最优"但你讲不明白的方案,在面试和工程中都更靠谱。我见过太多人写出了 DCL 但被追问 volatile 就卡壳,这种状态还不如老老实实写枚举。方案没有绝对的高低,只有适不适合当前上下文。5. 常见问题与排查技巧实录5.1 序列化与反射如何破坏单例前文反复提到序列化和反射是单例的两大威胁,这里展开讲讲,因为这是面试追问的重灾区。先说序列化。如果一个单例类实现了 Serializable 接口,你在序列化之后做反序列化,默认情况下会创建一个全新的实例,因为它走的不是构造函数,而是 ObjectInputStream 内部基于反射的对象构造逻辑。修复方法是给类加一个 readResolve 方法:public class Singleton implements Serializable { private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } private Object readResolve() { return INSTANCE; } }readResolve 的作用是:反序列化流程在创建好临时对象之后,会检测是否存在 readResolve 方法,如果有,就用这个方法的返回值替代临时对象作为反序列化结果。这样一来,外部拿到的还是原来那个唯一实例,临时对象直接废弃。再说反射。反射攻击的路径非常清楚:拿到单例类的 Class 对象,调用 getDeclaredConstructor,再 setAccessible(true),最后 newInstance。私有构造器在反射面前形同虚设。防御手段是在私有构造函数里加一个标志位判断:public class Singleton { private static boolean flag = false; private Singleton() { if (flag) { throw new IllegalStateException("禁止反射创建实例"); } flag = true; } }这个方案也不算绝对安全,因为攻击者同样可以通过反射修改 flag 字段,但它足以挡住常规的反射调用。真正对这类攻击零容忍的方案就是枚举,这也是它被称为终极形态的根本原因。面试时能把"枚举为什么能防住这两种攻击"说清楚,这一题基本就满分了。5.2 双重检查锁的两次实战复盘DCL 是我实际项目里用得最多的写法,这里分享两个踩过的点。第一个是忘记 volatile。有一回我在代码评审里看到同事写了一个没有 volatile 的 DCL,当时觉得眼熟,差点就放过去了,后来才意识到这是经典隐患。为什么要揪着 volatile 不放?前面说过,没有 volatile 时指令重排序可能让引用先于构造完成发布,线程在并发场景下拿到的可能是一个构造到一半的对象。这个 bug 极其隐蔽:它不是必现问题,只在特定时机、特定线程调度下才会触发,可能上线几个月才出一次事故,一旦出现就是极难排查的诡异行为——某个字段是默认值、某个方法是空指针,但单独调试永远复现不出来。这类问题最折磨人,所以大家在写 DCL 时务必养成肌肉记忆:instance 字段必须是 volatile。第二个是锁对象的选取。DCL 里 synchronized 锁的是 Singleton.class,这是个非常讲究的选择。如果锁的是其他对象,比如每次 new Object(),那么每次调用 getInstance 拿到的锁对象可能都不同,锁就完全失效了。静态方法上的同步本来锁的就是类对象,我们把同步块放在方法内部,自然也应该锁同一个类对象,保持语义一致。这个细节如果在面试中被问到,能答上来会加分不少。5.3 容易被忽略的实现细节与代码规范最后整理几个平时写单例容易忽略的细节。第一,构造函数不仅要私有,还应该尽量只做最简单的赋值工作,不要在里面启动线程、连接数据库、读取大文件,否则第一次触发 getInstance 时的响应时间会非常难看。第二,如果单例类要被继承体系使用,优先用接口或抽象类定义行为,单例类本身保持 final,防止子类覆盖破坏唯一性。第三,不要在静态初始化块里做可能抛异常的事情,类初始化失败会导致后续所有对这个类的访问都抛 ExceptionInInitializerError,这种错误极其隐蔽,日志里不仔细看根本定位不到。还有一个我特别想强调的工程实践:单例模式不要让"唯一"解决问题的方式污染了业务代码。我看到过一些项目把单例对象直接当作全局变量到处调用,结果模块之间耦合越来越深,代码也没法测试。正确的姿势是把单例交给合理的入口管理,比如通过一个获取器统一访问,或者直接交给 IoC 容器管,业务代码只面向接口编程。单例负责的是"唯一性",不是"全局可达的上帝对象",这两者要分清楚。最后分享一个我自己攒下来的小习惯:写单例前先回答三个问题——这个对象真的需要全局唯一吗?能否接受类加载时就创建?有没有序列化或反射暴露的风险?把这三个问题想清楚了,方案自然就浮出水面。有一次我就是靠着这个习惯,在项目启动前提前发现某个重量级组件根本不该做成懒加载单例,硬是帮团队省了一次线上事故的排查时间。单例模式本身不难,难的是别在不该用单例的地方滥用它,也别在该做防御的时候图省事。希望能帮你把这道"送分题"稳稳拿到手。
"手写一个单例模式,要考虑线程安全。"这句话,凡是做过几年 Java 开发的人应该都不陌生。它几乎是面试题库里的钉子户,笔试、机试、二面、三面,换个姿势反复出现。但说实话,能把这个问题讲透的人真不多。我前几年带团队的时候做过个小测试:让组里几个人现场手写一个线程安全的单例,结果只有少数人写出了双重检查锁,能说清楚 volatile 为什么不能省的,更是屈指可数。这个结果不算意外,单例的逻辑本身很简单,难的是它背后牵扯到的一串东西——类加载机制、指令重排序、内存可见性、反射和序列化的攻击面。这篇文章就沿着"线程安全"这条主线,把单例模式从最初级的写法到工程上推荐的写法完整过一遍,顺带把面试里那些追问点也拆开揉碎讲清楚。看完之后,你不仅写得出来,还能解释得明白。1. 单例模式到底解决了什么问题1.1 单例的本质是"全局唯一"单例模式的核心诉求一句话就能说完:保证一个类在 JVM 生命周期内,有且只有一个实例,并且提供一个全局访问点。它由三个基本要素组成:私有的构造函数,防止外部直接 new;一个保存实例的静态字段;一个公开的静态获取方法。这三样缺一不可。为什么要费这么大劲保证唯一?因为有些资源天生就应该是唯一的。比如一个应用里的配置管理器,如果每个调用方都各自 new 一份配置对象,改了一个地方的配置另一处不生效,那系统行为就完全失控了。再比如数据库连接池、线程池这类重量级对象,创建一次的成本可能高达几十毫秒甚至更高,如果反复创建,应用一启动就把资源耗光了。缓存管理器也是典型例子,全局只需要一份缓存数据,多个实例不仅浪费内存,还会出现同一份数据多个版本的混乱局面。我接手过的一个系统里就踩过类似坑。那是某核心服务在高峰期频繁 OOM,排查到最后发现罪魁祸首是一个负责读写本地缓存的对象,代码里完全没有单例约束,请求一多就疯狂 new,缓存对象遍地开花,内存直接被撑爆。所以说单例这个设计不是学院派的玩具,它真真实实关系到系统的稳定性。理解了"为什么需要唯一",后面讨论"怎么保证唯一"才有意义。1.2 哪些场景真的需要单例单例的适用场景,我总结下来大致有三类。第一类是资源管理型:连接池、线程池、网络客户端、文件句柄管理器,这类对象的创建成本高,并且天然要求全局共享。第二类是状态协调型:配置中心、注册中心、任务调度器、指标收集器,它们的核心作用是让所有模块读到同一个状态,多个实例会导致状态分裂,你改了一处配置,另一个实例还抱着旧配置不放。第三类是工具门面型:一些封装了底层能力的门面对象,比如统一的日志工厂、统一的 ID 生成器,虽然功能上可以做成纯静态方法,但做成单例后可以保留接口语义,方便将来替换实现和做单元测试。这里有个容易被混淆的概念要单独说一下:单例模式和纯静态工具方法不是一回事。静态方法类(比如各种 Utils)没有实例状态,本质上是一堆函数的集合;单例则是一个真正的对象,它可以实现接口、可以持有状态、可以被延迟初始化。当前主流的 IoC 容器默认以单例方式管理对象,其实就是把这个模式下沉到了容器层面,让框架替你保证唯一性。这一点搞清楚了,你就不会在写单例时纠结"为什么不直接写静态方法"这种问题。2. 线程不安全的根源:一次典型的事故现场2.1 经典懒汉式为什么「凉了」要说线程安全,得先从线程不安全的版本聊起。最朴素的懒汉式单例长这样:public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这个写法在单线程环境下没有任何问题:第一次调用时 instance 是 null,走进去创建对象,之后再进来直接返回。但一旦进入多线程环境,问题立刻暴露。两个线程 A 和 B 同时调用 getInstance,线程 A 判断 instance == null 为 true,正要执行 new 的时候,线程 B 也进来了,同样看到 instance 还是 null,于是也执行了 new。最终结果就是两个线程各拿到一个实例,单例的"单"字成了摆设。我把这个竞态拆开模拟一下:时刻线程 A线程 Binstance 状态t1判断 instance == null,结果为 truenullt2准备执行 new,但被操作系统挂起判断 instance == nullnullt3判断结果为 true,执行 new 并赋值指向对象 Bt4恢复执行,执行 new 并赋值被覆盖为对象 A看到没,两次判断都通过了,两个对象都被创建了,这就是经典的"check-then-act"竞态条件。竞态条件这个词听起来唬人,拆开其实就是"先检查、再行动",而检查与行动之间不是原子的,中间被打断就会出问题。程序员的直觉这时候会告诉我们:加锁就能解决。确实,加锁能解决一部分问题,但它解决不了全部,因为还有一个更隐蔽的坑藏在创建实例那行代码里。2.2 实例创建的三步操作与指令重排序隐患即便你侥幸只有单线程执行创建逻辑,代码里那句 instance = new Singleton() 也不是你想象中一步到位的操作。它在 JVM 层面大概要经过三步:给 Singleton 分配一块内存;调用构造函数初始化对象;把引用地址赋值给 instance 字段。问题就出在这三步的顺序上。JVM 和 CPU 出于性能考虑,允许在不影响单线程语义的前提下对指令进行重排序。在极端情况下,第三步"赋值"可能被提前到第二步"初始化"之前执行。这意味着 instance 变量不再为 null,但它指向的对象还没有完成构造。此刻如果有另一个线程进来,第一层判断发现 instance 不为 null,直接返回了一个半初始化的对象,后续使用这个对象调用方法,可能读取到默认值、空指针,甚至出现更诡异的运行时异常。这个隐患单靠加锁也解决不彻底,因为锁只能保证"临界区内互斥",管不到临界区外面的读操作。要彻底解决,需要靠 volatile 的内存屏障语义来禁止这种重排序。这个点留到讲双重检查锁的时候细说,这里先记住一个结论:instance = new Singleton() 不是原子操作,也不是天然有序操作,所有号称线程安全的单例实现,本质上都是在处理"创建"和"读取"之间的同步问题。3. 五种线程安全实现方案逐一拆解3.1 饿汉式:借类加载机制换安全第一种线程安全方案简单到让人怀疑它是不是作弊。既然创建实例有竞态问题,那干脆在类加载阶段就把实例创建好,多线程环境下大家拿到的永远是同一个对象:public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } }这段代码之所以线程安全,依赖的是 JVM 类加载机制的一条硬性保证:一个类只会被加载一次,类的初始化过程由 JVM 保证线程安全,多个线程同时触发类初始化时,只有一个线程会执行初始化代码,其余线程等待。static final 字段在类初始化时赋值,天然满足这个条件,所以饿汉式不需要任何额外的同步手段。饿汉式的短板也很明显:实例在类加载时就被创建了,哪怕你从头到尾没有调用过 getInstance,它也占据了内存。如果这个类的构造函数特别重,比如要建立网络连接、加载大文件,那么应用启动时间就会被拖慢。所以我个人的看法是:如果单例对象本身不重,且不介意随类加载提前创建,饿汉式完全够用,它是五种方案里代码最少、语义最直白、也最难写错的。提示:不要因为饿汉式简单就看不上它。在生产项目里,大量单例对象其实根本不需要懒加载,饿汉式反而避免了线程同步和反射攻击的大部分麻烦。3.2 方法级 synchronized:最直白但也有代价针对懒汉式的竞态问题,最直觉的修复方式就是给获取方法加锁:public class Singleton { private static Singleton instance; private Singleton() { } public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }synchronized 修饰静态方法,锁的是类对象本身,这样 getInstance 的调用就被串行化了,从"单例是否安全"的角度看,这个问题确实被解决了。但它的问题是拿到了"安全"的同时丢掉了"性能":每一次获取实例都要进入同步块,即使 instance 已经创建好了,后面进来的一百个线程依然要排队过锁。高并发场景下,getInstance 往往是高频调用,方法级加锁等于人为制造了一个全局热点,吞吐量会明显下降。我实测过一个直观的数字:在八线程并发持续调用 getInstance 的场景下,方法级 synchronized 的耗时通常比后面要讲的双重检查锁高出一个数量级。原因很简单,锁争用是全局性的,所有线程都必须等锁释放才能继续,而这个锁保护的临界区其实只在第一次创建实例时有意义。所以这个写法我一般只建议用于讲解演示、或者并发量极低的后台管理类应用,工程主流程上不推荐。3.3 双重检查锁 DCL:并发场景的主流答案双重检查锁(Double-Checked Locking)是方法级 synchronized 基础上的改良,核心思路是:只在第一次创建实例时才加锁,后续获取直接走无锁路径: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; } }代码看起来不复杂,但里面每个细节都有来头。外层 if 是第一次检查,目的是过滤掉"实例已经创建好"的绝大多数调用,让它们不进入锁,避免全局串行。加了锁之后,进入 synchronized 块的线程还要做第二次 if 检查,这是关键——因为可能有多个线程同时通过第一次检查,如果它们排队进入临界区,每个线程都执行一次 new,那还是会造成多个实例。第二层检查确保只有真正第一个进入的线程才会创建对象,后面的线程看到 instance 不为 null 就直接放弃了。然后就是 volatile。为什么没有它不行?回到前面说的指令重排序问题。没有 volatile 时,线程 A 进入临界区执行 new,由于 new 分三步且三步可能重排序,A 可能先把引用赋值给 instance,还没来得及调用构造函数。此时线程 B 在外层判断发现 instance 不为 null,直接返回并使用这个半成品对象,程序行为就不可预期了。volatile 提供了 happens-before 语义和内存屏障:它保证了对 instance 的写操作不会与构造函数内的操作发生重排序,同时也保证了写完之后,其他线程能立刻看到这个最新引用。一句话总结:volatile 在 DCL 里承担了"禁止重排序"和"保证可见性"双重职责,去掉它 DCL 就是一颗延时炸弹。这里还要替 DCL 说句公道话:在 Java 5 之前,volatile 的内存语义不够完善,DCL 确实存在争议,很多老文章说"DCL 不可用"也是源于那个年代。但 Java 5 之后内存模型被重写,volatile 的语义已经足够支撑 DCL 正确工作,现在写 DCL 是完全正当的,不要被历史包袱吓到。3.4 静态内部类 Holder:不锁也不饿的优雅方案如果你既想要懒加载,又不想要锁,静态内部类方案是公认最优雅的写法:public class Singleton { private Singleton() { } private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它的原理还是那套类加载机制,但做了个巧妙的封装。外层 Singleton 类加载时,静态内部类 Holder 并不会被加载,所以 INSTANCE 不会在这个阶段创建。首次调用 getInstance 时,代码访问了 Holder.INSTANCE,触发 Holder 的类初始化,JVM 保证这个初始化过程线程安全且只执行一次。于是懒加载有了,线程安全也有了,还没有任何显式加锁。这个方案我第一次看的时候也觉得像变魔术:为什么静态内部类的类初始化是线程安全的?原因是 JVM 规范对"类或接口的初始化"有明确约束:初始化过程会加一把内部锁,多个线程同时触发同一个类的初始化时,只有一个线程能真正执行初始化,其他线程阻塞等待。这个机制和饿汉式是同一个根基,只是通过内部类的加载时机把"创建实例"延后到了首次使用时。所以在工程实践里,如果项目没有特殊要求,我一般优先写 Holder 方案——代码简洁、语义清晰、性能接近无锁,面试官看了通常也会点头。3.5 枚举:对抗反射和序列化的终极形态前四种方案有个共同的软肋:它们都允许通过反射机制调用私有构造器来创建第二个实例,也都可能在实现 Serializable 接口后被序列化机制破坏唯一性。要彻底堵死这两个漏洞,终极方案是枚举:public enum Singleton { INSTANCE; // 这里写业务方法 public void doSomething() { } }枚举实现单例的线程安全,靠的还是 JVM 类加载和枚举本身的特性,这一点和其他方案一致。它真正的优势在于"先天的防御力":枚举类的构造函数在字节码层面就是 private 的,反射调用 newInstance 会直接抛异常;枚举类实现序列化接口时,JVM 有专门的序列化逻辑,反序列化阶段不会调用构造函数创建新实例,而是直接返回已有的枚举常量。换句话说,反射和序列化这两条攻击路径,枚举从根上就堵死了。当然枚举方案也不是万能钥匙。有些团队对枚举的写法不熟悉,觉得"用枚举做单例"不直观,代码评审时还得跟人解释;另外如果单例需要继承某个类,枚举也不行,因为枚举隐式继承了 java.lang.Enum。但从并发安全、反攻击、代码量三个维度综合看,枚举是公认最稳健的实现,很多资深开发者写单例时第一选择就是它。4. 方案选型对比与适用场景4.1 一张表看清五方案差异五种方案讲完了,我先把关键差异用一张表收拢起来,方便大家查阅。实现方案线程安全懒加载抗反射抗序列化并发性能代码复杂度饿汉式是否否需额外处理极高最低方法级 synchronized是是否需额外处理低低双重检查锁 DCL是是否需额外处理高中静态内部类 Holder是是否需额外处理高中枚举是是是是高最低补充一个细节:这里说的"抗序列化:否"不代表一定会出问题,而是说默认情况下有问题,需要你额外实现 readResolve 方法来补救。枚举是唯一一个什么都不用做就天然免疫的方案。抗反射同理,前四种方案都需要在构造函数里手动加一道防反射的检查,枚举是代码层面直接禁止。这张表平时放在手边,写代码前扫一眼,基本不会选错。4.2 不同场景下的选择建议选型这块,我给几条实操建议。第一,如果你是在面试场景,DCL 和 Holder 二选一写出来,并且把 volatile 的作用讲清楚,基本就稳了。面试官后面大概率会追问"为什么用 volatile""不用会怎样",这两个问题答好了,比写出五种方案更有说服力。第二,如果你是在业务项目里,单例对象不重、启动时可接受提前创建,直接上饿汉式,简单可靠,出问题的概率最低。很多时候追求懒加载其实是过度设计,为了省那几毫秒的启动时间,引入了额外的复杂性,不划算。第三,如果项目里频繁出现序列化场景,比如单例对象要存缓存、做 RPC 传输,优先考虑枚举,少写一段 readResolve 的防御代码。第四,如果单例的创建特别重、启动时不想加载、又不想加锁,Holder 方案是最优解。这里我想多说一句关于"选择恐惧"的建议:单例模式不是一个需要炫技的地方,选一个你百分百能解释清楚、能背下来的方案,比选一个"理论上最优"但你讲不明白的方案,在面试和工程中都更靠谱。我见过太多人写出了 DCL 但被追问 volatile 就卡壳,这种状态还不如老老实实写枚举。方案没有绝对的高低,只有适不适合当前上下文。5. 常见问题与排查技巧实录5.1 序列化与反射如何破坏单例前文反复提到序列化和反射是单例的两大威胁,这里展开讲讲,因为这是面试追问的重灾区。先说序列化。如果一个单例类实现了 Serializable 接口,你在序列化之后做反序列化,默认情况下会创建一个全新的实例,因为它走的不是构造函数,而是 ObjectInputStream 内部基于反射的对象构造逻辑。修复方法是给类加一个 readResolve 方法:public class Singleton implements Serializable { private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } private Object readResolve() { return INSTANCE; } }readResolve 的作用是:反序列化流程在创建好临时对象之后,会检测是否存在 readResolve 方法,如果有,就用这个方法的返回值替代临时对象作为反序列化结果。这样一来,外部拿到的还是原来那个唯一实例,临时对象直接废弃。再说反射。反射攻击的路径非常清楚:拿到单例类的 Class 对象,调用 getDeclaredConstructor,再 setAccessible(true),最后 newInstance。私有构造器在反射面前形同虚设。防御手段是在私有构造函数里加一个标志位判断:public class Singleton { private static boolean flag = false; private Singleton() { if (flag) { throw new IllegalStateException("禁止反射创建实例"); } flag = true; } }这个方案也不算绝对安全,因为攻击者同样可以通过反射修改 flag 字段,但它足以挡住常规的反射调用。真正对这类攻击零容忍的方案就是枚举,这也是它被称为终极形态的根本原因。面试时能把"枚举为什么能防住这两种攻击"说清楚,这一题基本就满分了。5.2 双重检查锁的两次实战复盘DCL 是我实际项目里用得最多的写法,这里分享两个踩过的点。第一个是忘记 volatile。有一回我在代码评审里看到同事写了一个没有 volatile 的 DCL,当时觉得眼熟,差点就放过去了,后来才意识到这是经典隐患。为什么要揪着 volatile 不放?前面说过,没有 volatile 时指令重排序可能让引用先于构造完成发布,线程在并发场景下拿到的可能是一个构造到一半的对象。这个 bug 极其隐蔽:它不是必现问题,只在特定时机、特定线程调度下才会触发,可能上线几个月才出一次事故,一旦出现就是极难排查的诡异行为——某个字段是默认值、某个方法是空指针,但单独调试永远复现不出来。这类问题最折磨人,所以大家在写 DCL 时务必养成肌肉记忆:instance 字段必须是 volatile。第二个是锁对象的选取。DCL 里 synchronized 锁的是 Singleton.class,这是个非常讲究的选择。如果锁的是其他对象,比如每次 new Object(),那么每次调用 getInstance 拿到的锁对象可能都不同,锁就完全失效了。静态方法上的同步本来锁的就是类对象,我们把同步块放在方法内部,自然也应该锁同一个类对象,保持语义一致。这个细节如果在面试中被问到,能答上来会加分不少。5.3 容易被忽略的实现细节与代码规范最后整理几个平时写单例容易忽略的细节。第一,构造函数不仅要私有,还应该尽量只做最简单的赋值工作,不要在里面启动线程、连接数据库、读取大文件,否则第一次触发 getInstance 时的响应时间会非常难看。第二,如果单例类要被继承体系使用,优先用接口或抽象类定义行为,单例类本身保持 final,防止子类覆盖破坏唯一性。第三,不要在静态初始化块里做可能抛异常的事情,类初始化失败会导致后续所有对这个类的访问都抛 ExceptionInInitializerError,这种错误极其隐蔽,日志里不仔细看根本定位不到。还有一个我特别想强调的工程实践:单例模式不要让"唯一"解决问题的方式污染了业务代码。我看到过一些项目把单例对象直接当作全局变量到处调用,结果模块之间耦合越来越深,代码也没法测试。正确的姿势是把单例交给合理的入口管理,比如通过一个获取器统一访问,或者直接交给 IoC 容器管,业务代码只面向接口编程。单例负责的是"唯一性",不是"全局可达的上帝对象",这两者要分清楚。最后分享一个我自己攒下来的小习惯:写单例前先回答三个问题——这个对象真的需要全局唯一吗?能否接受类加载时就创建?有没有序列化或反射暴露的风险?把这三个问题想清楚了,方案自然就浮出水面。有一次我就是靠着这个习惯,在项目启动前提前发现某个重量级组件根本不该做成懒加载单例,硬是帮团队省了一次线上事故的排查时间。单例模式本身不难,难的是别在不该用单例的地方滥用它,也别在该做防御的时候图省事。希望能帮你把这道"送分题"稳稳拿到手。