单例模式与工厂模式实战:线程安全与架构设计

发布时间:2026/10/5 8:45:22
单例模式与工厂模式实战:线程安全与架构设计 1. 全局认知为什么单例和工厂是登场率最高的两个模式做了十几年开发面试过的人也快上百了我发现一个很有意思的现象23种设计模式里真正能在日常业务代码中频繁见到、每个人都能聊上几句的翻来覆去就是单例和工厂。其他模式要么场景太窄要么抽象层次太高有些模式我甚至一年到头都碰不到一次。而单例和工厂不一样它们解决的是最基础、最普遍的痛点对象应该怎么创建、怎么管理。如果你能把这两个模式吃透不仅写出来的代码更干净别人review你的代码时也会舒服很多。所谓单例模式就是保证一个类在整个进程中只有一个实例存在并且提供一个全局访问点。举个生活化的例子一个公司只能有一个CEO你不能今天见一个CEO明天又冒出另一个CEO来发号施令那公司就乱套了。对应到代码里典型的场景比如日志管理器、配置管理器、线程池、数据库连接池这些对象如果每个模块都各自new一个内存里会有好几份重复的配置数据、连接、日志流既浪费资源又容易造成状态不一致。我记得早期接手过一个老项目日志模块没有做单例结果多个线程各自往文件里写日志文件句柄互相冲突日志内容也乱成一片排查了半天才发现是这么低级的问题。工厂模式则解决另一个问题创建对象的逻辑不该散落在业务代码里。想象一下你去餐厅点菜不需要知道厨师是怎么备料、怎么下锅的你只需要告诉服务员来一份鱼香肉丝后厨自然会给你端出来。工厂模式就是这个服务员把创建对象这件事集中起来让调用方不需要关心具体创建的是哪个类、构造参数怎么传。这样做的好处是当你需要新增一种产品类型时业务调用方一行代码都不用改只需要扩展工厂即可。这两个模式经常放在一起讨论还有一个实际原因单例模式创建的往往就是一个工厂对象。这就好比工厂是一个全局唯一的对象生产车间既要保证车间只有一个又要保证车间能生产各种产品。两者结合几乎成了大型项目的基础设施标配。接下来的内容我会分别拆解这两种模式的实现方式、选型逻辑和踩过的坑代码会用Java和C各写一遍对照这两个语言在单例实现上有些关键差异新手很容易踩雷。2. 单例模式五种实现方式与线程安全深度拆解2.1 懒汉式最直观的实现却是一颗定时炸弹先看最基础的懒汉式写法Java版本public class Singleton { private static Singleton instance; private Singleton() { // 私有构造禁止外部new } public static Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }这种写法在单线程环境下完全没问题逻辑也很清晰第一次调用getInstance时才创建实例之后直接复用。懒就懒在不到万不得已不创建省资源。但是多线程环境下它就废了。假设线程A和线程B同时进入getInstance方法两个线程都判断instance为null然后都能进入if块各自new一个实例出来单例就失效了。更麻烦的是即使不是同时进入也可能出现线程A已经new完正在赋值线程B刚好读到了旧值拿到的还是null。解决这个问题最直接的办法是加synchronized关键字public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; }但这种方式性能很差因为getInstance是高频调用方法每次进来都要抢锁而实际上只有第一次创建时才需要同步后面都是读操作。这就好比每次去食堂打饭都要过安检明明大家都知道饭已经打过了还得重复检查白白浪费时间。我之前做过一个简单的压测这种写法在多线程高并发场景下性能损耗可以达到数倍在真正的服务端项目里是不能接受的。2.2 双重检查锁C和Java的两种命运为了既保证线程安全又不损失性能经典的做法是双重检查锁DCLDouble-Checked Lockingpublic class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查不加锁 synchronized (Singleton.class) { if (instance null) { // 第二次检查加锁 instance new Singleton(); } } } return instance; } }第一层判断就是为了过滤掉大多数进入方法但实例已经创建好的情况不用抢锁只有实例确实为null时才进入同步块在锁内部再做一次检查防止多个线程同时通过第一层判断后重复创建。这个双重检查的写法在Java里有一个关键点instance必须用volatile修饰否则在极端情况下会出大问题。为什么必须volatile因为instance new Singleton()这行代码在CPU执行时不是原子的它本质上是三步分配内存、调用构造方法初始化对象、把内存地址赋值给instance引用。在Java内存模型的规则下编译器和CPU允许指令重排也就是第三步可能先于第二步执行。线程A执行完第一步和第三步后第二步还没执行完线程B这时候进来看到instance不为null直接拿去用得到的却是一个半初始化的对象里面的字段都是默认值一调用就出问题。volatile关键字能禁止这种重排序保证赋值操作一定在对象完整初始化之后。但是C的DCL在早期是很尴尬的因为在C03标准里并没有volatile的多线程语义volatile只是告诉编译器别把这个变量的读写优化掉根本不解决指令重排问题。直到C11标准引入了原子库和内存序memory order这个问题才算真正有了标准解法。现在的推荐写法是class Singleton { private: static std::atomicSingleton* instance; public: static Singleton* getInstance() { Singleton* p instance.load(std::memory_order_acquire); if (p nullptr) { std::lock_guardstd::mutex lock(mutex_); p instance.load(std::memory_order_relaxed); if (p nullptr) { p new Singleton(); instance.store(p, std::memory_order_release); } } return p; } };这段代码里的acquire和release是什么含义呢release存储的意思是说在我这个存储之前的所有内存操作都不能被重排到这个存储之后其他线程通过acquire加载读到这个值时就能保证看到释放侧之前所有的修改。简单理解就是release相当于我把货架上的商品摆整齐了然后贴出已上架的告示acquire相当于看到已上架告示的人就能放心地认为商品是完整的。这样就能确保拿到指针的线程一定看到一个完整构造好的单例对象。2.3 饿汉式与静态内部类线程安全为何不再需要加锁双重检查锁虽然解决了性能问题但代码复杂度和理解成本都上去了。如果你不需要懒加载——也就是程序启动时就创建好实例那么饿汉式是最简单的选择public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }它的核心原理是JVM在类加载阶段就会完成静态变量的初始化而类加载机制本身就保证了线程安全。多个线程访问同一个类时JVM内部有锁机制保证类只会被成功加载一次静态变量的赋值也只会执行一次。所以饿汉式天生就是线程安全的不需要加任何同步控制。但饿汉式的缺点是只要类被加载有可能只是被引用了一下还没调用getInstance实例就已经创建了。对资源要求苛刻的场景比如一个大项目里有很多单例组件如果全是饿汉式启动过程会一次性初始化所有东西内存和初始化时间都会飙升。静态内部类的写法恰好弥补了这个缺陷这也是我认为Java里最优雅的单例实现方式public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }静态内部类SingletonHolder只有在getInstance方法第一次被调用时才会被JVM加载从而触发INSTANCE的创建。也就是说它同时实现了懒加载和线程安全线程安全靠的还是类加载机制懒加载靠的是外部类加载时不会加载内部类这一机制。既没有锁也没有复杂的判断逻辑代码简洁得不能再简洁这是我个人最推荐的Java单例写法。2.4 枚举单例防御反射与序列化的终极方案如果追求极致的安全Java里还有一个容易被忽略的写法——枚举单例public enum Singleton { INSTANCE; public void doSomething() { // 业务逻辑 } }这种方式虽然看着简单但它天然解决了两个很隐蔽的问题。第一是反射破坏普通单例的私有构造函数可以通过setAccessible(true)强行调用然后new出第二个实例。而枚举类在JVM层面就没有无参构造可以被反射调用即使强行攻击也会抛异常。第二是序列化破坏如果单例类实现了Serializable接口反序列化时会通过特殊机制直接创建一个新实例不走构造函数单例就破了。普通类的解决办法是在类里加readResolve方法返回已有实例但枚举类从语言层面根本不允许反序列化创建新实例。不过实际工作里用枚举单例的人确实不多因为团队习惯和代码风格问题在一个全是普通类的项目里突然冒出个枚举很多人不理解。而且枚举单例的可读性相对差一些不太好承载复杂的初始化逻辑。我的建议是如果你的单例涉及序列化或者对安全要求极高比如支付核心模块用枚举是最稳的一般业务场景静态内部类完全够用。2.5 C的Meyers单例我见过最简单的线程安全实现C里还有个非常取巧但极其优雅的写法叫Meyers单例用局部静态变量实现class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } private: Singleton() {} Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; };为什么这么简单就能线程安全因为C11标准明确规定函数内局部静态变量的初始化是线程安全的编译器会自动生成保护代码确保多个线程同时第一次调用getInstance时只有一个线程会执行构造其他线程会阻塞等待初始化完成。这相当于编译器帮你做好了之前DCL需要手动做的所有事。代价就是没有懒加载第一次调用必然触发构造和不能控制构造时机但绝大多数场景这都不是问题。我见过有些老项目还在用C03时代的对拍写法没有delete拷贝构造导致外部可以Singleton a Singleton::getInstance()复制一份实例出来单例名存实亡。你这个class至少要把拷贝构造和赋值运算符禁掉否则这个单例是锁不住的。3. 工厂模式从简单工厂到抽象工厂的演变逻辑3.1 简单工厂一个工厂类里的if-else工厂模式最容易上手的是简单工厂它不是GoF那23种设计模式中的正式成员但写起来最直白public class WeaponFactory { public static Weapon create(String type) { if (sword.equals(type)) { return new Sword(); } else if (gun.equals(type)) { return new Gun(); } else if (bow.equals(type)) { return new Bow(); } throw new IllegalArgumentException(未知武器类型: type); } }调用方拿到的只是Weapon接口或者抽象类不需要知道具体是哪个实现类。这么做最直接的价值是把创建哪个类的判断逻辑从业务代码里抽了出来。游戏里的拾取装备、商店购买、Boss掉落都可能需要不同武器如果在每个系统里都写一遍这种if-else去判断创建代码会到处都是一样的碎片。集中到WeaponFactory后新增武器只需要改动一个文件。但简单工厂的问题很明显首先新增一个类型就要改动工厂类的判断逻辑违背了开闭原则对扩展开放对修改关闭其次如果产品类型非常多工厂类会膨胀成一个巨大的if-else集合再往下就是switch-case维护体验很差。所以它适合产品种类少、变动不频繁的场景比如常量解析、消息类型创建。我自己经常在Parser相关的代码里用到它因为消息类型虽然多但相对固定。3.2 工厂方法模式把创建动作延迟到子类工厂方法模式把创建对象这件事从一个大工厂类拆散到各个子类中每个子类负责创建自己对应的产品public abstract class EnemyFactory { public abstract Enemy createEnemy(); public void spawnEnemy() { Enemy enemy createEnemy(); enemy.spawn(); } } public class ZombieFactory extends EnemyFactory { Override public Enemy createEnemy() { return new Zombie(); } } public class BossFactory extends EnemyFactory { Override public Enemy createEnemy() { return new Boss(); } }注意这里的模板方法思想spawnEnemy是父类定义好的完整流程但具体创建什么敌人由子类决定。也就是说父类不关心到底创建的是Zombie还是Boss它只负责协调流程。当你新增一种怪物时只需要新增一个子工厂改一行传入的工厂对象其他所有逻辑不动。这解决了简单工厂每加一个类型就要改工厂类的痛点。工厂方法模式的代价是类的数量翻倍每加一个产品就要加一个对应的工厂类。这个代价在小项目里可能显得多余但当一个项目里需要为不同平台Android/iOS/Web、不同数据库MySQL/PostgreSQL提供同一套接口的不同实现时工厂方法模式的扩展性优势就非常明显了。每个平台一套子工厂互不干扰。3.3 抽象工厂模式创建一族相关对象抽象工厂模式是工厂方法模式的延伸解决的是几个产品之间有固定搭配关系的场景。比如游戏里一个角色实例不仅需要武器还需要坐骑、套装皮肤这三者通常是一起被创建出来、并且配套使用的public interface CharacterFactory { Weapon createWeapon(); Mount createMount(); Skin createSkin(); } public class KnightFactory implements CharacterFactory { Override public Weapon createWeapon() { return new Sword(); } Override public Mount createMount() { return new Horse(); } Override public Skin createSkin() { return new IronArmor(); } } public class ArcherFactory implements CharacterFactory { Override public Weapon createWeapon() { return new Bow(); } Override public Mount createMount() { return new Wolf(); } Override public Skin createSkin() { return new LeatherArmor(); } }使用抽象工厂的核心意义在于保证产品族的配套一致性。如果你用两个独立工厂分别创建武器和坐骑很容易出现骑士拿了一把弓弓手骑着一匹马这种搭配混乱的情况。抽象工厂从入口就绑定了这一族产品必须一起出现。缺点是扩展产品族中的新品种非常痛苦比如现在要添加一个护盾产品CharacterFactory接口以及所有实现它的工厂类全部要改一遍。这是抽象工厂的固有缺陷所以这个模式更适合产品族结构稳定、但不同系列之间变化多的场合。3.4 Unity场景中的工厂模式应用参考不少做游戏的朋友搜到工厂模式是因为在Unity里遇到了对象管理的问题。Unity里创建对象一般用Instantiate但如果你的项目同时存在多种敌人、多种子弹、多种掉落物直接在MonoBehaviour各个脚本里写Instantiate会非常散乱。用工厂模式重构之后一般会长这样public class EnemyFactory { private DictionaryEnemyType, GameObject prefabMap; public GameObject CreateEnemy(EnemyType type, Vector3 pos, Quaternion rot) { if (!prefabMap.ContainsKey(type)) { Debug.LogError(未知敌人类型: type); return null; } return Object.Instantiate(prefabMap[type], pos, rot); } }这种做法的价值在哪里第一把敌人prefab从哪来这件事集中到一个字典里策划加新敌人只需要在配置表和工厂字典里注册一次第二生产逻辑比如某些敌人出生时需要对位置做偏移、需要挂上特殊脚本统一在工厂里处理不会散落在各个Spawn脚本中。我自己在游戏项目里还喜欢把对象池Object Pool和工厂结合起来工厂负责创建池子负责回收复用两者协同能显著减少频繁实例化和销毁带来的卡顿。搜索热词里提到unity工厂模式大概率就是碰到了这类问题思路理清楚再写代码会顺手很多。4. 模式联手单例工厂架构的完整实战设计4.1 需求场景与整体架构单独用单例和单单独用工厂都不算难难的是理解它们怎么配合。我在一个Spring Boot网关项目中实践过这种组合架构这里梳理一个完整的例子场景设定为一个日志收集系统需要支持不同的日志输出端控制台、文件、远程服务同时整个系统中只允许存在一个日志管理入口所有模块都通过这个入口来记录日志。架构设计如下LoggerManager全局唯一的日志管理入口采用单例模式负责初始化日志配置、接收各模块的日志请求。LoggerTarget日志输出端的抽象接口定义output(String msg)方法。ConsoleTarget、FileTarget、RemoteTarget三种具体输出实现。TargetFactory采用简单工厂型号不大会变化根据配置创建LoggerTarget实例这个工厂对象本身由LoggerManager拥有。这样拆分后模块之间完全不直接依赖LoggerTarget的具体实现类。比如订单模块需要打日志它只调用LoggerManager.getInstance().debug(订单创建成功)LoggerManager再根据当前配置的target从TargetFactory取一个输出端对象。新增一种日志输出端比如钉钉、企业微信告警只需要写一个新Target类在工厂里注册一下整个业务模块零改动。4.2 核心代码实现与设计要点Target接口和实现类public interface LoggerTarget { void output(String msg); } public class ConsoleTarget implements LoggerTarget { Override public void output(String msg) { System.out.println([console] msg); } } public class FileTarget implements LoggerTarget { private String filePath; public FileTarget(String filePath) { this.filePath filePath; } Override public void output(String msg) { // 追加写入文件的具体IO逻辑 } }TargetFactory用简单工厂就够了public class TargetFactory { public static LoggerTarget create(String type, Properties config) { switch (type) { case console: return new ConsoleTarget(); case file: return new FileTarget(config.getProperty(log.file.path)); case remote: return new RemoteTarget(config.getProperty(log.remote.url)); default: throw new IllegalArgumentException(unsupported target: type); } } }LoggerManager采用静态内部类单例public class LoggerManager { private LoggerTarget target; private LoggerManager() { Properties config loadConfig(); String targetType config.getProperty(log.target.type, console); target TargetFactory.create(targetType, config); } public static LoggerManager getInstance() { return Holder.INSTANCE; } private static class Holder { private static final LoggerManager INSTANCE new LoggerManager(); } public void debug(String msg) { target.output([DEBUG] msg); } }这个设计最关键的地方是构造时机与控制反转LoggerManager的构造方法里读取配置并创建对应的Target而这个构造过程只会在Holder类第一次被加载时执行一次也就是getInstance第一次被调用时。后面无论哪个模块调用getInstance拿到的都是已经完全初始化好的实例Target也已经根据最新配置创建完毕。你若是在初始化完成后修改了配置文件这里不会生效因为单例的构造已经执行过了——这是单例的固有限制也需要在业务上明确配置在启动时读取一次即可。这里要注意一个常见设计失误有人会把根据配置选择Target的逻辑直接放进LoggerManager里而不是抽到TargetFactory里。如果后面Target类型变多了LoggerManager类就会变成既管日志入口又管创建Target的职责混杂类。职责分离后LoggerManager只关心日志往哪写不关心怎么写TargetFactory只关心怎么创建不关心谁会来要。这也是工厂模式最核心的价值——边界清晰。我自己实际写的时候还会把LoggerManager设计成接口实现方便测试时替换mock这也是一种策略模式的思路但放在这两个模式的基础之上讲显得复杂了项目需要时再升级就可以。4.3 为什么不能只用一个模式有人可能会问LoggerManager直接用饿汉单例然后在构造方法里写if-else判断用哪个Target这样不行吗行是行项目小的时候完全够用。但你要考虑扩展和维护成本如果团队要加一种批量异步日志输出你需要改LoggerManager的构造方法如果还要加一种按日志级别分流输出又要改LoggerManager。一个入口类的构造方法里塞满了各种判断逻辑每个新需求都来改一遍这个类单例类会慢慢膨胀成一个上帝类。把创建逻辑迁到工厂单独维护LoggerManager的职责就稳定了——不管日志类型怎么变它的构造方法里始终只有一行target TargetFactory.create(...)。单例解决了全局唯一入口的问题工厂解决了入口内部该创建什么对象的问题。这两种模式的联合使用实际上是很多框架底层的老套路Spring的ApplicationContext本身就是一个大型单例同时它又承担了BeanFactory的角色负责创建和管理所有Bean对象。理解了这个套路你看很多框架源码时会恍然大悟原来里面到处都是这两个模式的影子。5. 高频踩坑与排查记录写给容易翻车的人5.1 Fragment单例与ViewBinding的冲突搜热词里出现fragment 单例模式 oncreateview binding我猜大概率是有人想在Fragment里用单例方式持有ViewBinding对象结果各种空指针、内存泄漏。这里明确说一下Fragment自身不应该做单例ViewBinding更不适合被单例持有。ViewBinding绑定的是具体某个View的生命周期Fragment销毁后View也销毁了binding持有的View引用就成了死引用再往后你想用它更新界面拿到的可能是已经detach的View轻则空指针重则界面改不动但内存还在泄漏。我在项目里见过新人在Fragment的onCreateView里把binding存到一个单例Utils类中结果页面切换几次后就出现了界面状态错乱。正确做法是binding作为Fragment的成员变量在onDestroyView里置null随Fragment的生命周期走。5.2 双重检查锁的指令重排问题Java的DCL如果不加volatile前面我讲过会出现半初始化对象。但实际开发中还有个变种问题有些同学把volatile加在了方法的参数上或者加在了类字段上但不够彻底导致实例的可见性也出问题。我排查过一个线上bug单例在某个线程中拿到的是null另一个线程其实已经初始化过了就是因为漏了volatile导致线程缓存问题。记住一个口诀DCL里单例引用字段一定要volatile而且只能是字段。5.3 单元测试里怎么打破单例的限制很多团队在写单元测试时被单例坑过单例的私有构造函数没法mock你没法在测试用例之间重置状态。我自己的做法是给单例类增加一个包内可见的resetForTest()方法或者用反射在测试里重置INSTANCE。这不是什么高大上的技巧但在团队工程规范里很实用。如果你用的是Spring直接把单例Bean的作用域改成prototype放在测试profile里也行但System.out.println式的Demo项目就没必要为这个纠结。5.4 简单工厂的class类参数写法有些人写工厂时会用Class作为参数public static T extends Weapon T createWeapon(ClassT clazz) { try { return clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException(e); } }这是利用了Java反射机制好处是新增产品类时工厂完全不用改坏处是编译器无法检查你传入的Class有没有无参构造容易在运行时才炸出来。我之前在一个项目里用过这种方式后来发现一个产品类的无参构造里加了带参逻辑导致运行时疯狂报错排查了很久。如果产品类构造逻辑复杂不推荐这种写法老老实实把创建逻辑写在工厂里出了问题也方便排查。5.5 工厂模式滥用也会造成灾难我有一个非常深刻的教训刚学会工厂模式那阵子想把所有new都替换成工厂结果项目里涌现出十几个工厂类每个工厂类对应的产品可能只有一个实现创建逻辑比直接new更绕阅读代码时需要跳好几层才能看到一个真正的对象。后来我给自己定了个规矩如果工厂里只有一个产品的分支、并且这个产品不会有其他替代实现直接new就好别为了模式而模式。设计模式是工具目的是解决问题不是为了在代码里秀肌肉。6. 面试里关于这两个模式的高频追问整理如果你是在准备面试或者期末考这几个点几乎必考单例模式的线程安全实现有哪些DCL为什么需要volatile静态内部类单例为什么是线程安全的简单工厂、工厂方法、抽象工厂三者的区别和适用场景分别是什么还有些面试官会故意挖坑单例能不能被反射破坏能不能被序列化破坏枚举单例为什么天然免疫这两种破坏这些内容我上面都提到了如果你能用自己的话把类加载机制保证线程安全这件事讲清楚面试官通常会认为你是真懂而不是背的。另外要提醒一点面试时如果有人问你最常用哪些设计模式不要只说单例和工厂最好能带出它们在你实际项目中的具体场景比如我在网关里用单例管理了全局配置同时通过工厂模式按路由类型创建不同的处理器。能举一个真实场景比背十遍定义都有说服力。7. 最后聊聊我自己的使用体会这两个模式我在不同项目里反复用过、也反复踩过坑最后沉淀下来的经验就三条第一单例不是哪里都需要别为了省一个对象就把一个无状态工具类设计成单体很多时候一个普通的public类加静态工具方法就足够了第二工厂的边界感要把握好不是所有对象的创建都值得抽一个工厂出来只有创建逻辑有重复、或者产品种类有明显变化趋势时才值得第三模式之间是可以组合的单例负责唯一入口工厂负责对象生产策略模式可以配合同一接口的不同实现切换观察者模式可以在工厂生产出对象后自动通知订阅方代码的世界里没有银弹只有合适不合适。希望这篇文章能帮你少走一些我当年走过的弯路。