真实面经题目 · 原创解析

单例模式如何实现,如何保证线程安全?

单例模式的核心目标是让一个类在进程内只暴露一个可访问实例,并控制实例创建时机。回答时应从构造器私有化、全局访问点、线程安全发布、延迟加载、反射和序列化破坏边界几个维度展开。常见实现包括饿汉式、同步懒汉式、双重检查锁、静态内部类和枚举单例,其中推荐优先说明静态内部类和枚举单例,再解释为什么双重检查锁必须配合 volatile。

出现于:阿里巴巴 · 后端开发

60 秒回答模板

可以这样回答:单例模式通常通过私有构造器阻止外部 new,通过静态方法或枚举常量提供全局访问点。实现上有饿汉式、懒汉式、双重检查锁、静态内部类和枚举单例。饿汉式类加载时创建实例,天然线程安全但不支持延迟加载;普通懒汉式如果不加锁会有并发创建多个实例的问题;同步方法懒汉式安全但每次访问都加锁,性能较差;双重检查锁只在实例为空时进入同步块,但实例字段必须加 volatile,防止对象创建过程中的指令重排导致其他线程拿到未初始化完成的对象。静态内部类利用类加载机制实现延迟加载和线程安全,是工程里很常见的写法。枚举单例最强健,天然防反射和序列化破坏,适合无继承需求、初始化逻辑清晰的场景。还要补充,单例不只是线程安全创建,还要考虑反射调用私有构造器、反序列化重新生成对象、克隆复制对象、类加载器隔离等边界。

考点 单例模式的本质
主线 饿汉式实现
易错点 把 static 对象等同于单例,忽略私有构造器和受控…

深入解析

01

单例模式的本质

单例模式不是简单地把变量设成 static,而是把对象创建权收回到类内部,保证外部只能通过受控入口获取同一个实例。典型结构包括私有构造器、私有静态实例字段、公开静态访问方法。线程安全的关键不只在于“只创建一次”,还在于实例发布后其他线程看到的是完整初始化后的对象。如果构造过程涉及配置、连接池、缓存、权限校验器等共享资源,错误的单例实现会带来重复初始化、状态覆盖、半初始化对象暴露等问题。

02

饿汉式实现

饿汉式是在类加载阶段就创建实例,例如静态字段直接初始化。它依赖 JVM 类初始化过程的线程安全保证,多个线程同时访问时不会创建多个对象,因此实现简单、可读性好、没有运行期锁开销。缺点是实例创建时间较早,只要类被加载就会初始化对象,如果对象很重、依赖外部资源、未必会被使用,就可能造成启动成本和资源浪费。它适合轻量、必然使用、初始化失败风险低的全局组件。

03

懒汉式与同步

懒汉式强调延迟加载,即第一次调用获取方法时才创建实例。最朴素的写法会先判断实例是否为空,为空就创建,但在多线程下两个线程可能同时看到空值并各自创建对象,破坏单例。给整个获取方法加 synchronized 可以保证安全,因为同一时间只有一个线程进入创建逻辑,但代价是实例创建完成后,每次读取仍然要竞争锁。对于高频访问的全局服务,这种锁成本通常没有必要。

04

双重检查锁

双重检查锁的思路是先在同步块外判断实例是否为空,只有为空时才进入 synchronized;进入同步块后再次判断,防止多个线程排队进入后重复创建。这样实例创建完成后,大多数调用只做一次空判断,不再加锁。它的问题在于对象创建不是单一原子动作,通常包含分配内存、初始化对象、把引用赋给变量等步骤。如果没有额外内存语义,其他线程可能看到一个非空但尚未初始化完成的引用。

05

volatile 的作用

双重检查锁中的实例字段必须使用 volatile。volatile 一方面保证可见性,让一个线程写入实例引用后,其他线程能及时看到;另一方面提供禁止特定指令重排的语义,避免引用赋值先于构造初始化对外可见。没有 volatile 时,第二个线程可能绕过同步块,直接返回一个内部字段尚未初始化完成的对象。这里的重点不是 synchronized 不安全,而是同步块外的第一次读取没有锁保护,必须靠 volatile 补足发布安全。

