饿汉式和懒汉式单例有什么区别?
简化版
饿汉式在类初始化阶段创建实例,实现简单且天然利用类初始化的线程安全;懒汉式在首次访问时才创建,能延迟成本,但必须正确处理并发发布。选择依据是创建成本、失败处理和是否一定会使用,而不是盲目追求懒加载。
详细版
饿汉式通常写成静态字段直接初始化:
private static final Service INSTANCE = new Service();
它的优点是代码少、线程安全、获取路径无额外同步;代价是类初始化时就占用创建成本,即使最终没有使用实例也会创建。
最简单的懒汉式是在 getInstance() 中判断 null 后创建,但这种写法在并发环境下可能创建多个对象。要安全延迟初始化,可以使用同步方法、双重检查锁或静态内部类。
如果实例轻量、必然使用且初始化不易失败,优先考虑饿汉式;如果实例昂贵、依赖在稍后才具备或可能永远不用,再选择正确实现的懒加载。
完整版教学
一、区别在于初始化时机
“饿”和“懒”描述的是何时创建对象:
- 饿汉式:所属类初始化时创建;
- 懒汉式:第一次真正请求实例时创建。
public final class EagerService {
private static final EagerService INSTANCE = new EagerService();
private EagerService() {}
public static EagerService getInstance() { return INSTANCE; }
}
Java 虚拟机负责同步类初始化,因此静态字段初始化完成后,其他线程才能正常观察到初始化结果。这里的线程安全来自语言和虚拟机的类初始化协议。
二、朴素懒汉式的问题
public static LazyService getInstance() {
if (instance == null) {
instance = new LazyService();
}
return instance;
}
两个线程可能同时发现 instance == null,随后各自创建对象。即使最后静态字段只保留一个引用,也已经产生过多个实例,构造过程中的注册、文件创建等副作用还可能执行多次。
三、两者的工程权衡
| 维度 | 饿汉式 | 正确实现的懒汉式 |
|---|---|---|
| 创建时机 | 类初始化阶段 | 首次获取时 |
| 实现复杂度 | 低 | 取决于同步方案 |
| 首次访问延迟 | 较低 | 需要承担创建成本 |
| 不使用时的成本 | 仍会创建 | 可以避免 |
| 初始化失败时机 | 较早暴露 | 首次访问时暴露 |
饿汉式也不是“程序启动就一定创建”。只有类发生主动使用并进入初始化阶段时,静态字段初始化才执行;类仅被加载或验证,并不必然已经初始化。
四、选择时关注真实成本
很多对象的创建成本很小,强行使用复杂的懒加载只会增加代码风险。只有在对象创建昂贵、依赖外部资源、使用概率低,或者需要推迟到某些配置就绪之后时,懒加载才具有明确收益。
如果对象初始化需要 I/O,还应考虑失败重试、超时和资源关闭。单例模式本身不会替你解决这些生命周期问题。
五、把“类初始化”和“首次业务调用”放到时间线上
饿汉式并不等于 JVM 启动瞬间创建,它发生在所属类首次主动使用并进入初始化时;懒汉式把创建进一步推迟到获取方法第一次真正需要实例时。两者的差异最终落在成本由谁承担、失败何时暴露以及并发控制由谁完成。
t0: JVM 启动,Service 尚未主动使用
t1: 读取 EagerService.INSTANCE -> 类初始化并创建
t2: 后续获取仅返回现有引用
另一方案 t1: 仅加载 LazyService,不创建对象
t2: 首次 getInstance() -> 执行并发初始化协议
t3: 首次调用承担 120 ms 假设创建延迟
若从未调用,懒汉式避免这 120 ms 和资源占用
这条时序必须同时检查“谁负责创建”和“其他线程或调用方从哪里取得实例”。只看到最终引用相同还不够;若构造副作用执行了两次、发布了半初始化状态,或者作用域边界之外又创建了一份,就已经违反了本题讨论的保证。
六、保证范围与失效边界
| 评审维度 | 本题结论 |
|---|---|
| 唯一性/正确性保证 | 饿汉式依赖类初始化协议;懒汉式只有采用正确同步方案才保证一次创建与安全发布。 |
| 作用域边界 | 初始化时机位于单个类加载边界内,不决定跨进程唯一性。 |
| 仍未解决的问题 | 昂贵 I/O 放进静态初始化可能导致类初始化失败难重试;错误懒加载会产生竞态。 |
| 更合适的替代方案 | 轻量且必用选饿汉;昂贵且可能不用选静态内部类、容器或显式可重试初始化器。 |
设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。
七、工程落地时的验证方法
- 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
- 比较引用身份时使用
==,不要让重写后的equals()掩盖实际存在的多个对象。 - 若涉及类加载器,记录
instance.getClass().getClassLoader(),同时比较类对象和加载器身份。 - 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
- 若涉及 Spring,分别检查 Bean 名称、
ApplicationContext身份和作用域,而不只检查 Java 类名。 - 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
- 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。
记忆钩子:饿汉省并发心智,懒汉省未使用成本;先量化成本,再决定是否值得延迟。
八、常见误区与追问
- 误区:饿汉式一定在应用进程启动时创建。 只有类发生主动使用并初始化时才执行静态字段初始化。
- 误区:懒汉式天然更省内存。 实例一旦创建占用相同,收益只存在于可能永不使用或推迟占用的场景。
- 误区:懒加载只写 null 判断就够了。 并发线程可能同时观察 null 并各自构造。
- 追问:初始化依赖网络且允许重试怎么办? 类初始化失败语义不灵活,应使用显式工厂、Future 或容器生命周期管理。
- 追问:首次访问延迟如何处理? 可在明确生命周期阶段预热,或接受延迟并配置超时与监控。
- 追问:饿汉式的 final 字段有什么价值? 静态 final 引用初始化后不可重新指向其他实例,进一步收紧赋值入口。
九、加强记忆
饿汉式用更早的创建换简单可靠,懒汉式用更复杂的并发控制换延迟成本。面试中先说初始化时机,再比较线程安全、首次延迟和失败时机,最后根据对象是否昂贵且是否必用作选择。