四、单例模式设计缺陷:为什么你的单例不够「单」

做 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() 时仍然可能发生指令重排。具体来说:

  1. 分配内存空间
  2. 初始化对象
  3. 将 instance 指向该内存

如果 2 和 3 被重排了,另一个线程拿到的是一个“半初始化”的对象。调用方法时,崩溃。

个人经验: 我曾经踩过这个坑,在线上版本里,每次启动有 0.5% 的概率崩溃。查了两天,最后用 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(),全量注册两次,崩溃。

关键结论: 如果你的 SDK 运行在插件化或热修复环境中,单例的“单”是伪命题。需要用进程级别的唯一标识来确保只注册一次。

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 图帮你梳理了单例不够“单”的完整链路:

单例模式不够“单”的问题链路 单例模式 多线程:非线程安全 类加载器隔离 构造函数出错 多个实例被创建 不同 ClassLoader 多份 实例创建失败 register() 被多次调用 → 重复注册 启动崩溃 说明:单例模式不够“单”,本质上是“实例唯一性条件”被破坏。 无论何时,register() 都应该保证幂等性,即多次调用效果等同于一次。

4.7 避坑清单

最后,我把自己这些年踩过的坑、修过的 Bug 总结成一张清单。你照着检查:

  1. 所有 getInstance() 必须是线程安全的。推荐双重检查锁定 + volatile,或者 Holder 模式。
  2. 不要在构造函数里做任何可能失败的操作。放到 init() 方法里,让调用方自己控制时机。
  3. 考虑类加载器隔离。如果 SDK 用于插件化场景,需要额外机制确保注册逻辑只执行一次。
  4. register() 必须是幂等的。不管调多少次,效果和调一次一样。这是防御性编程的底线。
  5. 日志打全。在 register() 开头打日志:”register called, thread=xxx, classLoader=xxx, instanceHash=xxx”。线上排错时,这就是救命稻草。

记住,单例模式的“单”不是靠名字得来的,是靠写对代码赢来的。别让你的 SDK 死在最简单的地方。