
1. 项目概述一套代码里到底能装几个唯一写业务代码这么多年这个类只能有一个实例的需求几乎天天见。你要做一个配置管理器全局读一份配置文件你要维护线程池、连接池资源就那几份你要写日志器所有模块往里写不能各写各的。这些场景如果放任new随意创建轻则资源浪费重则状态错乱、数据互相踩踏。设计模式里的单例Singleton就是专门解决这个问题的保证一个类只有一个实例并提供全局访问点。不过单例虽然结构简单网上搜单例模式能出来二十多种写法。最经典的分法就是饿汉式和懒汉式两派各自的支持者能吵上半天。这篇文章我就从这两派入手把单例模式的来龙去脉、线程安全、序列化防御、反射攻击、C与Java实现差异一次讲透。适合期末复习设计模式的学生也适合工作三五年还说不清双重检查锁为什么必须配volatile的老开发。看完你不仅能写出正确版本还能跟人解释每行代码为什么这么写。先说个题外话。标题写的是设计模式24种其实GoF经典设计模式一共23种24这个数字大概率是加了别的变体或者口误。这不影响单例模式本身的地位——它是23种模式里最简单、也最容易写错的一个。2. 单例模式解决什么问题又带来什么问题2.1 一个类凭什么只能有一个实例你可能会想我写个静态类不就行了所有方法都是static全局直接Config.Load()调用不也没法创建多个实例对静态类在某些场景下确实能代替单例而且静态类天生线程安全、无需实例化。但在面向对象的世界里单例比静态类多几个关键优势。首先单例可以有状态。静态类的方法是无状态的你要存配置项、缓存结果就得自己搞静态字段本质是过程式思维。单例则是一个真正的对象可以继承、可以实现接口、可以传给其他对象做依赖注入。其次单例可以延迟初始化。有些对象创建代价很高比如建立数据库连接池静态类在类加载时就得初始化而懒汉式单例能做到真正用到才创建。单例的代价也很明显它是一个全局访问点意味着任何代码都能拿到这个实例模块之间会形成隐式耦合。测试也不友好——你没法轻易替换掉单例的实例除非提供setter这又破坏了单例保证。所以单例模式一定要用在真正的唯一性场景配置中心、连接池、线程池、日志器、缓存管理器、平台设备管理器。不要把什么类都套单例否则就是全局变量换了个马甲。2.2 饿汉式和懒汉式的本质区别这两兄弟的区别就一句话实例是什么时候创建的。饿汉式Eager Initialization在类加载时就完成实例化。不管你有没有用到实例已经躺在那里等着了。优点是线程安全——类加载机制天然保证了只有一个线程能执行静态初始化。缺点是启动时就占用资源万一这个类永远没被用到资源就白占了。懒汉式Lazy Initialization在第一次调用getInstance()时才创建实例。优点是省资源、启动快符合用到才加载的直觉。缺点是线程安全麻烦——多个线程同时第一次调用时可能各自创建出不同的实例必须用同步机制来约束。实际项目里饿汉式的白占资源问题通常没那么严重因为单例对象往往很小。但如果你单例持有连接池、大缓存、I/O句柄这类重型资源懒汉式的优势就很明显了。3. 饿汉式代码最蠢但往往是最优解3.1 饿汉式的标准写法与线程安全原理Java版本长这样public class ConfigManager { private static final ConfigManager INSTANCE new ConfigManager(); private ConfigManager() { // 初始化配置读取配置文件等 } public static ConfigManager getInstance() { return INSTANCE; } public String get(String key) { return value; } }C版本长这样// ConfigManager.h class ConfigManager { public: static ConfigManager getInstance(); std::string get(const std::string key); private: ConfigManager(); ConfigManager(const ConfigManager) delete; ConfigManager operator(const ConfigManager) delete; }; // ConfigManager.cpp ConfigManager ConfigManager::getInstance() { static ConfigManager instance; return instance; }C里这叫Meyers SingletonScott Meyers在《Effective C》里提出的写法利用函数内局部静态变量的特性。这里要特别说明C11之后局部静态变量的初始化是线程安全的编译器会自动加锁。所以C的饿汉式不仅写法简单还同时保证了线程安全和延迟初始化比Java的饿汉式更优雅——因为Java的饿汉式无法延迟初始化。Java的饿汉式为什么线程安全因为JVM在类加载阶段会执行clinit方法静态初始化块和静态字段初始化而这个方法由JVM保证只执行一次且类加载过程有锁保护。简单说JVM保证任意一个类只会被加载一次初始化也只会执行一次。所以INSTANCE new ConfigManager()这段代码不管多少线程并发调用都只跑一次。3.2 饿汉式的短板和适用边界饿汉式最大的争议点是不管用不用都创建。假如ConfigManager构造时要去读远程配置、建立连接而这些配置在80%的启动场景里根本用不到那饿汉式就白白浪费了启动时间。另外一个更隐蔽的问题Java饿汉式在类加载时就new实例如果构造函数里依赖了其他还没准备好的组件可能抛出ExceptionInInitializerError。这种错误非常难排查因为它发生在类加载阶段堆栈往往只有一行at java.lang.Class.forName0(Native Method)。所以我的经验是单例对象轻量、构造函数简单、项目启动时基本都会用到 → 直接用饿汉式。单例对象重量级、启动阶段不确定是否会被使用 → 用懒汉式或交给IoC容器管理。个人项目、中小型系统饿汉式基本够用别过度设计。4. 懒汉式一场线程安全的攻防战4.1 第一版裸奔的懒汉式线程不安全public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这个版本能通过功能测试但并发下会出大事。线程A判断instance null为true正准备new这时线程B也判断instance null为true也进来了然后A和B各自new了一个不同的实例。结果就是调用方拿到的对象不一样。更糟糕的是这个bug不是必现的它依赖两个线程的执行时序。你跑一百次测试可能都没问题上线后突然有一天用户量上来就炸了。所以面试题里考绝不推荐用这个版本考的是你是否知道它为什么不安全。注意这里还有重排序问题instance new LazySingleton()不是原子操作它分三步——分配内存、调用构造函数、把引用指向内存。JVM可能把2和3重排序另一个线程读到引用不为null但对象还没构造完的中间状态。这也是后面DCL需要volatile的原因。4.2 第二版加synchronized安全了但慢了public static synchronized LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; }给getInstance()整个方法加锁一次只有一个线程能进入线程安全了但问题也很明显读操作也要排队。当instance已经创建好了后续所有调用都是在读一个非null字段根本不需要锁。结果就是一个高并发的读场景所有线程都堵在同一个锁上性能大幅下降。在锁竞争不激烈的小项目里无所谓但作为通用方案显然不够好。锁被争用的时候吞吐量可能降低一个数量级这就是synchronized方法版懒汉式最大的问题把写锁用在了读路径上。4.3 第三版双重检查锁DCL——精妙的折中public class LazySingleton { // volatile是必须的 private static volatile LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { // 第一次检查不加锁 synchronized (LazySingleton.class) { if (instance null) { // 第二次检查加锁 instance new LazySingleton(); } } } return instance; } }双重检查锁Double-Checked LockingDCL的思路是只有在instance null时才进入同步块一旦实例创建完成后续调用直接走第一次检查不需要碰锁。这个设计看起来完美但有个关键细节instance必须是volatile。为什么我之前提到过instance new LazySingleton()的步骤可能重排序。如果没有volatile线程A执行new时发生重排序先内存分配、再指针赋值、后构造线程B看到instance ! null直接返回一个未完全构造的对象。volatile在Java 5之后提供了禁止重排序可见性保证确保new的操作顺序不会被编译器乱改也确保一个线程写入instance的值能立刻被其他线程看到。所以记住了DCL不配volatile等于白搞。但DCL也不是毫无瑕疵。在C的旧标准C03里DCL同样依赖编译器是否保证线程安全所以老代码里DCL没少出问题。C11之后更推荐直接用Meyers Singleton放弃DCL。Java版的DCL是主流方案但现在也用得少了因为下面这个更简洁。4.4 第四版静态内部类——兼顾懒加载和线程安全public class LazySingleton { private LazySingleton() {} private static class Holder { private static final LazySingleton INSTANCE new LazySingleton(); } public static LazySingleton getInstance() { return Holder.INSTANCE; } }这个版本叫Initialization-on-demand holder idiom按需初始化持有者模式。原理外部类LazySingleton加载时内部类Holder不会被加载只有第一次调用getInstance()时JVM才会加载Holder并初始化它的静态字段。而类加载初始化是线程安全的跟饿汉式的原理一样所以天然既满足懒加载又满足线程安全。我第一次看到这个写法时拍案叫绝——它用JVM的类加载机制同时解决了两个问题没有任何显式锁代码也简洁。唯一的缺点你得额外写一个静态内部类理解成本稍高一点。但实际使用中这是我在Java里最推荐的单例写法的候选者之一。4.5 第五版枚举单例——让Joshua Bloch亲自推荐的狠角色public enum Singleton { INSTANCE; private int count; public void increment() { count; } }没错Java枚举也可以是单例。这个写法有几个核武器级别的优势枚举实例的创建由JVM保证天然线程安全枚举天然抵抗反射攻击反射无法创建枚举实例枚举天然抵抗序列化攻击序列化机制对枚举有特殊处理反序列化不会产生新实例。如果你还在为如何防御反射和序列化破坏单例头疼枚举直接把这俩问题从根上解决了。Joshua Bloch在《Effective Java》里说单元素的枚举类型是实现单例的最佳方法。所以我自己的偏好是如果你的单例不是必须继承某个类Java里优先用枚举。当然现实项目中很多人觉得枚举不够正统或者团队其他成员不理解这种写法——这种沟通成本也确实是枚举单例在工业界用得少的现实原因。4.6 C版懒汉式C11之后一切回归简单标准C11之后的写法就是前面讲的Meyers Singleton。它在C里兼具懒加载第一次调用getInstance()时才构造线程安全C11标准保证局部静态变量的初始化线程安全编译器内部无锁实现成本极低简洁没有DCL、没有volatile、没有复杂的同步块C单例还有一个隐藏问题单例的析构顺序。如果你有多个单例互相依赖A的析构函数用到了B程序结束时析构顺序是未定义的可能出现崩溃。解决方案通常是用Leaky Singleton故意不释放、或用静态局部变量让析构顺序与构造顺序相反构造顺序可控。C的坑在于你用智能指针管理单例生命周期时全局析构顺序是个大坑。所以C圈子有句玩笑话最好的单例就是写在main函数里、自己在结尾手动管理的那个唯一的对象。5. 为什么说单例模式是所有设计模式里最会翻车的一个我见过太多人把单例模式写出花来线程安全只是入门反射攻击、序列化破坏、克隆破坏、类加载器隔离问题每一关都在等着验证你对这门语言的底层有多熟悉。5.1 反射攻击把你的私有构造器打穿ConstructorSingleton constructor Singleton.class.getDeclaredConstructor(); constructor.setAccessible(true); Singleton s1 constructor.newInstance(); Singleton s2 constructor.newInstance(); System.out.println(s1 s2); // false私有构造函数挡得住普通代码new挡不住反射。setAccessible(true)之后私有性形同虚设。防御手段在构造函数里判断实例是否已存在存在就抛异常。private Singleton() { if (Holder.INSTANCE ! null) { throw new IllegalStateException(Already initialized); } }但严格来说这个判断存在竞态窗口并非绝对安全。如果项目有安全策略需求就得上安全管理器SecurityManager禁止反射或者直接用枚举单例——反射机制不允许创建枚举实例这是语言层面的实操。5.2 序列化破坏一次序列化给你整出个新对象Java对象实现了Serializable后反序列化会通过一条特殊路径创建对象不走构造函数。也就是说你序列化了一个单例反序列化后得到的是一个新的实例单例被打破。防御手段是加readResolve()方法protected Object readResolve() { return getInstance(); }反序列化时JVM会调用readResolve()返回已有的单例实例覆盖反序列化产生的对象。这个方法极其冷门但如果你在分布式缓存、MQ消息体、RPC调用中传输单例对象强烈不推荐一定会踩到这个坑。5.3 克隆破坏你以为没人会clone但有人就是会如果单例类实现了Cloneable或者继承了某个实现了Cloneable的父类调用clone()也会创建新实例。防御手段就一句话重写clone()直接抛异常。Override protected Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(Singleton cannot be cloned); }5.4 类加载器隔离破坏两个ClassLoader两个单例这是最隐蔽的问题。同一个类如果被两个不同的ClassLoader加载它们在JVM里就是两个完全不同的类两个类各自的静态字段也是独立的。所以单例只在同一个ClassLoader的作用域内成立。在Tomcat这类Web容器里不同Web应用可能由不同ClassLoader加载同一个类这时全局唯一就失效了。解决办法确认单例类由哪个ClassLoader负责加载或者把单例尽量放在共享层父ClassLoader中。这也是为什么很多框架用缓存、ConcurrentHashMap来模拟全局唯一而不是依赖类加载机制——因为类加载机制面对多ClassLoader时根本不保证唯一。5.5 C的饿汉式线程安全还要看静态初始化时机C的Meyers Singleton在C11之后线程安全没问题但如果你把它用在动态库.so/.dll里不同动态库各自持有一份静态变量副本跨库的单例也会分裂。这种问题不常见但一旦中招特别让人头秃。排查方法检查符号表确认单例类的符号是否被隐藏、是否在每个动态库里各自实例化。业界经验是C单例尽量放在主程序或固定的一个动态库中不要跨模块分散定义。概览一张表破坏方式影响防御手段推荐程度反射调用私有构造器创建第二个实例构造函数内判空抛异常使用枚举单例高序列化/反序列化反序列化创建新对象实现readResolve()使用枚举单例高克隆创建第二个实例重写clone()抛异常中多ClassLoader每个ClassLoader一个实例统一类加载器放到共享层低C多动态库每个库一个实例单例类固定在单一模块中6. 实操环节手写一个配置管理器把饿汉式和懒汉式都用上前面的原理分析够多了我们来做一个完整的实操项目一个全局配置管理器。需求是从配置文件读键值对内存保存全局唯一访问。我先后用饿汉式和懒汉式各写一版再加一个枚举版本对比。这样你既能直观看到三种写法的差异也能知道面试时怎么演示。6.1 用饿汉式实现配置管理器import java.io.IOException; import java.io.InputStream; import java.util.Properties; public class Config { private static final Config INSTANCE new Config(); private final Properties props new Properties(); private Config() { try (InputStream in Config.class.getClassLoader() .getResourceAsStream(app.properties)) { if (in ! null) { props.load(in); } } catch (IOException e) { throw new ExceptionInInitializerError(加载配置失败, e); } } public static Config getInstance() { return INSTANCE; } public String get(String key) { return props.getProperty(key); } public String get(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }这段代码里注意两点配置文件加载失败时我抛的是ExceptionInInitializerError不是业务异常。因为饿汉式初始化发生在类加载期构造函数里抛受检异常需要包一层。另外ClassLoader.getResourceAsStream是读取classpath资源的推荐方式Config.class.getResourceAsStream也行但路径写法容易搞混建议统一用前者。6.2 用懒汉式静态内部类实现配置管理器import java.io.IOException; import java.io.InputStream; import java.util.Properties; public class Config { private final Properties props new Properties(); private Config() { try (InputStream in Config.class.getClassLoader() .getResourceAsStream(app.properties)) { if (in ! null) { props.load(in); } } catch (IOException e) { throw new ExceptionInInitializerError(加载配置失败, e); } } private static class Holder { private static final Config INSTANCE new Config(); } public static Config getInstance() { return Holder.INSTANCE; } public String get(String key) { return props.getProperty(key); } public String get(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }这段和饿汉版看起来几乎一样但语义完全不同。Config类本身被加载时不会创建实例Holder类只有在getInstance()第一次被调用时才加载加载时才创建实例。所以启动时不管你有没有调用getInstance()Config类都是空壳没有任何资源开销。等到某个模块真正需要配置时实例才初始化。6.3 用枚举实现配置管理器import java.io.IOException; import java.io.InputStream; import java.util.Properties; public enum Config { INSTANCE; private final Properties props new Properties(); Config() { try (InputStream in Config.class.getClassLoader() .getResourceAsStream(app.properties)) { if (in ! null) { props.load(in); } } catch (IOException e) { throw new ExceptionInInitializerError(加载配置失败, e); } } public String get(String key) { return props.getProperty(key); } public String get(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }调用方式从Config.getInstance().get(foo)变成了Config.INSTANCE.get(foo)。枚举单例的好处我已经反复提过但在配置管理器这个场景里还有额外优势枚举天然是Serializable的而且反序列化不会破坏实例唯一性。6.4 三版对比和选型建议实现方式加载时机线程安全反射攻击风险序列化风险推荐场景饿汉式类加载时天然安全有有轻量对象、启动即用静态内部类首次getInstance时天然安全有有重型对象、懒加载需求枚举枚举类加载时天然安全免疫免疫高安全性要求、简单场景配置管理器这种场景三种都能用。我个人倾向在Java里选枚举因为配置管理器通常很短不存在继承需求枚举的简洁和防御性最省心。团队协作时如果你担心别人不熟悉枚举单例用静态内部类版也完全没问题它是最正统的Java单例写法。C对应的配置管理器长这样// config_manager.h #pragma once #include string #include unordered_map class ConfigManager { public: static ConfigManager getInstance(); std::string get(const std::string key, const std::string def ) const; private: ConfigManager(); ConfigManager(const ConfigManager) delete; ConfigManager operator(const ConfigManager) delete; std::unordered_mapstd::string, std::string data_; }; // config_manager.cpp #include config_manager.h ConfigManager ConfigManager::getInstance() { static ConfigManager instance; return instance; } ConfigManager::ConfigManager() { // 解析配置文件填充 data_ } std::string ConfigManager::get(const std::string key, const std::string def) const { auto it data_.find(key); return it data_.end() ? def : it-second; }这个C版本和Java静态内部类版在效果上最接近——第一次调用时才构造线程安全由语言机制保障。但C还有一个独有的语法约束你必须在.cpp文件里定义构造函数。如果你在头文件里直接写ConfigManager() {}那么每个包含头文件的翻译单元都会看到构造函数虽然不会造成多实例但会引入头文件编译依赖也让私有构造函数形同虚设——因为friend或模板元编程可以变相访问。规范做法是构造和析构都放到.cpp里头文件只留声明。这里有个小细节值得多说C的Meyers Singleton返回的是引用不是指针。如果你返回指针调用方可能顺手delete掉导致后续调用全部野指针。返回引用在语义上告诉调用方这个对象不归你管你无权释放。这个选择在API设计上是深思熟虑的。7. 常见问题排查实录我在实战中踩过的单例坑7.1 并发压测一上来单例就不是单例了我第一次写懒汉式跑单元测试全部通过信心满满地提交。结果测试环境的压测服务一启动日志开始错乱各个线程拿到的配置居然不一样。排查步骤第一步先确认是不是多实例在构造函数里加一行System.out.println(create)观察输出次数。如果压测时打印多次单例确实被破坏。第二步确认是哪个线程安全问题登录日志系统发现多个线程同时执行到if (instance null)。修复换成synchronized或DCL版本问题消失。经验功能测试永远测不出线程安全问题压测打印构造日志是最好的排查方法。7.2 volatile被误删DCL变成半残代码DCL版本开发了很久都没问题某天有同事优化代码说volatile在这个场景下是多余的加上反而影响性能就给删了。删完线上出现偶发崩溃——有的线程拿到未构造完成的对象调用方法直接NPE。排查了很久最后用jstack看线程栈反复复现才意识到是删volatile导致的重排序问题。重新加回去问题消失。经验volatile在DCL里不是可有可无它就是DCL的正確性基石。谁删谁负责。7.3 Spring管理的单例和设计模式的单例不是一回事很多人在Spring项目里直接用Component或者Service认为Spring默认单例所以不需要写单例模式。这话对了一半。Spring容器管理的Bean默认确实是单例的singleton scope但它不是通过私有构造器实现的。Spring在运行期用CGLIB动态代理、反射来创建和管理Bean它保证的是IoC容器中的单一实例而不是JVM层面的唯一。所以同一个Spring容器内Component的Bean确实只有一个实例。如果你绕开Spring容器直接new得到的就是不同的实例。设计模式的单例是通过语言机制硬性阻止多个实例Spring的单例只是容器约定。这两种单例的语义不同面试时如果被问Spring的单例和设计模式单例的区别要能说清楚。实际项目中Spring容器的单例已经帮我们解决了绝大多数全局唯一的需求写代码时不需要再去套单例模式。如果一个Bean被Spring管了还自己实现了个私有构造器反而麻烦——Spring要用反射创建它私有构造器会报错。7.4 C单例析构崩溃一个说不清道不明的SIGSEGVC单例生命周期有一个隐性问题如果单例A的析构函数访问单例B程序退出时如果B已经被析构A的析构就会访问已销毁对象产生未定义行为。我在一个插件系统里遇到过两个管理器互相引用退出时崩溃。排查结果两个单例都在程序结束时各自释放析构顺序恰好是先到的后析构后到的先析构形成了悬垂引用。解决方案避免单例之间互相依赖。如果非依赖不可让单例B的生命周期长于单例A比如B在main开头显式构造、main结尾显式释放。或者用Leaky Singletonstatic ConfigManager* instance new ConfigManager();返回指针。程序结束时进程回收内存不调用析构函数。很多库的作者选择这种不析构策略虽然内存不释放但避免了析构顺序问题。代价是内存泄漏检测工具会报告泄漏你得写进文档说明这是有意为之。7.5 多人协作时单例模式的全局状态成了测试噩梦单例的全局性在单元测试里特别难受。你写了一个测试改动了单例内部状态下一个测试跑的时候状态还是脏的。解决思路单例提供reset()方法只在测试代码里调用。用依赖注入替代直接访问单例给方法传参传一个Config实例进去而不是在方法内部Config.getInstance()。这样测试时可以传一个Mock实例。如果项目用了JUnit可以配合BeforeEach重置状态。经验是单例模式写起来容易但要写可测试的单例就要在设计上多花心思。永远不要把业务逻辑直接和getInstance()耦合死最好通过接口或者参数注入的方式调用。8. 单例模式在真实业务里的体面用法与最佳实践单例模式就几行代码但用得好不好差距巨大。结合我自己的项目经验总结几个业界共识层面的最佳实践。8.1 选型决策树饿汉式、懒汉式、枚举怎么选我常用的判断流程非常朴素如果单例对象的创建代价不高启动时基本都会用到 → 饿汉式。代码最简单性能最好连锁都不用考虑。如果单例对象创建代价高且不是每次启动都用 → 静态内部类Java或Meyers SingletonC。懒加载省资源。如果追求极致防御性或者团队能理解枚举写法 → 枚举单例。反射、序列化问题一次性解决。如果用Spring等框架 → 尽量交给IoC容器别自己实现单例模式。这几个选择之间没有绝对优劣。面试里有面试官问你推荐哪种标准答案是看场景并把原理说清楚。能说出饿汉式适合轻量对象、懒汉式适合重型懒加载、枚举防御性最强、Spring容器单例是另一套语义的人通常已经比大多数候选人扎实了。8.2 单例模式与现代编程框架的碰撞近年来IoC容器Spring、Google Guice、Dagger的普及让单例模式的使用频率大幅下降。容器负责管理对象的生命周期和依赖关系我们只需要在配置文件里标记Singleton或者Scope(singleton)就能获得一个容器管理的单例。这比手写单例有什么优势可测试性更强容器可以注入Mock对象可替换性更好切换实现不影响调用方生命周期由容器统一管理不会出现互相依赖时析构顺序的问题。但单例模式本身的思想没有过时全局唯一、共享资源、缓存管理这些需求永远存在。只不过实现手段从自己手写私有构造器变成了容器帮你管实例。8.3 分布式环境下的伪单例与真正的全局唯一这里有一个很容易被忽略的领域在分布式系统中单例指的是每个进程内只有一个实例并不是整个集群只有一个。两个节点部署了同一个服务每个节点都有自己的单例对象它们之间是独立的。如果你需要的是集群级的全局唯一——比如全局ID生成器、分布式锁、分布式配置中心——单例模式解决不了需要引入外部基础设施数据库、Redis、ZooKeeper等。这个边界很多人分不清。把自己的服务做成单例然后以为全局只有一个结果多节点部署后共享状态互相冲突——这是生产中经常出现的设计失误。单例模式的适用边界是单个进程内的唯一性千万不要跨出这个边界去用。8.4 给单例模式写测试从不可测到好测最后分享一个我在实际项目中沉淀下来的测试技巧与其让业务代码直接调Singleton.getInstance()不如把单例作为默认值、允许通过构造函数或者setter替换public class NotificationService { private final Config config; // 正常业务用这个构造器传入单例 public NotificationService() { this(Config.getInstance()); } // 测试用这个构造器传入自定义实现 NotificationService(Config config) { this.config config; } }这种构造器注入 默认读单例的做法既保证了生产环境的单例访问又让测试代码可以传入假的配置对象。看起来多写了一个构造器但换来的是整个服务的可测试性。我习惯在团队规范里推荐这个模式。9. 实操心得单例模式背后的设计哲学写到这里我相信你已经能区分饿汉式、懒汉式、静态内部类、枚举和C的Meyers Singleton也知道怎么防御反射、序列化和克隆了。但单例模式给我最深的启发不在代码本身而在它背后那个朴素的哲学有些东西一个就够了。这个唯一性的需求在工程领域无处不在。配置管理要唯一连接池要唯一日志器要唯一线程池要唯一。但唯一不等于全局变量更不等于处处可访问。单例模式的价值恰恰在于它把唯一性约束封装在类内部通过私有构造器、静态访问点来提供控制力。这是约束即设计的一个典型体现。我也见过很多过度使用单例的代码库。一个类图里十几个单例互相依赖、全局耦合改一个要连带改一圈。所以我会建议**单例模式是可用工具不是默认选择。**真正了解它的代价才能用好它的优势。如果你能回答出为什么这个场景需要全局唯一而不是看网上都这么写你的单例模式才算是真正入门了。单例模式的内容就分享到这里。这段时间在做C项目和Java项目时我每次认真审视单例实现都会发现一些可优化的细节少一个synchronized、加一个volatile、换一种防御方式性能差异可能巨大。设计模式看起来简单真正吃透需要结合语言特性和真实场景反复打磨。如果你也在项目里用单例模式遇到了什么诡异的问题欢迎一起交流排查经验。