面试专题

Java 如何实现单例模式?

从饿汉式、静态内部类、双重检查锁和枚举四种实现出发,讲清 Java 单例模式的线程安全、懒加载、安全发布及常见误区。

难度:入门更新:2026-08-02

Java 如何实现单例模式?

面试回答

单例模式用于保证一个类在指定作用域内只有一个实例,并提供统一的访问入口。Java 中常见实现包括饿汉式、静态内部类、双重检查锁和枚举。

饿汉式依靠 JVM 的类初始化机制保证线程安全,实现简单,但不能按需加载;静态内部类同时具备线程安全和懒加载,通常是普通业务代码中的推荐实现;双重检查锁适合需要显式控制懒加载过程的场景,但实例引用必须使用 volatile,防止对象发布与初始化发生不安全的重排序;枚举写法最简洁,还能天然抵御常规反射和反序列化破坏,是实现严格单例的可靠方式。

需要注意,单例的唯一性通常只限定在同一个 ClassLoader 中。分布式系统中的“全局唯一实例”不能只靠 Java 单例模式实现。

一句话总结:

普通懒加载优先使用静态内部类,强调防反射和反序列化时使用枚举;使用双重检查锁时,实例引用必须声明为 volatile。

一张图看懂

Java 单例模式四种常见实现及选择方式

详细讲解

一、什么是单例模式

单例模式主要解决两个问题:

  1. 限制对象创建,保证一个类在指定作用域内只有一个实例;
  2. 提供统一入口,让其他代码能够获取这个实例。

一个典型的单例类通常包含:

私有构造方法
+ 类内部持有唯一实例
+ 对外提供获取实例的方法

例如配置管理器、无状态工具服务或共享资源协调器,可能只需要一个实例。单例并不等于全局变量,它还负责控制对象的创建过程。

二、饿汉式

饿汉式在类初始化阶段直接创建对象:

public final class Singleton {

    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

它的线程安全来自 JVM 的类初始化机制:一个类的初始化过程由 JVM 加锁并协调,同一个 ClassLoader 下只会执行一次类初始化方法。

优点:

  • 实现简单;
  • 天然线程安全;
  • 实例通过类初始化安全发布,不需要额外加锁。

缺点:

  • 类被初始化时就会创建实例;
  • 如果实例创建成本较高且最终没有使用,会提前占用资源;
  • 构造失败会导致类初始化失败。

适合对象创建成本不高、启动时即可创建的场景。

三、静态内部类

静态内部类可以同时实现线程安全与懒加载:

public final class Singleton {

    private Singleton() {
    }

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

加载外部类 Singleton 时,不会立即初始化 Holder。第一次调用 getInstance() 并访问 Holder.INSTANCE 时,JVM 才初始化 Holder 并创建实例。

加载 Singleton
    ↓
尚未初始化 Holder,不创建实例
    ↓
第一次调用 getInstance()
    ↓
JVM 初始化 Holder
    ↓
创建并安全发布 INSTANCE

这种写法不需要显式同步,读取实例也没有锁竞争,通常是普通业务代码中实现懒加载单例的优先选择。

四、双重检查锁

双重检查锁也称 DCL,即 Double-Checked Locking:

public final 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;
    }
}

1. 为什么检查两次

第一次检查发生在同步块外:

if (result == null)

实例已经创建后,绝大多数调用可以直接返回,不必再竞争锁。

第二次检查发生在同步块内:

synchronized (Singleton.class) {
    result = instance;
    if (result == null) {
        // 创建实例
    }
}

假设线程 A 和线程 B 同时通过第一次检查,线程 A 先获得锁并创建对象。线程 B 随后获得锁时,必须再次检查,否则还会创建第二个对象。

2. 为什么必须使用 volatile

创建对象可以概念性地分为:

1. 分配内存
2. 初始化对象
3. 将引用赋给 instance

如果没有适当的内存语义约束,其他线程可能观察到非空引用,却没有可靠地观察到对象完整初始化后的状态。

private static volatile Singleton instance;

volatile 在这里有两个关键作用:

  • 禁止对象初始化与引用发布之间发生不安全的重排序;
  • 建立 volatile 写与后续 volatile 读之间的 happens-before 关系,使初始化结果对读取线程可见。

五、枚举单例

枚举是更简洁、约束更强的单例实现:

public enum Singleton {
    INSTANCE("default-service", 3000);

    private final String serviceName;
    private final int timeoutMillis;

    Singleton(String serviceName, int timeoutMillis) {
        this.serviceName = serviceName;
        this.timeoutMillis = timeoutMillis;
    }

    public String getServiceName() {
        return serviceName;
    }

    public int getTimeoutMillis() {
        return timeoutMillis;
    }

    public void execute() {
        System.out.println(
                serviceName + " timeout=" + timeoutMillis);
    }
}

