一、单例模式:五种实现与序列化攻防战
单例模式,说白了就是保证一个类只有一个实例。我刚开始写代码那会儿,觉得这玩意儿太简单了——不就是 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 项目:能用枚举就用枚举。如果不能用(比如 Android),用静态内部类。
- C++ 项目:C++11 之后,用局部静态变量(Meyer's Singleton)最省心。
- 需要序列化:要么用枚举,要么老老实实加
readResolve()。 - 高并发场景:别用懒汉式,别用懒汉式,别用懒汉式。重要的事情说三遍。
我曾经在一个支付系统中,因为用了不恰当的单例实现,导致线上出现了偶发的重复扣款问题。排查了两天才定位到是单例被序列化破坏。从那以后,我对单例模式就格外谨慎了。
好了,单例模式就聊到这里。记住一句话:简单不等于容易,越是基础的东西,越要把它做扎实。