“单例模式”这四个字,应该是Java面试里出现频率最高的基础题之一了。几乎每次面试都会让你手写一个单例模式,而一旦问“考虑线程安全”,很多平时写业务代码很溜的人就开始卡壳。线程安全怎么写?加锁?加什么锁?为什么加了锁还不对?今天这篇博文,就把手写单例模式这件事从头到尾撸一遍——从最简单写法到最稳妥写法,从代码到原理,从原理到坑,全给你讲清楚,看完你不仅能默写,还能跟面试官把背后的并发和类加载机制聊明白,而且实际项目里也能选对正确方案。1. 单例模式到底在解决什么问题1.1 先搞清楚“单例”是干什么用的单例模式的核心诉求就一句话:保证一个类在整个应用运行期间只有一个实例。听起来很简单,但对应的现实场景非常多。比如一个全局配置管理器、一个线程池对象、一个数据库连接池、一个日志写入器,这类组件的特点是:本身没有状态或者状态全局唯一,创建和销毁的成本很高,而且多个实例同时存在会导致资源浪费甚至数据冲突。你可以类比成公司里只有一台打印机,所有人都去这台打印机上打印,而不是每人买一台回家。如果每人一台,不仅浪费,更麻烦的是各部门看到的打印版本可能还不一致——对应到程序里,就是多个实例维护了各自的内部状态,互相覆盖,Bug 离奇得让你怀疑人生。所以单例模式有几个不可替代的价值:实例唯一性:整个应用只存在一个对象,状态可以被全局共享。资源可控性:避免频繁创建和销毁大对象,节省内存和 CPU。一致性保证:所有调用方访问的是同一个对象,看到的内部数据不会因实例不同而产生偏差。1.2 线程安全问题的根源在哪里理解了单例的价值,接下来就是关键问题:为什么会有线程安全问题?如果你写的是最简单版本的懒汉式:public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这段代码在单线程环境下完全没问题,但在多线程环境下就会出乱子。假设线程 A 和线程 B 同时执行到if (instance == null)这一行,此刻实例还都是 null,于是两个线程都会进入 if 分支,各自执行new Singleton()。结果就是内存里出现了两个不同的实例,线程 A 拿到 A 对象,线程 B 拿到 B 对象,单例被破坏了。更隐蔽的问题发生在创建对象的过程本身。new Singleton()在字节码层面并不是一个原子操作,它大致要经历三步:分配内存空间。调用构造器初始化对象。把引用指向这块内存。如果发生了指令重排序,2 和 3 的顺序可能互换,也就是说引用先指向了内存,但对象还没初始化完。此时另一个线程看到引用不为 null,直接拿去使用,就会拿到一个“半初始化”的对象——这在并发环境里是相当致命的问题。所以手写一个线程安全的单例,本质上是在解决两件事:一是保证实例只会被创建一次,二是保证创建过程对其他线程是“安全可见”的。2. 六种手写方案的完整拆解2.1 饿汉式:线程安全的基础写法饿汉式的思路很简单:在类加载阶段就把实例创建好,利用 JVM 类加载机制保证“一个类只会被加载一次”这个天然特性来实现线程安全。public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }为什么它线程安全?因为INSTANCE是静态字段,在类加载的初始化阶段就会被赋值,而类的初始化过程由 JVM 保证只会执行一次,并且其他线程在类被初始化完成之前是无法访问这个类的静态字段的。也就是说,JVM 帮你把并发问题挡在了门外。饿汉式的优点很突出:写法极简单,不需要加锁,没有任何并发复杂度。线程安全由类加载机制天然保证,可信度高。没有锁竞争,getInstance() 调用性能极高。缺点同样明显:实例在类加载时就创建,如果这个单例对象非常“重”,比如初始化时要读取大量配置文件、建立一堆连接池,而应用启动后根本没用上它,那这部分资源就白白浪费了。这叫作“饿汉式”的原因就在这里——不管吃不吃,先端上桌。适用场景:单例对象创建开销可接受,或者应用启动后本来就一定会用到它的场景。注意:INSTANCE必须声明为final,这样既保证了不可变语义,也避免写代码时不小心重新赋值覆盖掉单例对象。2.2 懒汉式(不加锁):只能看不能用的写法懒汉式的初衷是解决饿汉式的资源浪费问题——我什么时候调用getInstance(),什么时候才创建实例。public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这种写法从概念上讲是对的,但线程安全问题非常明显。多个线程同时进入if (instance == null)时,都会创建新的实例,前面已经分析过,这里不再重复。那这种写法是不是完全没有价值?也不是,它把“懒加载”的核心思想表达得很清楚,代码可读性也最高。如果你只是写一个单线程工具类,或者某个死都不会被并发访问的场景,它能跑。但核心问题是:只要有一天需求变了,这个类被多个线程同时调用,Bug 就会以非常低的概率间歇性出现——这是最让人头疼的类型,因为测试环境可能十次都跑不出问题,一到线上高并发就崩。所以我的建议是:面试时你可以先写这版,解释“它线程不安全,因为存在竞态条件”,然后再引出后面的优化方案。但生产环境不要用。2.3 方法级 synchronized 懒汉式:安全但效率拉胯既然不加锁不行,那最简单的加锁方式就是在方法上加上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 != null,直接返回已有对象。线程安全是保证了。但问题在于锁的粒度太大了。每次调用getInstance()(即使实例早就创建好了)都要经过一次加锁、锁竞争、释放锁的完整流程。单例对象创建完成之后,剩下的所有调用都是纯读取操作,完全不需要互斥,但你仍然让所有线程在锁上排队。我在一个项目里真实遇到过类似的场景:一个工具类单例被一个高并发的热点接口频繁调用,每秒几万次请求,因为加了方法级同步锁,接口吞吐率直接腰斩,压测数据惨不忍睹。这个方法能不能用?能,前提是你的单例访问频率极低,锁竞争不会成为瓶颈。否则,你需要更细粒度的方案。2.4 双重检查锁(DCL):面试常考点,也是生产常用点双重检查锁(Double-Checked Locking,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; } }拆开来看,它的设计很精妙:第一层if (instance == null)是在没有锁的情况下做判断。如果实例已经创建好了,直接返回,不需要进入同步块,避免锁竞争,保证高频读取场景的性能。第二层synchronized包住的代码块,只有在实例尚未创建时才会执行。同一个时刻只允许一个线程进入同步块执行创建逻辑。第三层if (instance == null)是为了防止“多个线程连续获得锁”导致重复创建。假设线程 A 先获得锁并创建了实例,释放锁后线程 B 进入同步块,如果这里不做第二次检查,线程 B 会再次创建新实例,单例直接失效。那volatile在这里干什么?这才是 DCL 的灵魂。instance = new Singleton()这个赋值操作不是原子操作,前面提过它包含三步:分配内存、初始化对象、引用指向内存。如果 JVM 对这三步做了指令重排序,把“引用指向内存”排到了“初始化对象”前面,那么另一个线程在第一次if判断时就会看到instance != null,于是直接返回这个“半初始化”的对象,后续使用这个对象里的字段时就会拿到默认值甚至触发空指针异常。volatile关键字在这里发挥两大作用:可见性:对instance的写入操作对其他线程立即可见,其他线程不会读到过期的 null。禁止指令重排序:volatile会插入内存屏障,保证new Singleton()的三步操作不会被重排,防止“半初始化”对象被其他线程看到。经验之谈:DCL 里volatile不是一个可选项,而是必选项。面试时如果把volatile去掉,表面上看代码好像也能跑,并发测试偶尔也能过,但理论上就是存在隐患。这种“偶尔能过”的Bug最可怕,因为你不知道什么情况下会触发,排查起来非常痛苦。DCL 的优点:懒加载,线程安全,且首次创建之后读取无锁竞争,性能优秀。缺点:代码略复杂,需要理解 volatile 和重排序才能完全吃透。2.5 静态内部类:懒加载与线程安全兼得的优雅方案如果说 DCL 是靠程序员的手艺和 volatile 来保证最稳妥,那静态内部类方案就是把线程安全重新交给 JVM 的类加载机制。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()时,JVM 才会去加载Holder类。类的加载过程是线程安全的,同一个类在同一个类加载器下只会被加载一次。所以当多个线程同时第一次调用getInstance()时,JVM 保证只有一个线程能完成Holder的加载和初始化,其他线程都会等待并在加载完成后直接读取到同一个INSTANCE。这个方案同时拿到了饿汉式的线程安全和懒汉式的延迟加载,而且代码清晰,没有锁,没有 volatile,没有复杂的并发判断。这算是我个人最喜欢的手写方案,因为你不需要向别人解释太多 volatile、重排序、内存屏障这些概念,它的安全性是由 JVM 类加载机制天然保证的,站得住脚,也禁得起追问。需要注意一点:静态内部类方案是“懒加载”但不是“懒初始化”。所谓懒加载,是指getInstance()被调用之前不会去创建INSTANCE。这个语义和懒汉式是一致的,和饿汉式在“类加载时就创建”有明显区别。2.6 枚举单例:最硬核的防破坏写法枚举单例是《有效Java》中力荐的方案,正确性由 JVM 层面保证,不但线程安全、懒加载(其实不完全准确,枚举实例创建时机是类初始化时),更重要的是它能防御几乎所有外部破坏手段。public enum Singleton { INSTANCE; // 这里可以定义单例的业务方法 public void doSomething() { // 业务逻辑 } }使用方式就是Singleton.INSTANCE.doSomething()。为什么说枚举单例是最硬核的?因为 Java 语言规范对枚举类型做了非常严格的规定:枚举实例的创建是线程安全的,由 JVM 保证。反射攻击无效:Constructor.newInstance()对枚举类型会直接抛出异常,因为枚举不能被反射创建。反序列化安全:枚举类型的序列化由 JVM 特殊处理,反序列化时不会创建新对象,而是返回已存在的枚举实例。这一点是其他所有手写方案都做不到的。前面几节里能反射破坏、序列化破坏的问题,在枚举方案这里直接被语言特性封死。有一个细微的区别要说清楚:严格来说,枚举的INSTANCE是在枚举类被加载时初始化的,不属于真正的“懒加载”。但在实际项目里,枚举类的加载成本几乎可以忽略,而且你也不会为了“绝对懒加载”去拒绝这么安全又简洁的方案。枚举单例唯一的“缺点”可能是写法太另类,有些团队里大家习惯用 class 而不是 enum,代码审查时要解释一阵子。但如果你是技术上追求极致正确性的人,枚举方案就是我建议的首选。3. 单例模式容易忽略的“破坏”路径3.1 反射:能不能绕过私有构造器前面提到的饿汉式、懒汉式、DCL、静态内部类,它们的构造器都是private的,目的是防止外部直接new。但反射可以无视访问权限:Class<?> clazz = Singleton.class; Constructor<?> constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); Singleton instance = (Singleton) constructor.newInstance();这么一搞,就算你不调用getInstance(),也能拿到一个新的实例。单例被破坏得干干净净。防御思路有几种:在私有构造器里加一个标志位,比如private static boolean created;,构造器里判断如果created为 true 就抛异常,第一次创建后置为 true。这样可以拦截后续反射调用,但也引入了状态,代码可读性下降。直接用枚举方案,从语言层免疫反射攻击,最简单也最可靠。3.2 序列化与反序列化:绕过构造器创造新实例这个问题很多老开发都会忽略。如果一个单例类实现了Serializable接口,那么它经过序列化再反序列化之后,会得到一个全新的对象,因为反序列化是通过特殊机制直接创建对象的,并不走构造器。ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.ser")); oos.writeObject(Singleton.getInstance()); oos.close(); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.ser")); Singleton anotherInstance = (Singleton) ois.readObject(); ois.close();如果不对反序列化做任何处理,anotherInstance和原来的单例实例就不是同一个对象。解决办法是在单例类中声明一个readResolve()方法:protected Object readResolve() { return getInstance(); }JVM 在反序列化时会检查是否存在readResolve()方法,如果存在,就调用它,用它的返回值替代默认反序列化出来的对象。这样,反序列化拿到的就会是同一个单例实例。而枚举方案再次体现出优势:Enum本身实现了Serializable,但它的反序列化机制由 JVM 特殊保证,无论怎么反序列化,拿到的都是同一个枚举实例。3.3 类加载器不是同一个,单例就“不单例”了还有一个隐蔽问题是类加载器隔离。Java 的类加载机制是“按需加载 + 双亲委派”,但同一个类如果被不同的类加载器加载,那就是两个不同的 Class 对象,静态字段也是各自独立的,于是会存在多个实例。典型场景在 Web 应用服务器里,比如 Tomcat 中不同 Web 应用使用不同的类加载器加载同一个类,就会各自产生自己的单例。这个一般不算编码 Bug,但在设计跨应用共享的单例时要意识到这个问题:如果你的“单例”要跨越不同的类加载器,那本质上就已经脱离了单例模式的控制范围,需要借助更上层的容器管理方案。4. 并发验证与方案选型实战4.1 不跑一跑代码,我不敢说自己写的线程安全很多人写完了单例模式,没有真正验证过。我自己习惯用一段简单粗暴的并发测试来检验写法是否安全:创建一个固定大小的线程池,让很多线程同时去调用getInstance(),然后收集所有返回实例的哈希码,看看是不是同一个。以 DCL 为例,验证代码长这样:public class SingletonTest { public static void main(String[] args) throws Exception { int threadCount = 100; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); Set<String> identities = ConcurrentHashMap.newKeySet(); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { startLatch.await(); identities.add(System.identityHashCode(Singleton.getInstance()) + ""); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }); } System.out.println("开始并发触发"); startLatch.countDown(); endLatch.await(); executor.shutdown(); System.out.println("不同实例数量:" + identities.size()); if (identities.size() == 1) { System.out.println("恭喜,单例生效,所有线程拿到同一个实例"); } else { System.out.println("糟糕,单例被破坏,多个实例并存"); } } }这段代码的核心思路是用CountDownLatch让 100 个线程同时起跑,最大化并发冲突概率,然后用System.identityHashCode()区分是否是同一个对象。你可以把这段测试代码套到每一种写法上,跑一遍就知道你的“线程安全”是不是真的安全。跑测试的时候注意一个现象:线程不安全的懒汉式,并不是每次都跑失败,可能你连续跑十次都通过了。为什么?因为线程调度太微妙,可能确实没有两个线程同时走到if (instance == null)分支。这也是线程安全类 Bug 最坑的地方——概率性出错,无法稳定复现。4.2 六种写法横向对比,一张表格讲明白写法线程安全懒加载防反射防序列化代码复杂度推荐度饿汉式是否否否最低一般,看场景使用懒汉式(不加锁)否是否否最低不推荐方法级 synchronized是是否否低不推荐但能理解DCL是是否否高面试必写,生产可用静态内部类是是否否低推荐枚举是否是是最低最强的防御力,优先选这个表格基本覆盖了所有决策维度。实际项目里,如果你要求的是“正确性无懈可击”,枚举是唯一能同时防反射和防序列化破坏的写法,这是硬优势。如果你对扩容灵活性要求高,并且明确知道不会被反射或序列化攻击,静态内部类在易读性和懒加载上更胜一筹。4.3 我实际项目里的选择标准拿我自己做过的几个后端项目来说,选型逻辑大概是这样的:如果单例对象很轻量,比如一个简单的工具类、一个常量配置持有者,我直接上饿汉式。不用考虑懒加载,反正应用一启动就得用,省心。如果这个单例对象初始化很重,比如要创建连接池、加载动态配置、初始化一堆缓存,那就必须懒加载。此时我优先选静态内部类,因为代码简单、无锁开销,安全性由 JVM 类加载机制保证。DCL 在这些场景是第二选择,除非团队对并发理解很深,否则没必要去承担 volatile 语义理解错的风险。如果这个单例需要被各种第三方框架扫描、序列化传输,或者我不确定团队成员会不会拿反射去搞事情,就上枚举。虽然看起来不太像“传统单例”,但防御力和简洁度都是所有方案里最好的。5. 单例模式实际操作中的“坑”与排查记录5.1 业务系统里最容易踩的三个坑第一个坑:把有状态的对象做成了单例。单例适合无状态对象,或者全局统一状态的对象。如果这个对象内部有可变业务字段,A 线程改了字段,B 线程读到了新值,就会造成数据串扰。我记得有次某业务系统的状态管理类被做成单例,里面存了当前用户的上下文内容,结果线上出现“一个用户看到另一个用户数据”的严重事故。排查到最后才发现不是权限漏洞,而是单例模式存了线程间共享的可变状态。正确的姿势是:可变状态用线程局部变量(ThreadLocal)隔离,或者直接不用单例。第二个坑:忘记加 volatile 的 DCL。这个坑我在一个分布式调度组件的代码里见到过。当时组里一位同事写了标准的 DCL 但把 volatile 漏了,原因是“本地测试没问题啊”。我让他写了一个 100 线程并发测试,连续跑了几百次,最终复现了拿到半初始化对象导致字段为默认值的场景。类里有个布尔字段默认是 false,但应该被构造器设置成 true,结果就是大量任务被错误地跳过了。第三个坑:在getInstance()里做重活。有人写单例模式时,脑洞大开地把初始化逻辑全部塞进getInstance()方法里,比如读取远程配置、查询数据库、创建外部连接。这种做法会带来两个问题:一是首次调用的线程会长时间占用锁,阻塞其他线程;二是如果这些重活抛出异常,整个单例创建失败,后续又得重新尝试,类加载时序一旦出现问题就不容易排查。正确的做法:单例的初始化尽量放在构造器或静态块中,让创建过程尽可能短平快;真正耗时的操作应该异步化或者明确放在启动阶段完成。5.2 线上问题排查时的思路顺序如果你真遇到了疑似单例被破坏的问题,排错步骤建议按下面顺序走:先打 log,打印每个实例对象的System.identityHashCode,确认是否同一个。检查类加载器是否会存在多个,特别是在 Tomcat、Jetty 这种容器环境里。检查是否被反射调用、是否出现了反序列化场景。检查是否引入了第三方框架做了代理增强(比如动态代理生成子类),导致实际使用了增强后的不同实例。最后再检查并发路径下的状态共享问题,而不是一上来就怀疑创建逻辑。5.3 写代码前先问自己三个问题每次要写单例模式时,我都习惯先问三个问题再动手:这个对象是否需要懒加载?如果不确定,默认选饿汉式或枚举。是否会受到反射、序列化的外部攻击?如果会,就选枚举。团队成员对并发知识的平均水平如何?如果平均水平一般,优先选静态内部类而不是 DCL,降低理解成本。这三个问题问完,方案基本就定下来了,完全不用纠结。6. 面试官为什么一直追问单例模式6.1 一个基础题能看出多少东西行业里手写单例模式几乎成了 Java 技术面试的保留项目,原因很简单:这个题目考察的不是背诵,而是理解深度。你写饿汉式,面试官会问“为什么类加载机制能保证线程安全”;你写 DCL,他会追问“为什么需要 volatile”和“指令重排序是怎么回事”;你写静态内部类,他可能继续问“外部类加载时会不会加载内部类”;你写枚举,他会问“枚举为什么能防止反射和序列化破坏”。你会发现,面试官其实是借一个很小的题目,在考察你对 JVM 类加载机制、Java 内存模型、并发控制手段、序列化机制等多个知识模块的综合掌握程度。手写不出单例模式的人,很多是其背后的原理没有真正吃透。6.2 我面试别人时的参考标准我自己面试后端候选人时,最简单的判断标准:能写出 DCL 并解释清楚 volatile 的,Java 并发基础合格;能写出静态内部类并说出懒加载原理的,对类加载机制有真实理解;能主动提到枚举方案反对反射和序列化破坏的,说明读过经典的实践类书籍,经验面会加分不少。反过来,如果一个人只能背出饿汉式代码,也说不出为什么线程安全,那我基本可以判断他更多是靠刷题而不是靠理解在准备面试。这种差距,在实际项目里遇到非线性问题时就会暴露得很明显。6.3 手写单例之后,还能顺着聊什么单例模式注定不是终点,而是一个入口。写完之后,面试官或项目 Leader 可能顺势问你:Spring 里默认的 Bean 作用域为什么是 singleton?单例 Bean 和手写单例的区别在哪里?Spring 容器里如何解决多例 Bean 的线程安全?如果你把这些都想通了,那就不只是会写单例模式,而是对整个对象生命周期和并发模型都有了系统性的理解。我个人在实际操作中的体会是:单例模式的价值不在于代码本身的繁琐程度,而在于它逼着你去理解 JVM 类加载机制、并发模型、序列化和反射这些底层的“硬货”。面试时手写一个单例模式,远比背一百道面经更有收获。最后再分享一个小技巧:当你对一种写法不确定的时候,想清楚“懒加载”“线程安全”“防破坏路径”这三个维度是什么排序,方案自己就清晰了。多数业务系统里,静态内部类和枚举已经覆盖了 90% 的正确场景,DCL 留给面试现场秀手艺就够了。
“单例模式”这四个字,应该是Java面试里出现频率最高的基础题之一了。几乎每次面试都会让你手写一个单例模式,而一旦问“考虑线程安全”,很多平时写业务代码很溜的人就开始卡壳。线程安全怎么写?加锁?加什么锁?为什么加了锁还不对?今天这篇博文,就把手写单例模式这件事从头到尾撸一遍——从最简单写法到最稳妥写法,从代码到原理,从原理到坑,全给你讲清楚,看完你不仅能默写,还能跟面试官把背后的并发和类加载机制聊明白,而且实际项目里也能选对正确方案。1. 单例模式到底在解决什么问题1.1 先搞清楚“单例”是干什么用的单例模式的核心诉求就一句话:保证一个类在整个应用运行期间只有一个实例。听起来很简单,但对应的现实场景非常多。比如一个全局配置管理器、一个线程池对象、一个数据库连接池、一个日志写入器,这类组件的特点是:本身没有状态或者状态全局唯一,创建和销毁的成本很高,而且多个实例同时存在会导致资源浪费甚至数据冲突。你可以类比成公司里只有一台打印机,所有人都去这台打印机上打印,而不是每人买一台回家。如果每人一台,不仅浪费,更麻烦的是各部门看到的打印版本可能还不一致——对应到程序里,就是多个实例维护了各自的内部状态,互相覆盖,Bug 离奇得让你怀疑人生。所以单例模式有几个不可替代的价值:实例唯一性:整个应用只存在一个对象,状态可以被全局共享。资源可控性:避免频繁创建和销毁大对象,节省内存和 CPU。一致性保证:所有调用方访问的是同一个对象,看到的内部数据不会因实例不同而产生偏差。1.2 线程安全问题的根源在哪里理解了单例的价值,接下来就是关键问题:为什么会有线程安全问题?如果你写的是最简单版本的懒汉式:public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这段代码在单线程环境下完全没问题,但在多线程环境下就会出乱子。假设线程 A 和线程 B 同时执行到if (instance == null)这一行,此刻实例还都是 null,于是两个线程都会进入 if 分支,各自执行new Singleton()。结果就是内存里出现了两个不同的实例,线程 A 拿到 A 对象,线程 B 拿到 B 对象,单例被破坏了。更隐蔽的问题发生在创建对象的过程本身。new Singleton()在字节码层面并不是一个原子操作,它大致要经历三步:分配内存空间。调用构造器初始化对象。把引用指向这块内存。如果发生了指令重排序,2 和 3 的顺序可能互换,也就是说引用先指向了内存,但对象还没初始化完。此时另一个线程看到引用不为 null,直接拿去使用,就会拿到一个“半初始化”的对象——这在并发环境里是相当致命的问题。所以手写一个线程安全的单例,本质上是在解决两件事:一是保证实例只会被创建一次,二是保证创建过程对其他线程是“安全可见”的。2. 六种手写方案的完整拆解2.1 饿汉式:线程安全的基础写法饿汉式的思路很简单:在类加载阶段就把实例创建好,利用 JVM 类加载机制保证“一个类只会被加载一次”这个天然特性来实现线程安全。public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }为什么它线程安全?因为INSTANCE是静态字段,在类加载的初始化阶段就会被赋值,而类的初始化过程由 JVM 保证只会执行一次,并且其他线程在类被初始化完成之前是无法访问这个类的静态字段的。也就是说,JVM 帮你把并发问题挡在了门外。饿汉式的优点很突出:写法极简单,不需要加锁,没有任何并发复杂度。线程安全由类加载机制天然保证,可信度高。没有锁竞争,getInstance() 调用性能极高。缺点同样明显:实例在类加载时就创建,如果这个单例对象非常“重”,比如初始化时要读取大量配置文件、建立一堆连接池,而应用启动后根本没用上它,那这部分资源就白白浪费了。这叫作“饿汉式”的原因就在这里——不管吃不吃,先端上桌。适用场景:单例对象创建开销可接受,或者应用启动后本来就一定会用到它的场景。注意:INSTANCE必须声明为final,这样既保证了不可变语义,也避免写代码时不小心重新赋值覆盖掉单例对象。2.2 懒汉式(不加锁):只能看不能用的写法懒汉式的初衷是解决饿汉式的资源浪费问题——我什么时候调用getInstance(),什么时候才创建实例。public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }这种写法从概念上讲是对的,但线程安全问题非常明显。多个线程同时进入if (instance == null)时,都会创建新的实例,前面已经分析过,这里不再重复。那这种写法是不是完全没有价值?也不是,它把“懒加载”的核心思想表达得很清楚,代码可读性也最高。如果你只是写一个单线程工具类,或者某个死都不会被并发访问的场景,它能跑。但核心问题是:只要有一天需求变了,这个类被多个线程同时调用,Bug 就会以非常低的概率间歇性出现——这是最让人头疼的类型,因为测试环境可能十次都跑不出问题,一到线上高并发就崩。所以我的建议是:面试时你可以先写这版,解释“它线程不安全,因为存在竞态条件”,然后再引出后面的优化方案。但生产环境不要用。2.3 方法级 synchronized 懒汉式:安全但效率拉胯既然不加锁不行,那最简单的加锁方式就是在方法上加上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 != null,直接返回已有对象。线程安全是保证了。但问题在于锁的粒度太大了。每次调用getInstance()(即使实例早就创建好了)都要经过一次加锁、锁竞争、释放锁的完整流程。单例对象创建完成之后,剩下的所有调用都是纯读取操作,完全不需要互斥,但你仍然让所有线程在锁上排队。我在一个项目里真实遇到过类似的场景:一个工具类单例被一个高并发的热点接口频繁调用,每秒几万次请求,因为加了方法级同步锁,接口吞吐率直接腰斩,压测数据惨不忍睹。这个方法能不能用?能,前提是你的单例访问频率极低,锁竞争不会成为瓶颈。否则,你需要更细粒度的方案。2.4 双重检查锁(DCL):面试常考点,也是生产常用点双重检查锁(Double-Checked Locking,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; } }拆开来看,它的设计很精妙:第一层if (instance == null)是在没有锁的情况下做判断。如果实例已经创建好了,直接返回,不需要进入同步块,避免锁竞争,保证高频读取场景的性能。第二层synchronized包住的代码块,只有在实例尚未创建时才会执行。同一个时刻只允许一个线程进入同步块执行创建逻辑。第三层if (instance == null)是为了防止“多个线程连续获得锁”导致重复创建。假设线程 A 先获得锁并创建了实例,释放锁后线程 B 进入同步块,如果这里不做第二次检查,线程 B 会再次创建新实例,单例直接失效。那volatile在这里干什么?这才是 DCL 的灵魂。instance = new Singleton()这个赋值操作不是原子操作,前面提过它包含三步:分配内存、初始化对象、引用指向内存。如果 JVM 对这三步做了指令重排序,把“引用指向内存”排到了“初始化对象”前面,那么另一个线程在第一次if判断时就会看到instance != null,于是直接返回这个“半初始化”的对象,后续使用这个对象里的字段时就会拿到默认值甚至触发空指针异常。volatile关键字在这里发挥两大作用:可见性:对instance的写入操作对其他线程立即可见,其他线程不会读到过期的 null。禁止指令重排序:volatile会插入内存屏障,保证new Singleton()的三步操作不会被重排,防止“半初始化”对象被其他线程看到。经验之谈:DCL 里volatile不是一个可选项,而是必选项。面试时如果把volatile去掉,表面上看代码好像也能跑,并发测试偶尔也能过,但理论上就是存在隐患。这种“偶尔能过”的Bug最可怕,因为你不知道什么情况下会触发,排查起来非常痛苦。DCL 的优点:懒加载,线程安全,且首次创建之后读取无锁竞争,性能优秀。缺点:代码略复杂,需要理解 volatile 和重排序才能完全吃透。2.5 静态内部类:懒加载与线程安全兼得的优雅方案如果说 DCL 是靠程序员的手艺和 volatile 来保证最稳妥,那静态内部类方案就是把线程安全重新交给 JVM 的类加载机制。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()时,JVM 才会去加载Holder类。类的加载过程是线程安全的,同一个类在同一个类加载器下只会被加载一次。所以当多个线程同时第一次调用getInstance()时,JVM 保证只有一个线程能完成Holder的加载和初始化,其他线程都会等待并在加载完成后直接读取到同一个INSTANCE。这个方案同时拿到了饿汉式的线程安全和懒汉式的延迟加载,而且代码清晰,没有锁,没有 volatile,没有复杂的并发判断。这算是我个人最喜欢的手写方案,因为你不需要向别人解释太多 volatile、重排序、内存屏障这些概念,它的安全性是由 JVM 类加载机制天然保证的,站得住脚,也禁得起追问。需要注意一点:静态内部类方案是“懒加载”但不是“懒初始化”。所谓懒加载,是指getInstance()被调用之前不会去创建INSTANCE。这个语义和懒汉式是一致的,和饿汉式在“类加载时就创建”有明显区别。2.6 枚举单例:最硬核的防破坏写法枚举单例是《有效Java》中力荐的方案,正确性由 JVM 层面保证,不但线程安全、懒加载(其实不完全准确,枚举实例创建时机是类初始化时),更重要的是它能防御几乎所有外部破坏手段。public enum Singleton { INSTANCE; // 这里可以定义单例的业务方法 public void doSomething() { // 业务逻辑 } }使用方式就是Singleton.INSTANCE.doSomething()。为什么说枚举单例是最硬核的?因为 Java 语言规范对枚举类型做了非常严格的规定:枚举实例的创建是线程安全的,由 JVM 保证。反射攻击无效:Constructor.newInstance()对枚举类型会直接抛出异常,因为枚举不能被反射创建。反序列化安全:枚举类型的序列化由 JVM 特殊处理,反序列化时不会创建新对象,而是返回已存在的枚举实例。这一点是其他所有手写方案都做不到的。前面几节里能反射破坏、序列化破坏的问题,在枚举方案这里直接被语言特性封死。有一个细微的区别要说清楚:严格来说,枚举的INSTANCE是在枚举类被加载时初始化的,不属于真正的“懒加载”。但在实际项目里,枚举类的加载成本几乎可以忽略,而且你也不会为了“绝对懒加载”去拒绝这么安全又简洁的方案。枚举单例唯一的“缺点”可能是写法太另类,有些团队里大家习惯用 class 而不是 enum,代码审查时要解释一阵子。但如果你是技术上追求极致正确性的人,枚举方案就是我建议的首选。3. 单例模式容易忽略的“破坏”路径3.1 反射:能不能绕过私有构造器前面提到的饿汉式、懒汉式、DCL、静态内部类,它们的构造器都是private的,目的是防止外部直接new。但反射可以无视访问权限:Class<?> clazz = Singleton.class; Constructor<?> constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); Singleton instance = (Singleton) constructor.newInstance();这么一搞,就算你不调用getInstance(),也能拿到一个新的实例。单例被破坏得干干净净。防御思路有几种:在私有构造器里加一个标志位,比如private static boolean created;,构造器里判断如果created为 true 就抛异常,第一次创建后置为 true。这样可以拦截后续反射调用,但也引入了状态,代码可读性下降。直接用枚举方案,从语言层免疫反射攻击,最简单也最可靠。3.2 序列化与反序列化:绕过构造器创造新实例这个问题很多老开发都会忽略。如果一个单例类实现了Serializable接口,那么它经过序列化再反序列化之后,会得到一个全新的对象,因为反序列化是通过特殊机制直接创建对象的,并不走构造器。ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.ser")); oos.writeObject(Singleton.getInstance()); oos.close(); ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.ser")); Singleton anotherInstance = (Singleton) ois.readObject(); ois.close();如果不对反序列化做任何处理,anotherInstance和原来的单例实例就不是同一个对象。解决办法是在单例类中声明一个readResolve()方法:protected Object readResolve() { return getInstance(); }JVM 在反序列化时会检查是否存在readResolve()方法,如果存在,就调用它,用它的返回值替代默认反序列化出来的对象。这样,反序列化拿到的就会是同一个单例实例。而枚举方案再次体现出优势:Enum本身实现了Serializable,但它的反序列化机制由 JVM 特殊保证,无论怎么反序列化,拿到的都是同一个枚举实例。3.3 类加载器不是同一个,单例就“不单例”了还有一个隐蔽问题是类加载器隔离。Java 的类加载机制是“按需加载 + 双亲委派”,但同一个类如果被不同的类加载器加载,那就是两个不同的 Class 对象,静态字段也是各自独立的,于是会存在多个实例。典型场景在 Web 应用服务器里,比如 Tomcat 中不同 Web 应用使用不同的类加载器加载同一个类,就会各自产生自己的单例。这个一般不算编码 Bug,但在设计跨应用共享的单例时要意识到这个问题:如果你的“单例”要跨越不同的类加载器,那本质上就已经脱离了单例模式的控制范围,需要借助更上层的容器管理方案。4. 并发验证与方案选型实战4.1 不跑一跑代码,我不敢说自己写的线程安全很多人写完了单例模式,没有真正验证过。我自己习惯用一段简单粗暴的并发测试来检验写法是否安全:创建一个固定大小的线程池,让很多线程同时去调用getInstance(),然后收集所有返回实例的哈希码,看看是不是同一个。以 DCL 为例,验证代码长这样:public class SingletonTest { public static void main(String[] args) throws Exception { int threadCount = 100; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch endLatch = new CountDownLatch(threadCount); Set<String> identities = ConcurrentHashMap.newKeySet(); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { startLatch.await(); identities.add(System.identityHashCode(Singleton.getInstance()) + ""); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }); } System.out.println("开始并发触发"); startLatch.countDown(); endLatch.await(); executor.shutdown(); System.out.println("不同实例数量:" + identities.size()); if (identities.size() == 1) { System.out.println("恭喜,单例生效,所有线程拿到同一个实例"); } else { System.out.println("糟糕,单例被破坏,多个实例并存"); } } }这段代码的核心思路是用CountDownLatch让 100 个线程同时起跑,最大化并发冲突概率,然后用System.identityHashCode()区分是否是同一个对象。你可以把这段测试代码套到每一种写法上,跑一遍就知道你的“线程安全”是不是真的安全。跑测试的时候注意一个现象:线程不安全的懒汉式,并不是每次都跑失败,可能你连续跑十次都通过了。为什么?因为线程调度太微妙,可能确实没有两个线程同时走到if (instance == null)分支。这也是线程安全类 Bug 最坑的地方——概率性出错,无法稳定复现。4.2 六种写法横向对比,一张表格讲明白写法线程安全懒加载防反射防序列化代码复杂度推荐度饿汉式是否否否最低一般,看场景使用懒汉式(不加锁)否是否否最低不推荐方法级 synchronized是是否否低不推荐但能理解DCL是是否否高面试必写,生产可用静态内部类是是否否低推荐枚举是否是是最低最强的防御力,优先选这个表格基本覆盖了所有决策维度。实际项目里,如果你要求的是“正确性无懈可击”,枚举是唯一能同时防反射和防序列化破坏的写法,这是硬优势。如果你对扩容灵活性要求高,并且明确知道不会被反射或序列化攻击,静态内部类在易读性和懒加载上更胜一筹。4.3 我实际项目里的选择标准拿我自己做过的几个后端项目来说,选型逻辑大概是这样的:如果单例对象很轻量,比如一个简单的工具类、一个常量配置持有者,我直接上饿汉式。不用考虑懒加载,反正应用一启动就得用,省心。如果这个单例对象初始化很重,比如要创建连接池、加载动态配置、初始化一堆缓存,那就必须懒加载。此时我优先选静态内部类,因为代码简单、无锁开销,安全性由 JVM 类加载机制保证。DCL 在这些场景是第二选择,除非团队对并发理解很深,否则没必要去承担 volatile 语义理解错的风险。如果这个单例需要被各种第三方框架扫描、序列化传输,或者我不确定团队成员会不会拿反射去搞事情,就上枚举。虽然看起来不太像“传统单例”,但防御力和简洁度都是所有方案里最好的。5. 单例模式实际操作中的“坑”与排查记录5.1 业务系统里最容易踩的三个坑第一个坑:把有状态的对象做成了单例。单例适合无状态对象,或者全局统一状态的对象。如果这个对象内部有可变业务字段,A 线程改了字段,B 线程读到了新值,就会造成数据串扰。我记得有次某业务系统的状态管理类被做成单例,里面存了当前用户的上下文内容,结果线上出现“一个用户看到另一个用户数据”的严重事故。排查到最后才发现不是权限漏洞,而是单例模式存了线程间共享的可变状态。正确的姿势是:可变状态用线程局部变量(ThreadLocal)隔离,或者直接不用单例。第二个坑:忘记加 volatile 的 DCL。这个坑我在一个分布式调度组件的代码里见到过。当时组里一位同事写了标准的 DCL 但把 volatile 漏了,原因是“本地测试没问题啊”。我让他写了一个 100 线程并发测试,连续跑了几百次,最终复现了拿到半初始化对象导致字段为默认值的场景。类里有个布尔字段默认是 false,但应该被构造器设置成 true,结果就是大量任务被错误地跳过了。第三个坑:在getInstance()里做重活。有人写单例模式时,脑洞大开地把初始化逻辑全部塞进getInstance()方法里,比如读取远程配置、查询数据库、创建外部连接。这种做法会带来两个问题:一是首次调用的线程会长时间占用锁,阻塞其他线程;二是如果这些重活抛出异常,整个单例创建失败,后续又得重新尝试,类加载时序一旦出现问题就不容易排查。正确的做法:单例的初始化尽量放在构造器或静态块中,让创建过程尽可能短平快;真正耗时的操作应该异步化或者明确放在启动阶段完成。5.2 线上问题排查时的思路顺序如果你真遇到了疑似单例被破坏的问题,排错步骤建议按下面顺序走:先打 log,打印每个实例对象的System.identityHashCode,确认是否同一个。检查类加载器是否会存在多个,特别是在 Tomcat、Jetty 这种容器环境里。检查是否被反射调用、是否出现了反序列化场景。检查是否引入了第三方框架做了代理增强(比如动态代理生成子类),导致实际使用了增强后的不同实例。最后再检查并发路径下的状态共享问题,而不是一上来就怀疑创建逻辑。5.3 写代码前先问自己三个问题每次要写单例模式时,我都习惯先问三个问题再动手:这个对象是否需要懒加载?如果不确定,默认选饿汉式或枚举。是否会受到反射、序列化的外部攻击?如果会,就选枚举。团队成员对并发知识的平均水平如何?如果平均水平一般,优先选静态内部类而不是 DCL,降低理解成本。这三个问题问完,方案基本就定下来了,完全不用纠结。6. 面试官为什么一直追问单例模式6.1 一个基础题能看出多少东西行业里手写单例模式几乎成了 Java 技术面试的保留项目,原因很简单:这个题目考察的不是背诵,而是理解深度。你写饿汉式,面试官会问“为什么类加载机制能保证线程安全”;你写 DCL,他会追问“为什么需要 volatile”和“指令重排序是怎么回事”;你写静态内部类,他可能继续问“外部类加载时会不会加载内部类”;你写枚举,他会问“枚举为什么能防止反射和序列化破坏”。你会发现,面试官其实是借一个很小的题目,在考察你对 JVM 类加载机制、Java 内存模型、并发控制手段、序列化机制等多个知识模块的综合掌握程度。手写不出单例模式的人,很多是其背后的原理没有真正吃透。6.2 我面试别人时的参考标准我自己面试后端候选人时,最简单的判断标准:能写出 DCL 并解释清楚 volatile 的,Java 并发基础合格;能写出静态内部类并说出懒加载原理的,对类加载机制有真实理解;能主动提到枚举方案反对反射和序列化破坏的,说明读过经典的实践类书籍,经验面会加分不少。反过来,如果一个人只能背出饿汉式代码,也说不出为什么线程安全,那我基本可以判断他更多是靠刷题而不是靠理解在准备面试。这种差距,在实际项目里遇到非线性问题时就会暴露得很明显。6.3 手写单例之后,还能顺着聊什么单例模式注定不是终点,而是一个入口。写完之后,面试官或项目 Leader 可能顺势问你:Spring 里默认的 Bean 作用域为什么是 singleton?单例 Bean 和手写单例的区别在哪里?Spring 容器里如何解决多例 Bean 的线程安全?如果你把这些都想通了,那就不只是会写单例模式,而是对整个对象生命周期和并发模型都有了系统性的理解。我个人在实际操作中的体会是:单例模式的价值不在于代码本身的繁琐程度,而在于它逼着你去理解 JVM 类加载机制、并发模型、序列化和反射这些底层的“硬货”。面试时手写一个单例模式,远比背一百道面经更有收获。最后再分享一个小技巧:当你对一种写法不确定的时候,想清楚“懒加载”“线程安全”“防破坏路径”这三个维度是什么排序,方案自己就清晰了。多数业务系统里,静态内部类和枚举已经覆盖了 90% 的正确场景,DCL 留给面试现场秀手艺就够了。