ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

单例模式模板全解析:从C++到Java的线程安全与防御指南

单例模式模板全解析:从C++到Java的线程安全与防御指南 “设计模式—单例模式模版”这个标题我第一反应是有朋友要应付“设计模式大作业”或者想在工程里抽一个可复用的单例基类。但单例模式作为23种设计模式里结构最简单的一个恰恰也是生产代码里被误用得最离谱的一个。网上讲原理的教程不少真落到代码上很多人反而不知道该选哪种写法写了饿汉被说浪费资源写了懒汉测试一并发就崩好不容易上了双重检查锁又有两个“隐藏刺客”等着拆台反射和序列化都能轻松打穿你的单例。这篇文章我就把“能直接抄”的模板和“为什么要这么写”的底层逻辑一起讲清楚。内容覆盖C和Java两套主流实现从线程安全、懒加载、防御攻击到哪些场景根本不该用单例。准备交作业的同学可以直接拿代码模板写业务系统的朋友也能避开那些足以让项目埋雷的坑。1. 单例模板到底在解决什么问题全局唯一与“何时创建”1.1 全局唯一这个需求是单例存在的前提先别急着看代码想清楚一个问题单例不是“我不想每次new对象”的偷懒入口而是业务上确实需要“全局只有一个实例”的强约束。举几个真实的例子配置管理器。整个App读一份配置文件启动时加载一次到处访问。如果每个模块各new一个配置源不一致A模块改了一版而B模块还是旧的这种bug非常难查。日志输出器。所有日志走同一个内部缓冲区和同一个输出管道如果你每次打日志都new一个Logger写入顺序全是乱的。连接池、线程池。资源有限必须全局共享一个池子来复用多创建就是白费内存和句柄。游戏里的AudioManager、GameManager。所有系统都要访问同一个管理器访问入口必须全局可见。这些场景的共同点不是“方便”而是“唯一性本身就是需求”。如果你的类并不具备这个特征那单例就不是设计方案而是技术债。1.2 饿汉与懒汉的真正差异实例何时创建单例模板千变万化本质上就是在回答一个问题实例是什么时候创建的。饿汉式类加载/程序启动时就创建之后永远只返回这个现成的实例。懒汉式第一次调用getInstance时才创建之后复用。饿汉的好处是线程安全天然成立JVM或C的静态初始化过程保证了只有一个线程来构造它。代价是启动变慢哪怕你从头到尾没用过这个类它也白白躺在内存里。懒汉的好处是延迟加载程序启动快不用就不会创建。代价是并发场景下“先检查后创建”的逻辑很容易出问题这正是无数单例bug的源头。你写单例模板第一步不是选“默认写法”而是想清楚“实例的创建时机对你有没有意义”。如果实例创建开销很小、程序启动时就一定会用到饿汉反而是最好的选择代码最简单还白送线程安全。2. 模板设计的第一道坎懒加载与线程安全的相爱相杀2.1 一个看似无害的懒汉在高并发下是怎么崩的假设你写了这样一个版本class Singleton { public: static Singleton* getInstance() { if (instance_ nullptr) { instance_ new Singleton(); // 问题就在这里 } return instance_; } private: static Singleton* instance_; };单线程下没问题一上多线程就出大事。线程A执行到new Singleton()构造还没完成线程B同时执行if (instance_ nullptr)看到指针还是空也跟着new了一个。两个线程各拿各的实例后写进指针的还会把前一个覆盖掉单例彻底失效而且这个bug不是每次都复现完全是概率性的你很难第一时间定位。这也是为什么但凡讲单例的教程都要单独拉一章讲线程安全。模板的价值不在于“单线程能跑”而在于“放进并发环境依然只有一个实例”。2.2 加锁重派与双重检查锁的取舍逻辑最简单的线程安全版本就是给整个getInstance加锁std::lock_guardstd::mutex lock(mutex_); if (instance_ nullptr) { instance_ new Singleton(); } return instance_;逻辑没毛病但性能不好。getInstance是个高频入口每次调用都要走一遍锁全局锁竞争会拖慢整个系统。线程安全是保证了效率被牺牲了。于是有了双重检查锁DCL先无锁快速判断只有发现实例为空时才加锁细查if (instance_ nullptr) { // 第一次检查无锁 std::lock_guardstd::mutex lock(mutex_); if (instance_ nullptr) { // 第二次检查持锁 instance_ new Singleton(); } }思路是大多数情况第一次判断就命中了根本进不去锁区域性能损失很小。2.3 为什么双重检查锁里必须用volatileDCL看起来天衣无缝但有个致命细节——指令重排。instance_ new Singleton()这行代码编译后不是一步而是三步分配内存调用构造函数完成初始化把地址赋值给instance_编译器也好CPU也好都可能把第2步和第3步对调。如果线程A先写了指针再初始化对象线程B在同一时刻发现指针非空直接拿去用拿到的就是一个“半成品”对象。这个对象里的成员变量还是乱值调用它的方法轻则返回脏数据重则直接崩溃。这就是为什么C和Java要在共享实例指针上加volatile关键字。它告诉编译器这个变量的访问不要给我优化重排我要求严格的可见性和有序性。如果你是从网上抄的DCL代码一定先检查有没有漏掉volatile。漏了它单例在并发下就是一颗定时炸弹平时测试跑得欢线上高负载时才爆炸。2.4 C11之后的大杀器静态局部变量说个我实际工作中的偏好。C11以后我写单例模板几乎默认用局部静态变量而不是手写DCL。class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } };C标准明确保证函数内静态局部变量的初始化在第一次执行到该语句时发生而且是线程安全的初始化过程中的并发调用会阻塞等待。也就是说这段代码同时拿到了懒加载、线程安全、代码最少三个优点。这也是我在团队Code Review里推荐大家默认选择的版本。手写DCL意味着你要自己处理volatile、锁、内存序任何一个细节错了都埋雷而静态局部变量把这些问题全部丢给编译器它比你靠谱得多。3. 可以抄进工程里的四套单例模板附带选型表既然标题是“模版”那就直接给能落地的代码。我会给出C和Java各两套并说明每套适配什么场景。3.1 C模板一静态局部变量版本日常首选// singleton.h #ifndef SINGLETON_H #define SINGLETON_H class Singleton { public: // 禁止复制和赋值防止意外出现第二份实例 Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; static Singleton getInstance() { static Singleton instance; return instance; } void doSomething() { // 业务方法 } private: // 构造和析构都放private外部无法自己建立或删除 Singleton() default; ~Singleton() default; }; #endif // SINGLETON_H几个要点返回引用而不是指针。指针容易被误delete一delete整个单例就没了下次访问直接崩溃引用没法delete天然安全。删掉拷贝构造和赋值运算符。如果不删别人拿到引用后能复制出一个新对象单例约束被绕开。构造和析构都私有。防止有人new Singleton()或声明栈对象。这套模板适合大部分业务类配置中心、日志器、内部管理器。代码量最小线程安全性由标准库保证懒加载也满足了作为通用模板最合适。3.2 C模板二参数化初始化的懒汉版本静态局部变量版本的缺点是不好传参数。如果你的单例需要读取外部配置或者依赖某些运行时参数才能初始化就得回到显式懒加载的写法。这里给一个兼顾线程安全的版本class Singleton { public: static Singleton getInstance() { std::call_once(flag_, [] { instance_ new Singleton(); }); return *instance_; } static void init(int param) { std::call_once(flag_, [param] { instance_ new Singleton(param); }); } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton(int p) : value_(p) {} static std::once_flag flag_; static Singleton* instance_; int value_; }; std::once_flag Singleton::flag_; Singleton* Singleton::instance_ nullptr;std::call_once的意思是“这段初始化代码在程序生命周期内只执行一次多个线程同时调用时只有一个人执行其他人阻塞等待”。这比手写mutex加DCL要清晰也不需要volatile的玄学逻辑都封装在标准库了。日常如果遇到“单例需要根据配置参数初始化”的场景我直接用这个模板。3.3 Java模板一静态内部类Holder日常首选Java这边我最推荐的是静态内部类方案public class Singleton { private Singleton() { // 防止外部new } private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } public void doSomething() { // 业务方法 } }原理是JVM在加载Singleton类时并不会加载Holder只有第一次调用getInstance()JVM看到Holder.INSTANCE才去加载Holder类并完成构造。类加载过程由JVM内部锁保证天然线程安全懒加载也自然成立。这个模板我个人用了很久没有锁、没有关键字干干净净怎么挑都挑不出大毛病。除非你遇到反序列化或反射攻击第四章专门讲否则这个方案就是Java单例的“标准答案”。3.4 Java模板二枚举单例《Effective Java》里Joshua Bloch明确推荐过枚举单例public enum Singleton { INSTANCE; public void doSomething() { // 业务方法 } }这是Java里唯一既防反射、又防序列化的单例写法。因为枚举类没有公开构造方法即使拿到Class对象也new不出来序列化机制对枚举有特殊处理反序列化返回的是同一个枚举常量。它的缺点也很明显如果你用的框架要求类能被实例化或被代理比如很多依赖注入框架、Spring容器枚举单例生命周期是JVM级别的无法和容器协作。所以我的建议是系统级基础设施、工具类、需要绝对防御的场景用枚举业务协作类、服务类用静态内部类。3.5 四套模板怎么选一张表说清楚维度C静态局部变量C call_onceJava静态内部类Java枚举懒加载是是是否类加载即创建线程安全是是是是代码量极少中等少极少防反射攻击语言机制不支持语言机制不支持否是防序列化破坏需自行设计需自行设计需readResolve是支持传参初始化否是否否推荐场景通用业务类需参数化配置通用业务类绝对防御要求选型的核心逻辑就是看两件事要不要传参、要不要绝对防御。都不需要直接用静态内部类或静态局部变量需要传参就用call_once版本遇到被反射、序列化拆台的场景先看第四章的防御手段再考虑是否升级成枚举。4. 两个专门拆单例的“隐藏刺客”反射与序列化4.1 反射如何绕过私有构造Java里的私有构造能防住“普通人”防不住“会反射的人”。看这段代码Class? clazz Singleton.class; Constructor? ctor clazz.getDeclaredConstructor(); ctor.setAccessible(true); // 打开私有方法的访问开关 Singleton broken (Singleton) ctor.newInstance();运行之后broken和Singleton.getInstance()返回的不是同一个对象。你的单例就这么被一个“后门”变成了非单例。防御方式是在私有构造里加一道检查private Singleton() { if (Holder.INSTANCE ! null) { throw new IllegalStateException(单例构造已被调用禁止反射创建); } }原理是私有构造被反射调用时check一下静态内部类的实例是否已经存在。如果存在说明正常流程已经初始化过了反射这次应该是“重复创建”直接抛异常。但要提醒一句这个防御不完美。攻击者可以先反射new一个实例出来把Holder.INSTANCE用过再说然后再调getInstance拿到的反而就是反射创建的实例。真要到完全防住这个级别的攻击只有枚举。工程上你要掂量一下自己的类值不值得这么防大多数内部协作类用Holder加构造检查就够了防守等级太高只会让代码越来越别扭。4.2 反序列化如何凭空造出新对象Java对象实现Serializable接口后序列化再反序列化readObject会走一套独立的构造机制。它不走构造函数而是直接在堆上重新分配内存、恢复字段值所以能绕开你的私有构造凭空new一个实例。过程大概是// 序列化 ObjectOutputStream out new ObjectOutputStream(new FileOutputStream(singleton.bin)); out.writeObject(Singleton.getInstance()); // 反序列化 ObjectInputStream in new ObjectInputStream(new FileInputStream(singleton.bin)); Singleton another (Singleton) in.readObject();another和getInstance()返回的对象地址不同单例失效。防御方法是在类里加一个readResolve方法protected Object readResolve() { return getInstance(); }readResolve是序列化机制预留的后门反序列化完成后JVM会调用它用返回的对象替换掉刚才新实例。我们把返回对象替换成真正的单例反序列化攻击被化解。C没有这种运行时反射和序列化框架所以C单例的防御重点不在语言机制上而在设计层面不要在单例里保存那些“反序列化之后应该重新加载”的运行时状态别搞跟Java这种“自动一点点替换”的空间。4.3 一套完整的防御性单例模板Java演示把上面两招合到一起给你一个带防御的完整版本public class SingletonSafe implements Serializable { private static final long serialVersionUID 1L; private SingletonSafe() { if (Holder.INSTANCE ! null) { throw new IllegalStateException(禁止通过反射创建单例实例); } } private static class Holder { private static final SingletonSafe INSTANCE new SingletonSafe(); } public static SingletonSafe getInstance() { return Holder.INSTANCE; } // 防止序列化破坏单例 protected Object readResolve() { return getInstance(); } }这段代码同时覆盖了并发、懒加载、反射、序列化四类问题。测试的时候建议写一个单元测试分别验证“多次getInstance地址相同”、“反射创建抛异常”、“序列化后反序列化地址不变”以后重构也不怕改坏。5. 单例模板的适用边界从Android Fragment误用到游戏全局状态5.1 一个常见的反面教材Fragment里滥用单例看到热词里“fragment 单例模式 oncreateview binding”我不会感到意外因为这确实是很多Android新人踩过的坑。错误写法大概长这样写一个DataManager单例里面持有Fragment的Binding引用或者Activity上下文某Fragment的onCreateView里把Binding塞进单例。表面上是“方便其他位置访问数据”实际上是灾难。Fragment是会被系统销毁和重建的而单例不会。当你切走页面又切回来Fragment重建了一个新的但单例里保存的还是旧的Binding。旧Binding持有的View已经detach了更新界面要么没效果要么直接抛异常还连带着Activity上下文内存泄漏搞不好就是泄漏到应用被OOM。正确做法是用ViewModel加LiveData让数据跟随Fragment的生命周期走而不是把Fragment的生命周期硬绑到全局单例上。单例适合保存“应用级”的全局状态不适合保存“页面级”的UI状态这个边界一定要分清。5.2 游戏开发里的全局管理器好用但危险热词里有“设计模式与游戏完美开发”游戏开发确实是把单例用到极致的一个领域。AudioManager、GameManager、UIManager、EventManager几乎每个系统都是单例因为游戏里的对象分布太散跳转关系复杂全局访问入口能省去大量传参的麻烦。但这个用法有个很大隐患游戏场景切换时这些单例怎么办。场景A加载了角色资源场景B的游戏模式不需要这些数据但全局单例还死死攥着不放内存回收根本收不干净。如果你在某个MonoBehaviour的生命周期里把引用挂进单例场景销毁时引用忘清那这个“安全”的全局管理器反而是内存事故的温床。我的建议是游戏里能用单例的一般是“跨场景必须存在”的音频、设置、网络连接。而“场景内才需要的系统”如当前关卡数据、敌人管理器坚决不要用单例而是挂在场景对象的组件上随场景销毁而销毁。5.3 可测试性灾难单例为什么让单元测试头疼写单元测试的朋友应该深有感触单例对象极难替换。你想测一个依赖日志器的类单例日志器的内部实现可能连了磁盘、网络测试环境根本起不来。你想用Mock对象替换掉它不行getInstance返回的是硬编码的那个实例你没有替换入口只能看着测试失败。这也是为什么现在很多现代框架推崇依赖注入而不是单例。依赖注入把对象的创建和生命周期交给容器管理测试时很容易换成Mock对象。单例则是“我不是不用依赖注入我只是没有入口替换”。所以如果你的类将来很可能被Mock或者被替换那就别写单例。日志器这种全局唯一的还可以接受业务服务类真的不建议靠单例勉强撑着。5.4 什么时候该用什么时候别用一张边界清单适合用单例不适合用单例应用级配置信息页面级/场景级状态日志、线程池、连接池需要多态替换的公共组件游戏里的跨场景管理器容易有多个特例的实体对象系统级工具类需要依赖注入进行测试的业务类判断标准就一句全局唯一这个特性到底是业务需求还是你“懒得传递依赖”找的借口。6. 模板之后的几条补充建议写单例模板这么多年我总结三条比较实在的经验。第一默认选最简单的版本不要一上来就搞DCL。大部分业务场景用静态局部变量或静态内部类就够代码少意味着出错概率低。我见过不少人把DCL抄出错还意识不到因为平时测试根本触发不了并发问题一定要在压力测试或者线上才暴露。第二模板里把防御性代码配齐C里delete拷贝、私有构造Java里加readResolve、构造检查。这些代码看起来多余但能防止团队里其他同事“不小心”绕开约束。防御线路设计的目的不是对抗恶意攻击而是让意外不会发生。第三不要在单例里保存“会随环境变化”的运行时状态。单例的生命周期是整个进程级的状态一旦被污染全项目跟着遭殃。把可变的、易变的逻辑放到外部传参单例只保存它真正需要全局唯一的共享数据。如果有人再让我给一份“单例模式模版”我现在一定是先问清楚场景再给代码。单例模式本身不难难的是搞清楚“全局唯一”是真的业务需求还是你给自己埋的一个雷。把本文开头那四套模板配合选型表一起用足够你在绝大多数项目里写出既正确又干净的代码了。
返回列表