单例模式

你写了一个配置管理类,在应用启动时加载配置,所有模块都通过它获取配置项。测试环境运行正常,但上线后发现配置被修改了——某个模块的初始化代码不小心改了全局配置,导致其他模块拿到错误的值。

这个问题的根源是:没有控制住对象的创建过程。任何代码都可以 new ConfigManager() 创建新实例,无法保证全局只有一个配置管理器。单例模式就是来解决这个问题的。

单例模式的核心问题

单例模式看似简单,实现方式却有很多种,每种都有其适用场景和潜在问题。评价一个单例实现的三个维度:

  1. 线程安全:多线程环境下是否仍能保证唯一实例
  2. 延迟加载:是否在首次使用时才创建实例(避免启动开销)
  3. 防破坏能力:能否抵抗反射、序列化、克隆的破坏

七种实现方式

1. 饿汉式(静态常量)

public class SingletonEager {
    private static final SingletonEager INSTANCE = new SingletonEager();

    private SingletonEager() {
    }

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

优点:线程安全,JVM 保证类加载时就创建实例 缺点:不是延迟加载,如果单例创建成本高会影响启动时间

2. 饿汉式(静态代码块)

public class SingletonEagerBlock {
    private static final SingletonEagerBlock INSTANCE;

    static {
        INSTANCE = new SingletonEagerBlock();
    }

    private SingletonEagerBlock() {
    }

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

与静态常量方式等价,只是把创建逻辑放在静态代码块中。适合需要执行额外初始化逻辑的场景。

3. 懒汉式(线程不安全)

public class SingletonLazyUnsafe {
    private static SingletonLazyUnsafe INSTANCE;

    private SingletonLazyUnsafe() {
    }

    public static SingletonLazyUnsafe getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingletonLazyUnsafe();
        }
        return INSTANCE;
    }
}

严重问题:多线程下会创建多个实例。两个线程同时通过 if (INSTANCE == null) 检查,都会创建新实例。

4. 懒汉式(同步方法)

public class SingletonLazySync {
    private static SingletonLazySync INSTANCE;

    private SingletonLazySync() {
    }

    public static synchronized SingletonLazySync getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingletonLazySync();
        }
        return INSTANCE;
    }
}

优点:线程安全 缺点:每次调用都要获取锁,性能开销大。获取锁的代价比创建实例高得多。

5. 双重检查锁(DCL)

public class SingletonDCL {
    private static volatile SingletonDCL INSTANCE;

    private SingletonDCL() {
    }

    public static SingletonDCL getInstance() {
        if (INSTANCE == null) {          // 第一次检查:避免不必要的同步
            synchronized (SingletonDCL.class) {
                if (INSTANCE == null) {  // 第二次检查:防止多线程重复创建
                    INSTANCE = new SingletonDCL();
                }
            }
        }
        return INSTANCE;
    }
}

优点:延迟加载 + 线程安全 + 高性能 缺点:实现稍复杂,需要理解 volatile 的作用

DCL 为什么要加 volatile?

volatile 在这里起到两个作用:

1. 防止指令重排序

INSTANCE = new SingletonDCL() 不是一个原子操作,实际会被拆分为三个步骤:

// 1. 分配内存
memory = allocate();
// 2. 调用构造函数
constructor(memory);
// 3. 将引用赋值给 INSTANCE
INSTANCE = memory;

如果没有 volatile,JVM 可能优化成:

memory = allocate();
INSTANCE = memory;        // 引用先赋值
constructor(memory);       // 构造函数后执行

这导致其他线程可能看到已赋值但未构造完成的对象。

2. 保证可见性

一个线程对 INSTANCE 的写操作,对其他线程立即可见。普通变量没有这个保证。

6. 静态内部类

public class SingletonStaticInner {
    private SingletonStaticInner() {
    }

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

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

原理:JVM 的类加载机制保证了线程安全。只有当调用 getInstance() 时,Holder 类才会被加载,而 Holder 的加载过程是线程安全的。

优点:延迟加载 + 线程安全 + 高性能 + 代码简洁 缺点:无法阻止反射破坏

7. 枚举单例

public enum SingletonEnum {
    INSTANCE;

    public void doSomething() {
    }
}

优点

