四、单例模式设计缺陷:为什么你的单例不够「单」
做 SDK 开发,单例模式几乎是标配。我做了十年 SDK,单例的问题见得太多了。说白了,“单例”这个名字本身就暗示了它的承诺:整个进程里只有我一个实例。但现实往往打脸——你的单例真的“单”吗?
今天咱们就来扒一扒,单例模式在 SDK 注册场景中是怎么“叛变”的。我自己踩过的坑,今天全给你抖出来。
4.1 经典单例,你写对了吗?
先看一个最常见的写法。我在项目里见过不下百次:
public class SdkManager {
private static SdkManager instance;
private boolean registered = false;
private SdkManager() {}
public static SdkManager getInstance() {
if (instance == null) {
instance = new SdkManager();
}
return instance;
}
public void register(Context context) {
if (!registered) {
// do registration
registered = true;
}
}
}
这么写有什么问题?嗯,问题大了。多线程环境下,两个线程同时调用 getInstance(),instance 还是 null,结果两个线程各 new 了一个对象。这就是经典的 非线程安全单例。
4.2 你以为加了 synchronized 就万事大吉?
好,有人说了,我加个锁不就完了?
public static synchronized SdkManager getInstance() {
if (instance == null) {
instance = new SdkManager();
}
return instance;
}
这个方法确实解决了多线程竞争的问题。但性能呢?每次调用 getInstance() 都要获取类锁,并发极高的场景下,这简直是灾难。我记得有一个项目,SDK 初始化时频繁调用这个方法,结果启动时间从 800ms 飙到了 2.3s。
还有更隐蔽的问题:指令重排。即使你加了 synchronized,JVM 在 new SdkManager() 时仍然可能发生指令重排。具体来说:
- 分配内存空间
- 初始化对象
- 将 instance 指向该内存
如果 2 和 3 被重排了,另一个线程拿到的是一个“半初始化”的对象。调用方法时,崩溃。
volatile 关键字才解决。具体方案是 双重检查锁定(Double-Checked Locking):
public class SdkManager {
private static volatile SdkManager instance;
public static SdkManager getInstance() {
if (instance == null) {
synchronized (SdkManager.class) {
if (instance == null) {
instance = new SdkManager();
}
}
}
return instance;
}
}
加了 volatile 之后,指令重排就被禁止了。instance 的可见性也保证了。这个写法是目前最推荐的。
4.3 另一个“杀手”:类加载器隔离
你想想看,同一个类在不同 ClassLoader 里,会被加载成不同的 Class 对象。静态变量 instance 是绑定在 Class 上的,不同 ClassLoader 的静态变量互相隔离。这意味着一件事:你的“单例”在同一个进程里,可能有 多份。
什么场景会触发?我告诉你:
- 插件化架构:宿主和插件各自持有自己的 ClassLoader
- 热修复:补丁包的 ClassLoader 与主进程不同
- 多进程:每个进程都有自己的 ClassLoader(严格来说,这是跨进程问题,但表现类似)
在插件化场景中,宿主注册了一个监听器,插件也注册了一个。两个 SdkManager 实例互不知晓,同时调用 register(),全量注册两次,崩溃。
4.4 “静态单例”真的安全吗?
有人觉得,用静态内部类的 Holder 模式总该没问题了吧?
public class SdkManager {
private SdkManager() {}
private static class Holder {
private static final SdkManager INSTANCE = new SdkManager();
}
public static SdkManager getInstance() {
return Holder.INSTANCE;
}
}
这个写法利用了 类加载机制:Holder 只有在 getInstance() 被调用时才会被加载,且类加载是线程安全的。看起来完美,但注意了:
- 如果你在
SdkManager的构造函数里做了耗时的初始化工作(比如读配置文件、开线程),那么第一次调用getInstance()时,性能开销全部集中在这里。启动线程卡顿,用户点击“举报”的冲动你懂了吧? - 如果构造函数里抛出了异常,那么整个 Holder 类就加载失败了。以后所有对
getInstance()的调用都会抛出NoClassDefFoundError。崩溃,又是崩溃。
init() 方法里。
4.5 枚举单例,真香还是假香?
《Effective Java》里推荐枚举单例。它天然线程安全,且能防止反射和序列化攻击:
public enum SdkManager {
INSTANCE;
private boolean registered = false;
public void register(Context context) {
if (!registered) {
// do registration
registered = true;
}
}
}
这个写法确实简洁。但我在生产环境中发现一个问题:枚举在 Android 上的内存开销比普通类大 10 倍以上。如果你的 SDK 需要在低端机上运行,建议三思。
| 写法 | 线程安全 | 防反射 | 性能 | 内存开销 |
|---|---|---|---|---|
| 朴素懒加载 | ❌ | ❌ | 优 | 低 |
| synchronized 方法 | ✅ | ❌ | 差 | 低 |
| 双重检查锁定 | ✅ | ❌ | 优 | 低 |
| 静态内部类 Holder | ✅ | ❌ | 优 | 低 |
| 枚举 | ✅ | ✅ | 良 | 高 |
4.6 本章核心:单例模式问题链路
下面的 SVG 图帮你梳理了单例不够“单”的完整链路:
4.7 避坑清单
最后,我把自己这些年踩过的坑、修过的 Bug 总结成一张清单。你照着检查:
- 所有 getInstance() 必须是线程安全的。推荐双重检查锁定 + volatile,或者 Holder 模式。
- 不要在构造函数里做任何可能失败的操作。放到 init() 方法里,让调用方自己控制时机。
- 考虑类加载器隔离。如果 SDK 用于插件化场景,需要额外机制确保注册逻辑只执行一次。
- register() 必须是幂等的。不管调多少次,效果和调一次一样。这是防御性编程的底线。
- 日志打全。在 register() 开头打日志:”register called, thread=xxx, classLoader=xxx, instanceHash=xxx”。线上排错时,这就是救命稻草。
记住,单例模式的“单”不是靠名字得来的,是靠写对代码赢来的。别让你的 SDK 死在最简单的地方。