调用方式:

Singleton.INSTANCE.execute();

String serviceName = Singleton.INSTANCE.getServiceName();
int timeoutMillis = Singleton.INSTANCE.getTimeoutMillis();

枚举常量可以拥有构造参数、成员属性和普通方法。上例中的属性使用 final 修饰,实例创建后不再改变,多个线程读取时不需要额外同步。如果枚举单例内部保存的是可变属性,并发访问时仍然需要锁或其他线程安全机制。

枚举单例具有以下特点:

  • JVM 保证枚举常量只初始化一次;
  • 枚举构造器不能通过常规反射方式创建新实例;
  • Java 序列化机制能够保持枚举常量的唯一性;
  • 代码简洁,不需要手动编写并发控制。

如果业务能够接受枚举形式,并且特别关注反射与反序列化对单例的破坏,枚举通常是更稳妥的选择。

六、同步方法实现

最直接的懒加载写法是给整个获取方法加锁:

public final class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

这种实现是线程安全的,但每次调用 getInstance() 都要进入同步方法。现代 JVM 对无竞争锁已有较多优化,但在能够使用静态内部类的情况下,通常没有必要让每次读取都经过同步。

它的优点是逻辑直观,适合教学或对性能不敏感的简单场景。

七、两种错误实现

1. 没有同步的懒加载

public static Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

多个线程可能同时判断 instance == null,随后分别创建对象,因此不能保证单例。

2. 缺少第二次检查

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            instance = new Singleton();
        }
    }
    return instance;
}

多个线程可能同时通过外层判断,然后依次进入同步块并各自创建对象。加锁只能保证它们不会同时创建,不能阻止后获得锁的线程再次创建,因此锁内必须重新判断。

八、单例可能被哪些方式破坏

1. 反射

普通单例的私有构造器仍可能被反射调用:

Constructor<Singleton> constructor =
        Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton another = constructor.newInstance();

可以在构造器中增加防御性检查,但这种方式需要谨慎处理初始化顺序,也不能替代可靠的访问控制。枚举单例能够从语言与 JVM 层面阻止这种常规反射创建。

2. 反序列化

普通单例实现 Serializable 后,反序列化默认可能创建新对象。可以定义:

private Object readResolve() {
    return INSTANCE;
}

让反序列化结果返回已有实例。枚举单例由序列化机制直接保证唯一性。

3. 克隆

如果单例支持 Cloneable 并允许调用 clone(),也可能产生新对象。单例类通常不应实现 Cloneable,或者应拒绝克隆。

4. 多个 ClassLoader

静态字段属于其对应的类,而类的身份由“类名 + 加载它的 ClassLoader”共同决定。同一个类被不同 ClassLoader 分别加载时,每个 ClassLoader 都可以拥有自己的单例实例。

因此 Java 单例通常保证的是:

同一个 JVM、同一个 ClassLoader 范围内只有一个实例。

它不能保证多个 JVM、多个容器实例或分布式集群中只有一个对象。

九、不同实现如何选择

实现方式线程安全懒加载主要特点推荐场景
饿汉式简单,依靠类初始化创建成本低、启动时即可创建
静态内部类无显式锁,读取开销低普通业务中的懒加载单例
双重检查锁必须正确使用 volatile需要显式控制初始化过程
枚举通常不作为延迟加载方案防常规反射和反序列化破坏强调实现简洁和唯一性
同步方法简单但每次获取都同步教学或低频简单场景

十、单例不等于无条件线程安全

单例模式只保证实例数量,不会自动保证实例内部状态的线程安全。

public final class CounterSingleton {

    private int count;

    public void increment() {
        count++;
    }
}

即使 CounterSingleton 只有一个实例,多个线程并发执行 count++ 仍然可能丢失更新。可变状态仍然需要使用锁、原子类、不可变对象或其他并发控制方式保护。

在 Spring 中,默认的单例 Bean 也只是表示一个 ApplicationContext 中通常只有一个实例。Spring 不会自动保证这个 Bean 的可变字段线程安全。

十一、需要记住的边界

  • 单例保证实例数量,不保证内部状态线程安全;
  • Java 单例通常只在同一个 ClassLoader 范围内成立;
  • Spring Singleton 的作用域是一个 ApplicationContext
  • 多 JVM、多个服务实例之间的全局唯一,需要借助数据库唯一约束、分布式锁、协调服务或主节点选举等机制;
  • 单例适合承载无状态逻辑,或者经过并发保护的共享状态;
  • 不应把“当前用户”“本次请求参数”或普通可变集合等请求级数据保存在单例字段中。所有线程和测试用例会共享这些数据,容易造成请求间数据串扰、并发修改错误以及测试互相影响。
DISCUSSION

评论与讨论

留下你的想法