  • JVM 底层保证绝对线程安全
  • 反射无法创建新实例
  • 序列化安全(JVM 保证)

缺点:不是延迟加载,但枚举实例创建代价极低,几乎可以忽略。

// 使用方式
SingletonEnum instance = SingletonEnum.INSTANCE;

这是《Effective Java》作者 Joshua Bloch 推荐的方式,也是最简单的单例实现。

七种实现方式对比

方式线程安全延迟加载防反射防序列化推荐度
饿汉(常量)⭐⭐
饿汉(代码块)⭐⭐
懒汉(同步)⭐⭐⭐
懒汉(不安全)
双重检查锁⭐⭐⭐⭐
静态内部类⭐⭐⭐⭐⭐
枚举⭐⭐⭐⭐⭐

防止单例被破坏

即使正确实现了单例,仍然可能被以下方式破坏:

1. 反射攻击

SingletonDCL instance1 = SingletonDCL.getInstance();

Constructor<SingletonDCL> constructor = SingletonDCL.class.getDeclaredConstructor();
constructor.setAccessible(true);
SingletonDCL instance2 = constructor.newInstance();

System.out.println(instance1 == instance2); // false

防御方法:在构造函数中检查是否已创建实例

private SingletonDCL() {
    if (INSTANCE != null) {
        throw new RuntimeException("单例模式不允许创建多个实例");
    }
}

2. 序列化攻击

SingletonEnum instance1 = SingletonEnum.INSTANCE;

// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(instance1);
byte[] bytes = bos.toByteArray();

// 反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bis);
SingletonEnum instance2 = (SingletonEnum) ois.readObject();

System.out.println(instance1 == instance2); // true(枚举天然安全)

对于非枚举单例,需要添加 readResolve 方法:

private Object readResolve() {
    return INSTANCE;
}

3. 克隆攻击

public class SingletonDCL implements Cloneable {
    // ...

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

SingletonDCL instance1 = SingletonDCL.getInstance();
SingletonDCL instance2 = (SingletonDCL) instance1.clone();

System.out.println(instance1 == instance2); // false

防御方法:覆写 clone() 方法返回单例实例

@Override
protected Object clone() throws CloneNotSupportedException {
    return INSTANCE;
}

适用场景

应该使用单例

  • 全局唯一的配置管理器
  • 日志记录器(避免重复创建 Writer)
  • 连接池(数据库、Redis、HttpClient)
  • ID 生成器(雪花算法等)
  • 线程池(整个应用共用一个)
  • 计数器、缓存管理器

不应该使用单例

  • 业务对象(订单、用户等)—— 这些本就应该多实例
  • 有状态且需要扩展的对象
  • 需要继承和多态的场景

反模式警示

1. 过度全局化

// 错误:什么都做成单例
public class UserService extends Singleton {}
public class OrderService extends Singleton {}
public class ProductService extends Singleton {}

单例的「全局状态」是测试的天敌。如果每个服务都是单例,单元测试就无法并行执行,因为它们共享状态。

2. 单例的隐式依赖

// 错误:单例之间互相依赖
public class ConfigManager extends Singleton {
    private DatabaseManager dbManager = DatabaseManager.getInstance();
}

这种写法让依赖关系变得隐式且难以追踪。更好的方式是显式注入依赖。

3. 延迟加载的滥用

public class HeavySingleton {
    private static volatile HeavySingleton INSTANCE;

    public static HeavySingleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = loadFromDisk();  // 耗时操作
        }
        return INSTANCE;
    }
}

如果创建过程很耗时,应该考虑:真的要延迟加载吗?启动时一次性加载反而更简单。

思考题

问题 1:静态内部类单例为什么是线程安全的?

参考答案

JVM 的类加载机制保证了这一点。类加载过程分为「加载」「验证」「准备」「解析」「初始化」五个阶段。其中「初始化」阶段由 JVM 的类加载器锁(Class 对象的锁)控制,保证同一时间只有一个线程执行类的初始化代码。

静态内部类 Holder 只有在调用 getInstance() 时才会被加载,而 JVM 保证了这个加载过程的线程安全性。整个过程不需要额外的同步代码。

问题 2:DCL 中的两个 if (INSTANCE == null) 各起什么作用?

参考答案

第一个 if:性能优化。如果实例已经创建,直接返回,避免每次调用都要获取 synchronized 锁。

第二个 if:防止多线程重复创建。当多个线程同时通过第一个检查并进入 synchronized 块时,只有第一个线程会创建实例,后续线程的第二个检查会发现实例已创建。

如果去掉第二个检查:

  • 线程 A 和 B 同时通过第一个 if
  • A 获得锁,进入 synchronized,创建实例
  • A 释放锁
  • B 获得锁,再次创建实例
  • 单例被破坏

问题 3:为什么枚举单例天然防序列化?

参考答案

Java 规范保证枚举的反序列化过程不走普通对象的反序列化逻辑。当 JDK 的 ObjectInputStream 读取到一个枚举类型的对象时,它会通过枚举类型的 valueOf() 方法获取实例,而不是通过反射创建新对象。

源码位于 ObjectInputStreamreadEnum() 方法中:

Enum<?> enumConstant = Enum.valueOf((Class)cl, name);

这意味着无论你怎么序列化/反序列化枚举,得到的永远是同一个实例。不需要 readResolve 方法,也不需要额外配置。