一、单例模式:五种实现与序列化攻防战

单例模式,说白了就是保证一个类只有一个实例。我刚开始写代码那会儿,觉得这玩意儿太简单了——不就是 new 一次嘛。直到我在一个高并发项目中,因为用了不恰当的单例实现,导致系统出现了诡异的资源竞争问题……嗯,从那以后我才真正重视起这个「简单」的模式。

今天咱们就把单例模式彻底聊透。从最基础的饿汉式,到最优雅的枚举实现,再到序列化如何破坏单例,我都会结合自己的踩坑经历来讲。

1.1 饿汉式:简单粗暴,但别乱用

饿汉式,顾名思义——类加载时就迫不及待地创建实例。代码长这样:

// Java 实现
public class SingletonHungry {
    private static final SingletonHungry INSTANCE = new SingletonHungry();
    
    private SingletonHungry() {}
    
    public static SingletonHungry getInstance() {
        return INSTANCE;
    }
}
// C++ 实现
class SingletonHungry {
private:
    static SingletonHungry* instance;
    SingletonHungry() {}
public:
    static SingletonHungry* getInstance() {
        return instance;
    }
};
SingletonHungry* SingletonHungry::instance = new SingletonHungry();

优点:线程安全,实现简单。

缺点:不管用不用,类加载就创建。如果这个单例很重,或者依赖一些运行时配置,那就麻烦了。

我个人习惯:如果单例对象很小,且确定会被用到,用饿汉式没问题。但如果你不确定,或者对象初始化需要外部参数,就别用它。

1.2 懒汉式:延迟加载,但线程不安全

懒汉式就是「用到才创建」。基础版本长这样:

// Java 实现(线程不安全版)
public class SingletonLazy {
    private static SingletonLazy instance;
    
    private SingletonLazy() {}
    
    public static SingletonLazy getInstance() {
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
}

这个版本有个致命问题——多线程环境下,两个线程可能同时进入 if (instance == null),然后各自创建实例。我在一个内部工具项目里就遇到过这种情况,结果日志文件被两个实例同时写入,乱成一锅粥。

解决办法?加 synchronized 呗。但直接加在方法上,性能又太差。这就引出了我们的下一个方案。

1.3 双重检查锁:性能与安全的平衡

双重检查锁(DCL)的思路是:先判断一次,不加锁;如果实例为空,再加锁创建。这样大部分情况下都不需要进入同步块。

// Java 实现
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;
    }
}

注意:Java 中必须加 volatile 关键字!否则指令重排序可能导致其他线程拿到未初始化完成的对象。我曾经在面试中问过这个问题,十个有八个答不上来。

// C++ 实现(C++11 起可用)
#include <mutex>
class SingletonDCL {
private:
    static SingletonDCL* instance;
    static std::mutex mtx;
    SingletonDCL() {}
public:
    static SingletonDCL* getInstance() {
        if (instance == nullptr) {
            std::lock_guard<std::mutex> lock(mtx);
            if (instance == nullptr) {
                instance = new SingletonDCL();
            }
        }
        return instance;
    }
};

C++ 里要注意,C++11 之前没有标准的内存模型,DCL 可能失效。所以如果你用老编译器,建议直接用局部静态变量。

1.4 静态内部类:我最推荐的 Java 方案

这个方案利用了 Java 类加载机制——内部类只有在被使用时才会加载。既实现了延迟加载,又保证了线程安全。

// Java 实现
public class SingletonHolder {
    private SingletonHolder() {}
    
    private static class Holder {
        private static final SingletonHolder INSTANCE = new SingletonHolder();
    }
    
    public static SingletonHolder getInstance() {
        return Holder.INSTANCE;
    }
}

为什么说它好?因为 JVM 保证了类的加载是线程安全的。而且没有 DCL 那么复杂的同步逻辑,代码也干净。我在实际项目中,Java 单例基本都用这个方式。

1.5 枚举实现:最安全的方案

Joshua Bloch 在《Effective Java》里大力推荐枚举单例。它天然防止反射攻击和序列化破坏。

// Java 实现
public enum SingletonEnum {
    INSTANCE;
    
    public void doSomething() {
        // 业务方法
    }
}

调用方式:SingletonEnum.INSTANCE.doSomething()

小提示:枚举单例在 Android 开发中要慎用,因为枚举会占用更多内存。但在后端服务中,它是最优雅的选择。

1.6 序列化破坏单例:一个隐蔽的坑

你以为用了 DCL 或静态内部类就万事大吉了?序列化会悄悄破坏你的单例。

为什么会这样?因为反序列化时,会通过反射创建一个新对象,不管你的构造方法是不是私有的。

// 演示序列化破坏单例
public class SingletonSerializable implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final SingletonSerializable INSTANCE = new SingletonSerializable();
    
    private SingletonSerializable() {}
    
    public static SingletonSerializable getInstance() {
        return INSTANCE;
    }
}

// 测试代码
public static void main(String[] args) throws Exception {
    SingletonSerializable s1 = SingletonSerializable.getInstance();
    
    // 序列化
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.obj"));
    oos.writeObject(s1);
    oos.close();
    
    // 反序列化
    ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.obj"));
    SingletonSerializable s2 = (SingletonSerializable) ois.readObject();
    ois.close();
    
    System.out.println(s1 == s2); // false!单例被破坏了
}

解决办法?在类中添加 readResolve() 方法:

// 修复序列化破坏
public class SingletonSerializable implements Serializable {
    private static final long serialVersionUID = 1L;
    private static final SingletonSerializable INSTANCE = new SingletonSerializable();
    
    private SingletonSerializable() {}
    
    public static SingletonSerializable getInstance() {
        return INSTANCE;
    }
    
    // 这个方法会在反序列化时被调用
    private Object readResolve() {
        return INSTANCE;
    }
}

原理很简单:反序列化时,JVM 会检查有没有 readResolve() 方法,如果有,就返回该方法指定的对象,而不是新创建的那个。

不过话说回来,如果你用枚举实现单例,根本不用操心这个问题——枚举的序列化是 JVM 特殊处理的,天生安全。

知识体系总览

下面这张图总结了五种单例实现的核心区别:

单例模式五种实现对比 饿汉式 类加载即创建 懒汉式 用到才创建 双重检查锁 性能+安全 静态内部类 延迟+线程安全 枚举 最安全 特性对比: 线程安全 ✅ 安全 ❌ 不安全 ✅ 安全 ✅ 安全 ✅ 安全 延迟加载 ❌ 否 ✅ 是 ✅ 是 ✅ 是 ✅ 是 防反射攻击 ❌ 否 ❌ 否 ❌ 否 ❌ 否 ✅ 是 防序列化破坏 ❌ 需处理 ❌ 需处理 ❌ 需处理 ❌ 需处理 ✅ 天生 推荐场景 轻量对象 不推荐 Java/C++ Java首选 最推荐

总结一下我的建议

  • Java 项目:能用枚举就用枚举。如果不能用(比如 Android),用静态内部类。
  • C++ 项目:C++11 之后,用局部静态变量(Meyer's Singleton)最省心。
  • 需要序列化:要么用枚举,要么老老实实加 readResolve()
  • 高并发场景:别用懒汉式,别用懒汉式,别用懒汉式。重要的事情说三遍。

我曾经在一个支付系统中,因为用了不恰当的单例实现,导致线上出现了偶发的重复扣款问题。排查了两天才定位到是单例被序列化破坏。从那以后,我对单例模式就格外谨慎了。

好了,单例模式就聊到这里。记住一句话:简单不等于容易,越是基础的东西,越要把它做扎实。


公众号:蓝海资料掘金营,微信deep3321