ARTICLE DETAIL

资讯详情

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

单例模式全解析:五种实现、线程安全与反射序列化避坑指南

单例模式全解析:五种实现、线程安全与反射序列化避坑指南 单例模式可能是大家写的第一个设计模式也可能是被滥用得最多的一个。它的核心诉求一句话就能说完保证一个类在进程内只有一个实例同时提供一个全局访问入口。配置读取、连接池、日志管理器、线程池……这些对象一旦被多实例化轻则浪费资源重则状态错乱所以需要单例约束。但这篇文章不打算只给你背八个实现方式而是想借着单例模式聊清楚几件容易被忽略的事它真正解决了什么问题、在什么场景下会失效、以及作为业务开发怎么选型才不翻车。适合刚接触设计模式的新手也适合写过几年代码但想系统梳理一遍的工程师。1. 先想清楚单例模式到底解决什么问题又引入了什么问题1.1 单例模式的本质一个实例一个入口单例模式的定义很朴素保证一个类在整个运行期间只有一个实例并提供一个全局访问点。实现上通常由三部分组成私有化构造器、用静态字段保存实例、通过静态方法对外返回这个实例。私有化构造器是“禁外令”静态字段是“唯一的仓库”静态方法是“唯一的取货口”。我习惯拿一个生活场景类比一个家庭只有一个水表总闸所有水龙头都连到同一个总闸上。单例模式并不是禁止你装水龙头而是保证无论哪个房间开水走的都是同一套管道、同一个计数表。对应到代码里不同的业务模块都是“水龙头”它们通过统一入口拿到的都是同一个底层对象不会出现A模块改了一版配置、B模块还在用另一版配置的情况。严格来说设计模式书把单例归为创建型模式但它比一般创建型模式更“霸道”因为它同时管住了“创建”和“访问”两个环节。也正是因为这种霸道用不好它会带来比不用更大的隐患。很多团队把单例当成“全局变量专用类”在使用这其实是本末倒置。1.2 哪些场景真的需要单例哪些只是“你以为需要”在我看来真正需要单例的场景通常只有三类。第一类是高开销共享资源。比如数据库连接池、HTTP连接池、线程池这类对象创建代价高、维护连接需要状态如果每个业务类都自己 new 一个池子连接数会翻好几倍数据库和内存都会被拖垮。第二类是全局唯一的状态源。比如系统配置、开关切换、日志句柄、缓存中心它们本身就应该只有一份数据多实例化会导致状态分裂同一个 key 在你这里是 A 值在我那里是 B 值线上排查会让人崩溃。第三类是访问受限的外部资源。比如打印机、串口设备、某个硬件控制卡多个实例同时写设备会让物理设备冲突。但这些都绕不开一个前提你需要确认“唯一”到底发生在哪个层面。单例保证的是“同一个类加载器、同一个 JVM 进程内只有一个实例”不是分布式多节点全局唯一。如果你的系统部署了三个节点每个节点各有一个单例那它们之间并不共享状态。还有一类常见误用是“我怕重复 new 浪费内存”如果这个对象只有几 KB、构造又不涉及 IO 或网络那它即使被 new 几十次也无所谓强行套单例反而增加了耦合。1.3 使用单例的代价全局状态与测试噩梦先说全局状态。单例本质上是把实例挂到了全局作用域任何地方拿到它都能调用方法这意味着全局可写。最典型的问题就是有人在单例里放了一个 HashMap业务代码里到处 set线程 A 写入的数据线程 B 看不到、或者看到的是半路修改的脏数据排查成本极高。单例内部的任何可变状态都等于一个“隐形的全局变量”这是并发问题的温床。再说测试。单元测试最喜欢的就是替换依赖比如用 Mock 对象替代真实数据库。但单例通过静态方法直接访问调用方代码里写死了 Singleton.getInstance()测试时你想替换成 Mock会发现根本没有入口。很多人因此不得不去改 Spring 的上下文、或者用反射把单例字段偷偷重置这些操作都在消耗项目的可维护性。还有生命周期问题。单例一旦创建往往到进程结束才会销毁。如果它持有文件句柄、Socket 连接、线程池资源又没有被合理关闭就会成为内存泄漏的源头。普通对象用完可以 GC单例不能被 GC所以在设计时需要额外想清楚它要不要实现关闭接口、什么时候释放资源。我个人的判断标准很简单一个类是否要做成单例先问两个问题。第一它实例化两次会浪费多大的成本或者造成什么后果第二两个实例之间出现状态不一致会产生什么业务影响如果两个答案都不严重我宁愿用普通类生成局部对象也不要为了“单例”而单例。2. 五种实现方式逐一看从懒汉式到枚举2.1 懒汉式最直观但线程安全要当心刚学单例时绝大多数人写出的第一版是这个样子public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这叫懒汉式意思是“用到的时候才创建”。它在单线程环境里没有毛病但放到多线程环境问题立刻就来了两个线程同时进入 getInstance都发现 instance 是 null于是各自 new 了一个对象单例失效。更隐蔽的是指令重排序问题new 一个对象在底层要经过分配内存、初始化对象、把引用赋值给静态字段这三步编译器或 CPU 可能把第三步提前另一个线程看到的 instance 已经不是 null但对象还没初始化完成调用方法直接抛空指针。最直接的修法是给方法加 synchronized让整个 getInstance 变成同步方法。线程安全倒是解决了但代价很大每一个线程拿实例时都要抢锁即使实例已经创建好也得白白被阻塞一次。在高并发读场景下这是完全不能接受的性能瓶颈。2.2 饿汉式简单可靠但可能白等饿汉式的实现是“类加载时就创建”public class EagerSingleton { private static final EagerSingleton INSTANCE new EagerSingleton(); private EagerSingleton() {} public static EagerSingleton getInstance() { return INSTANCE; } }它的线程安全是 JVM 保证的类加载阶段完成初始化类加载本身有锁保护不会出现并发创建问题。代码量最少也不会忘加同步。但我一般不建议在所有场景下一刀切使用饿汉式。两个问题需要注意第一是没有延迟加载如果这个类整个进程都没人用过实例也会被创建白白占用内存。第二是初始化时机太早一旦构造器里有什么资源加载、配置读取而这个操作失败会导致类加载失败连带着启动过程被中断。如果一个类在启动时需要且必须加载用饿汉式完全没问题如果一个类是“备用的”“几天才用一次的”饿汉式就是白等。2.3 双重检查锁定性能与安全的平衡volatile 是关键把同步代码缩小到实例还没创建的那段路径上就有了经典的双重检查锁定代码通常长这样public class DclSingleton { private static volatile DclSingleton instance; private DclSingleton() {} public static DclSingleton getInstance() { if (instance null) { synchronized (DclSingleton.class) { if (instance null) { instance new DclSingleton(); } } } return instance; } }第一层判空是为了避免每次调用都闯入同步块instance 已经存在时直接返回性能接近无锁。第二层判空放在 synchronized 里面目的是防止多个线程同时通过第一层判空后、在同步块里重复创建对象。两层判断各司其职这也是“双重检查”这个名字的由来。这里最关键的一个点是 instance 必须用 volatile 修饰。我之前在网上看过很多教程只贴代码不讲原因结果有人照着写但漏了 volatile线上莫名其妙出空指针。原因是不写 volatile创建对象的指令重排可能让某个线程在第一次判空时拿到一个“分配了内存但还没初始化完成的实例”接下来调用它内部的方法就会出错。volatile 一方面保证了可见性另一方面通过内存屏障禁止了指令重排序确保引用赋值必然发生在对象完全初始化之后。另外多说一句Java 5 以前的 JMM 对 volatile 语义支持不完善那时 DCL 实际上有缺陷现代 JDK 环境下这套实现是可靠的。2.4 静态内部类推荐给大部分业务代码还有一个更优雅的实现也是我在业务代码里最常用的public class HolderSingleton { private HolderSingleton() {} private static class Holder { private static final HolderSingleton INSTANCE new HolderSingleton(); } public static HolderSingleton getInstance() { return Holder.INSTANCE; } }它利用的是 JVM 类加载机制外部类 HolderSingleton 加载时内部类 Holder 不会被立即初始化只有第一次调用 getInstance 时JVM 才会加载 Holder 并创建 INSTANCE。这就实现了真正的延迟加载。同时类加载过程是线程安全的所以无需加锁也没有 DCL 那么复杂的判断逻辑。从代码简洁性、线程安全、延迟加载三个维度看静态内部类都表现得很好。如果有人问我日常开发里选哪个我十有八九推荐它。唯一要补充的是它和饿汉式一样面临反射和序列化破坏单例的问题这个后面专门讲。2.5 枚举实现防御力最强但别被“小众”误导如果看过《Effective Java》应该记得作者明确推荐用枚举实现单例public enum EnumSingleton { INSTANCE; private String config; public String getConfig() { return config; } public void setConfig(String config) { this.config config; } }枚举实现有几个天然优势JVM 规定枚举实例只能有一个构造器是隐式私有的序列化机制对枚举有特殊处理反序列化不会产生新实例反射工具类看到枚举类型会直接抛 IllegalArgumentException不允许通过反射创建第二个实例。也就是说反射和序列化这两大“单例杀手”用枚举可以彻底规避。但我在真实项目里其实较少用枚举并不是因为它不好而是团队里很多成员不熟悉这种写法代码评审时总要解释一遍“为什么枚举也能当单例”。如果你的团队都读过《Effective Java》或者项目里本身就是枚举友好的代码风格那我非常建议用枚举如果大家更习惯传统类写法静态内部类也完全够用。3. 实操环节一个配置管理器的完整演进3.1 第一版能跑就行但根本不能上线假设现在要做一个支付系统的配置管理器负责从 pay.properties 文件里加载费率、网关超时时间、功能开关然后让支付网关、对账服务、管理后台三个模块共用。新手写法往往很直接public class PaymentConfig { private Properties props new Properties(); public PaymentConfig() { try (InputStream in PaymentConfig.class.getResourceAsStream(/pay.properties)) { props.load(in); } catch (IOException e) { throw new RuntimeException(e); } } public String get(String key) { return props.getProperty(key); } }看起来没问题但项目里一旦流传开“直接用就行”的说法就会有人在不同模块里写 new PaymentConfig()。后果是配置文件被反复加载造成多余的 IO 开销每个实例各自持有一份 Properties 对象内存里出现多个副本更危险的是如果某个页面支持动态修改配置代码里调用 set 方法修改了 A 实例的值B 实例完全感知不到就会出现网关读到的超时时间和对账服务读到的超时时间不一致的奇怪问题。我见过一个实际案例运营反馈支付成功率不稳定查了半天发现测试环境有人临时改了配置文件但加载这个配置的类被 new 了七八次缓存里的旧值和磁盘里的新值混在一起展示逻辑完全乱了。这种问题不是单纯靠单例就能根治但至少在单例模式下配置源的“唯一性”会提前暴露出来排查范围能缩小很多。3.2 第二版线程安全了性能却拖垮了意识到配置应该全局共享后第一步通常是给懒汉式加 synchronizedpublic class PaymentConfig { private static PaymentConfig instance; private Properties props; private PaymentConfig() { load(); } public static synchronized PaymentConfig getInstance() { if (instance null) { instance new PaymentConfig(); } return instance; } }这样写线程安全没问题但紧接着就会遇到性能问题读配置是一个高频操作网关每个请求都可能调用 getInstance而 synchronized 锁的是整个方法所有线程在拿配置前都得排队。高峰期请求量一大接口耗时会明显上升又因为锁竞争和线程上下文切换服务整体吞吐量下降。我实际遇到过有人把日志、缓存、配置全都写成“同步懒汉式单例”结果一个请求链路里要连环抢三四把锁压测直接跑出几十毫秒的额外耗时。这个问题的本质是锁的粒度太大了一个只需要实例创建的同步操作被放大成了所有读操作都要经过的全局锁。3.3 最终版静态内部类加 readResolve双保险综合延迟加载和性能考虑最终版我用静态内部类实现同时把反射和序列化防护也加上public class PaymentConfig implements Serializable { private static final long serialVersionUID 1L; private Properties props; private PaymentConfig() { // 防御反射第二次调用构造器时直接拒绝 if (ConfigHolder.INSTANCE ! null) { throw new IllegalStateException(PaymentConfig already instantiated); } loadConfig(); } private static class ConfigHolder { private static final PaymentConfig INSTANCE new PaymentConfig(); } public static PaymentConfig getInstance() { return ConfigHolder.INSTANCE; } private void loadConfig() { props new Properties(); try (InputStream in PaymentConfig.class.getResourceAsStream(/pay.properties)) { if (in null) { throw new IllegalStateException(pay.properties not found in classpath); } props.load(in); } catch (IOException e) { throw new ExceptionInInitializerError(e); } } public String get(String key) { return props.getProperty(key); } // 反序列化时返回已有单例防止产生新实例 private Object readResolve() { return ConfigHolder.INSTANCE; } }这段代码有几个细节值得细说。第一构造器里判断 ConfigHolder.INSTANCE ! null有人会觉得奇怪构造器访问内部类会触发类初始化相当于把单例创建动作提前到构造阶段。这正好达到了防御反射的效果第一次反射调用构造器时类内部会先完成单例创建之后构造器继续执行 loadConfig第二次再想反射创建就会被拒绝。相比“在 getInstance 里判断 instance 是否为空再去抛异常”这种方式更稳因为它不依赖调用方先走 getInstance。第二实现了 Serializable 就必须处理 readResolve否则 Java 反序列化时会绕过构造器、以字节流直接创建出一个新的实例。readResolve 的作用是在反序列化完成后用已有对象替换返回值相当于从机制上把“新对象”换回“老单例”。这个坑很多入门文章不会讲但如果你要把单例对象放到 HTTP Session 或消息队列里传输迟早会踩到。第三配置文件缺失时我抛的是 ExceptionInInitializerError 而不是普通 RuntimeException。原因是类初始化失败后JVM 会标记这个类为不可用后续继续 getInstance 依然会抛异常这能准确暴露“启动环境有问题”避免调用方误以为重新调用就能恢复。4. 那些让单例“失效”的坑反射、序列化、类加载器4.1 反射攻击私有构造器不是铜墙铁壁很多同学以为构造器私有化就万事大吉但反射可以直接绕过访问权限检查ConstructorPaymentConfig constructor PaymentConfig.class.getDeclaredConstructor(); constructor.setAccessible(true); PaymentConfig newInstance constructor.newInstance();上面这段代码在普通类实现上可以成功创建出第二个 PaymentConfig 实例。为什么能成功因为 getDeclaredConstructor 可以拿到私有方法setAccessible(true) 又关掉了修饰符检查newInstance 走的是“不走正常构造流程”的创建路径。防御手段就是我上一节写的那种构造器内部检查发现已有实例就抛异常。但要特别注意一个顺序问题如果单例是懒汉式实现而且调用方先执行了反射创建再调用 getInstance此时静态字段还没被赋值构造器里的“instance 是否为空”判断是挡不住的因为那一刻确实还没有实例。所以如果你决定用“构造器检查”方案最好配合静态内部类或饿汉式确保反射调用构造器时单例实例必然已经存在。最省心的还是枚举反射框架直接不允许创建枚举实例。4.2 序列化readObject 偷偷给你复制一份序列化破坏单例是一个隐蔽性很强的坑。假设你把单例类实现了 Serializable然后往文件里写了一个对象再读回来读到的这个对象和原来的单例不是同一个对象。原因在于反序列化创建对象时不调用构造器而是基于字节流在底层直接分配内存并恢复字段所以它绕过了你所有的构造器防御。对策是补一个 Object readResolve() 方法注意方法签名必须是 protected 或 private在反序列化结束时被调用框架会把它返回的对象作为最终反序列化结果。我的做法很统一只要单例类实现了 Serializable就必然写 readResolve 返回原实例。如果你用的是枚举单例这个坑完全不用管JVM 对枚举的序列化做了特殊限制反序列化永远返回唯一枚举值。4.3 多类加载器与深克隆你永远不知道有几个实例再扩展说两个容易忽略的破坏点。第一个是多类加载器。每个类加载器都会维护一份独立的类元信息同一个类文件被两个 ClassLoader 加载后它们各自的静态字段互不相通相当于两个“平行宇宙”单例自然也是两份。这在 Tomcat 这类 Web 容器里很常见多个 WebApp 各自加载同一个 jar 包时A 应用和 B 应用的数据库连接池单例并不会共享。这不是单例模式自身的问题但要理解“单例的作用域到类加载器为止”。分布式部署也一样三个 JVM 节点上的单例互相独立谁也不能把对方拉到自己进程里来。第二个是 clone。如果单例类实现了 Cloneable 接口克隆出来的对象是一个新实例同样会破坏单例。防御办法有两种要么不实现 Cloneable要么重写 clone 方法让它直接抛异常或者干脆返回当前单例对象Override protected Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(Singleton cannot be cloned); }这些破坏方式不常见但一旦遇到排查起来特别费劲。因为你的代码里可能根本看不到第二次 new对象就这么“凭空”多了一个靠打断点定位会浪费大量时间。5. 常见问题排查与经验速查5.1 三个高频问题定位先给几个我在代码评审和面试里反复遇到的问题做个直接回答。第一个懒汉式加 synchronized 就够了吧为什么还要 DCL答案主要是锁粒度。同步方法的锁覆盖了整个方法体实例创建完了之后每一次读操作仍旧要抢锁DCL 用两层判空把同步块限制在真正的创建路径上实例已存在时完全无锁性能高一个量级。第二个静态内部类和饿汉式都能做到线程安全区别在哪区别在初始化的时机。饿汉式在外部类被加载时就完成初始化静态内部类只有第一次调用 getInstance 时才会加载并初始化内部类。如果你的类确定必然会被使用选哪个都行如果可能一直用不上静态内部类更省资源。第三个Spring 的单例 Bean 是单例模式吗很多人会把两者划等号其实不是一回事。Spring 的“单例”是基于容器作用域一个 Bean 名称在一个 IoC 容器内只有一个实例但它并没有私有构造器也没有全局静态访问点而是由容器统一创建和管理。单例模式更强调“类自己管理唯一实例”Spring 单例 Bean 更强调“容器负责复用对象”代码设计上出发点完全不同不要混为一谈。5.2 一张表选对实现方式日常开发里如果拿不准选哪个可以直接参考这张对比表实现方式延迟加载线程安全防反射防序列化代码复杂度适用场景懒汉式不加锁是否否否低仅单线程或学习demo懒汉式方法加锁是是否否低并发要求极低饿汉式否是否否最低启动时必用的简单共享对象DCL是是否否中需要延迟加载且追求性能静态内部类是是否需额外防御否需 readResolve中业务代码中的常用选择枚举否加载即创建是是是低追求绝对唯一、防御力最强从这张表可以明显看出没有哪个方案在所有维度上都完美。如果你只能记住一条建议我推荐普通业务单例用静态内部类涉及序列化传输或想彻底防御反射用枚举DCL 则更适合面试里展示你对 JMM 和并发模型的理解深度。5.3 我的几条实战心得把零散经验集中说几条都是踩过坑之后才总结出来的。第一单例内部尽量保持不可变或只读。配置类、常量类可以做成单例但尽量不要往单例里塞线程不安全的 HashMap、ArrayList更不要让业务代码在运行时修改单例字段。如果实在需要动态配置请配合并发容器和读写锁仔细设计否则你会在深夜收到“线上数据时对时错”的告警。第二无状态工具类不一定要用单例。如果这个类只有纯方法、没有成员变量直接定义成静态方法就好了比如 StringUtils、DateUtils。加了单例反而多了一层不必要的概念负担。单例的适用对象是有共享状态或共享资源而不是“为了少写一个 new”。第三代码评审时遇到每一个单例我都会追问三个问题为什么必须唯一什么时候初始化内部状态线程安全吗这三个问题问下来大部分“为了单例而单例”的代码会被打回重写。我也建议你把这三个问题固化到团队的评审清单里比口头提醒管用得多。第四一点如果哪天你需要测试一个单例类里的状态不要反射去重置 private 字段那是拆东墙补西墙。更干净的做法是把依赖通过构造器参数传进去让一个类虽然在生产环境以单例提供但在测试环境却能独立创建。说穿了单例是“对外的使用约定”不应该是“代码内部永远的镣铐”。我的习惯是把单例类拆成两部分核心逻辑类本身不设任何静态限制可以自由 new外面再包一层唯一的 Holder 提供全局入口。这样既保证了生产环境的唯一性又没牺牲测试的灵活性算是个人比较推荐的一个折中方案。
返回列表