06

静态内部类

静态内部类是 Java 中很优雅的懒加载单例方案。外部类加载时不会立即初始化内部 Holder 类,只有第一次调用获取实例的方法、真正引用 Holder 中的静态字段时,内部类才会被加载和初始化。类初始化由 JVM 保证线程安全,因此它同时具备延迟加载、无显式锁、实现简洁等优点。它适合大多数普通单例场景,尤其是实例创建逻辑不需要复杂参数、生命周期跟随类加载器即可的组件。

07

枚举单例

枚举单例通常被认为是最强健的单例实现方式。枚举实例由 JVM 管理,天然只会初始化一次,并且语言层面对反射创建枚举对象有额外限制;序列化和反序列化枚举时也不会生成新的实例,而是回到同一个枚举常量。它的限制是形式不够灵活,例如不能继承其他类,构造参数和初始化流程需要符合枚举模型。对于无继承需求、需要防御反射和序列化破坏的场景,枚举单例非常适合。

08

破坏边界

成熟回答还要说明单例边界。私有构造器可以被反射强行访问,因此普通单例可以在构造器中检测已有实例并抛异常,但这仍不是绝对防线。实现 Serializable 的单例如果不定义 readResolve,反序列化可能产生新对象。实现 Cloneable 或暴露复制方法也可能破坏唯一性。此外,不同类加载器可以各自加载同一个类并形成多个单例实例,所以严格意义上的单例通常是“同一个类加载器命名空间内”的单例。

易错点

  • 把 static 对象等同于单例,忽略私有构造器和受控访问入口。
  • 写双重检查锁但忘记给实例字段加 volatile,导致安全发布不成立。
  • 只讨论 synchronized,没解释可见性、指令重排和半初始化对象问题。
  • 认为私有构造器可以绝对防止外部创建,忽略反射破坏。
  • 实现了 Serializable 却没有考虑 readResolve,反序列化后可能得到新对象。
  • 把所有场景都推荐枚举单例,忽略枚举不能继承其他类、初始化模型不够灵活等限制。

面试官追问

为什么普通懒汉式在多线程下不安全?

因为判断实例为空和创建实例不是一个不可分割的原子操作。两个线程可能同时通过空判断,然后分别执行创建逻辑,最终得到多个对象。即使最后静态字段只保存其中一个实例,另一个实例也已经被创建并可能产生副作用。

为什么双重检查锁要检查两次?

第一次检查是为了避免实例创建后每次调用都进入同步块,降低锁开销。第二次检查是因为多个线程可能同时通过第一次空判断并在锁外等待,拿到锁之后必须再次确认实例是否仍为空,否则会重复创建对象。

volatile 在单例里到底解决什么问题?

它解决两个核心问题:可见性和有序性。一个线程创建并赋值实例后,其他线程能看到最新引用;同时避免对象引用先赋值、构造初始化后完成的重排场景,从而防止返回半初始化对象。

静态内部类为什么能保证线程安全?

因为静态内部类的静态字段会在内部类初始化阶段创建,而类初始化过程由 JVM 加锁保证同一类加载器内只执行一次。外部类加载不会触发内部类初始化,所以它还能自然实现延迟加载。

枚举单例为什么更难被破坏?

枚举对象的创建由 JVM 和语言规范管理,反射不能像普通类那样随意构造新的枚举实例;枚举的序列化机制也按枚举常量名称恢复对象,不会调用普通反序列化路径生成新实例,因此边界更强。

单例一定是全局唯一吗?

通常只能说在同一个 JVM、同一个类加载器命名空间内唯一。如果应用存在多个类加载器,同一个类可能被加载多份,每份都有自己的静态字段和单例对象。分布式系统中更不能把本地单例理解成集群唯一。