ARTICLE DETAIL

资讯详情

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

Java单例模式全解析:五种写法、线程安全与防破坏机制

Java单例模式全解析:五种写法、线程安全与防破坏机制 单例模式在Java设计模式里是个很特殊的存在它是23种设计模式中最容易上手的一种五行业代码就能写完但也是最容易翻车的一种——饿汉式、懒汉式、双重检查锁、静态内部类、枚举五种常见写法背后牵扯的线程安全、类加载机制、Java内存模型JMM、反射和序列化原理能把一个看似基础的问题问出花来。我面试Java候选人的时候特别喜欢从这里切进去不是因为单例类技术多新奇恰恰因为它太基础了基础到一眼就能看出一个人是死记硬背还是真在写代码时思考过。网上讲单例类的资料其实非常多什么23种设计模式图解、java基础合集、java八股文里都有它的位置。但大多数内容停留在“贴代码”的程度很少有人把“为什么这么写”“为什么另一种写法有隐患”“哪些框架机制会悄悄拆掉你的单例”这些层面讲透。这篇文章我就把单例类从头到尾捋一遍从它到底解决什么问题开始到五种实现方式的完整解析再到反射、序列化、类加载器三种“拆台”手段最后落到真实项目中的选型和面试官的追问逻辑。不管你是刚接触设计模式的Java初学者还是正在准备java面试题备考这篇文章应该都能给你一些直接能用的东西。1. 从资源浪费到共享状态单例模式真正想解决的三个问题1.1 一个工具类被new满全场的场景我在维护一个老项目时遇到过一件很典型的事某个服务类没有做任何实例控制调用方每次需要的时候直接new Service()。单机流量一旦上来线程池、HTTP客户端、连接池被一遍又一遍地重复创建最后连接数直接被打满服务开始报错。排查下来根本不是业务代码写错而是这些重量级对象根本没有被复用。这种事情真的比比皆是工具类被当成普通类来new、组件对象被频繁实例化、昂贵资源的初始化每个调用方都重复做一遍。当我们允许一个类被无限次new时浪费的不只是内存还有初始化耗时、持有的系统资源连接、线程、句柄更麻烦的是共享状态的一致性会被破坏。单例类的设计本质就是在“实例化”这个入口上加上一道硬约束——构造器私有全局只能通过一个入口拿到同一个实例。1.2 控制实例、全局访问点、资源复用三大核心价值第一个价值是控制实例数量。构造器私有以后外部无法随便new所有调用方拿到的都是同一个对象。你可以类比一下公司里只有一个前台你不管从哪个门进去见到的都是她。第二个价值是全局访问点。通过Singleton.getInstance()这样的静态方法暴露实例调用方不需要自己管理生命周期也不用互相传递对象引用。这里需要区分一个常识单例和全局变量不是一回事。单例仍然是普通对象有构造、有生命周期只是访问入口是静态的。反过来说一个类里全是静态方法也不代表它就是单例。第三个价值是资源复用。如果初始化一个对象需要建立网络连接、加载配置文件多处使用时反复new的开销是很吓人的而复用同一个实例几乎零成本。这也是为什么数据库连接池、线程池在Spring容器里默认就是单例Bean。1.3 单例不是万金油什么场景不该用反过来也要点名几类不该用单例的场景类本身无状态成员变量全都不是共享的方法都是纯静态逻辑这种直接写静态方法就好不需要绕弯做单例。每次调用需要完全不同的上下文数据单例里保存共享状态反而添乱。并发场景下单例内部如果带了可变成员变量又没做同步控制这个“单例”会成为数据错乱的源头。单例模式带来的全局共享状态本身就是一把双刃剑。好处是大家都访问同一份数据坏处是任何一处修改都会影响整个进程。所以使用单例之前先回答一个问题这个类到底因为什么只能有一个实例想不清楚的时候先别急着套模式。2. 饿汉式和懒汉式两种最基础写法背后的设计取舍2.1 饿汉式类加载即创建代码最简但可能白占内存饿汉式的代码是这样的public class EagerSingleton { private static final EagerSingleton INSTANCE new EagerSingleton(); private EagerSingleton() { } public static EagerSingleton getInstance() { return INSTANCE; } }为什么叫“饿汉”因为类一加载到JVM它就急着把实例创建出来饿得等不到第一次调用。为什么这个写法线程安全static final字段在类加载阶段初始化而JVM保证了每个类只会被加载一次也只有一个线程能执行类初始化逻辑所以不存在并发创建问题。但饿汉式的缺点同样明显。如果这个类从头到尾没被用过实例也会被创建白白占用内存。另外如果构造器里依赖数据库连接、远程配置这类外部资源类一加载就触发初始化启动期稍微有点风吹草动就直接失败。在某些极端场景下饿汉式还容易踩到类初始化顺序的坑两个类互相引用时静态字段的初始化顺序可能和你预想的不太一样。不过单例类因为构造器私有、依赖可控这种问题相对少见。2.2 懒汉式把创建推迟到第一次调用却引入了并发问题懒汉式最早的版本是这样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一个。这样资源真正被用到时才消耗实现了延迟加载。问题在于这个版本在多线程环境下会翻车。假设线程A和线程B同时执行if (instance null)两个线程都看到了null然后各自走到创建流程最后内存里就会有两个不同的实例单例语义被直接破坏。更恶心的是两个线程创建出的对象返回给不同调用方行为可能完全不一致这种bug很隐蔽未必每次都能复现往往在流量高峰期才突然冒出来排查时特别头疼。2.3 给getInstance加锁线程安全了性能却塌了最简单的补救是把getInstance()声明为synchronizedpublic static synchronized LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; }加锁后线程安全了但仔细想一下代价synchronized锁住的是整个方法而大多数情况下instance早就不是null了调用方只是想读一下已经创建好的对象也得经历一次锁的获取和释放。在高并发场景下这个锁竞争会拖慢所有getInstance()的调用——明明只有第一次创建才需要保护。更关键的是这种“安全”是用性能换来的设计上并不优雅。我们真正想要的是第一次创建时加锁保证不会跑出两个实例后续读取时完全不碰锁。也就是说锁的粒度应该精确卡在“创建对象”这个动作上而不是包住整个方法——这就是后面双重检查锁要解决的问题。2.4 两个基础版本的取舍小结到这里先把这几个版本的差异用表格列清楚实现方式线程安全延迟加载性能表现使用建议饿汉式安全类加载机制保证否最佳无锁单例实例轻量且启动即用时可选懒汉式不加锁不安全是最佳无锁但有风险单线程场景或教学演示不推荐生产使用懒汉式方法加锁安全是每次调用都竞争锁性能一般低频调用可用但不是最优解这一轮分析下来核心矛盾已经很清楚了我们既想要延迟加载又想要线程安全还不想每次调用都付出锁竞争的代价。这需要更精细的线程安全控制。3. 双重检查锁与静态内部类进阶方案的线程安全细节3.1 双重检查锁DCL的完整代码与两次检查的含义双重检查锁Double-Checked LockingDCL是懒汉式的高频改进版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已经被创建绝大多数调用直接返回根本不碰锁这样“读取”路径上完全没有同步成本。第二次检查发生在锁内假设线程A、B同时通过了第一次检查A先拿到锁创建了instance释放锁后B才拿到锁此时如果不在锁内再检查一次B就会跟着再new一个出来单例被破坏。所以“锁内再查一遍”是为了挡住这种延迟进入的线程。3.2 为什么DCL必须加volatile指令重排才是真正的坑DCL有一个非常容易被忽略的前提instance字段必须用volatile修饰。去掉volatile在JVM的某些实现下代码依然会出问题。出错的原因要从new一个对象的底层步骤说起。在JVM视角里instance new DclSingleton()大致做了三件事分配内存、调用构造器初始化对象、把内存地址赋值给instance变量。这三步的顺序在现代处理器和JIT编译器眼中并不是铁板一块编译器和CPU可能为了性能把操作重排。最危险的重排是先分配内存并赋值地址给instance此时对象还处于默认值状态再执行构造器初始化。也就是说另一个线程在线程A还没真正完成初始化时第一次检查发现instance不等于null直接把这半个对象返回去用轻则空指针异常重则出现诡异的状态错乱。volatile在这里有两层作用第一禁止编译器和CPU对赋值前后的指令重排保证引用赋值一定发生在对象完成构造之后第二保证可见性——线程A对instance的写操作不会被本地缓存住线程B能立刻看到最新值。其实即便没有重排风险可见性问题也会让线程B一直拿着旧的null值去闯锁所以volatile对DCL来说不是可选项而是必须项。这里可以做个类比正常情况应该是“装修好房子才让你验收入住”指令重排等于“钥匙已经递给你了但房子还是毛坯”volatile相当于给整个流程加了一道强制验收工序没装修完就不能交付。3.3 静态内部类把延迟加载和线程安全都交给JVM另一种不需要锁的高级写法长这样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()触发了对Holder的主动使用JVM才去加载它。加载的同时执行INSTANCE的静态初始化而JVM规范保证了同一个类的初始化只会被一个线程执行其他线程会阻塞等待——所以天然线程安全。这种方案等于把懒汉式的延迟加载和饿汉式的线程安全结合了起来不需要手动控制锁也不需要关心volatile代码还更简洁。很多老Java项目里静态内部类就是生产代码中单例模式的默认选择。3.4 两个进阶方案怎么选DCL和静态内部类原理不同实际效果差不多。就我个人的使用感受来说如果是新代码且不打算用枚举优先考虑静态内部类代码更少、思维负担更低DCL适合用来在面试里展示线程同步功底也适合你对volatile语义有十足把握的时候用。但要记住一点手写DCL一旦把volatile去掉或者锁对象写错就会埋下一个隐蔽的并发bug排查起来非常痛苦。4. 枚举单例为何被推荐以及什么样的操作会“拆台”4.1 枚举单例简短到让人怀疑是不是写错了public enum EnumSingleton { INSTANCE; public void doSomething() { // ... } }就这么一段单例就建好了。调用方式也直白EnumSingleton.INSTANCE。为什么《Effective Java》里把它列为单例的最佳实现因为这是JVM级别的保证枚举实例在类加载时被创建而且JVM规范明确规定枚举不能通过反射来创建实例编译器也会在序列化机制里做特殊处理枚举实例反序列化后拿到的还是同一个对象。也就是说前面提到的各种“拆台”手法对枚举基本无效。用枚举做单例也有一个心理门槛很多人觉得枚举是一种“固定常量集合”放在业务单例上语义有点奇怪。我最初也是这么想的用多了才习惯——枚举本质就是个普通的类完全可以承载业务行为。如果团队确实不接受枚举的表达方式用静态内部类也完全可行。4.2 反射setAccessible之后私有构造器形同虚设Java反射可以让私有构造器不再“私有”Class? clazz Singleton.class; Constructor? constructor clazz.getDeclaredConstructor(); constructor.setAccessible(true); Singleton instance1 (Singleton) constructor.newInstance(); Singleton instance2 (Singleton) constructor.newInstance(); // instance1 ! instance2这段代码对之前所有的“类式单例”都能成功拆台。想防御的话最常规的做法是在私有构造器里加保护性判断private Singleton() { if (instance ! null) { throw new IllegalStateException(单例实例已存在不允许反射创建); } }但坦率地说这种方式本质上还是在和反射“比谁更聪明”。攻击者完全可以拿到instance字段通过反射先把它的值改成null再调用构造器。所以在源码层面没有任何一种类式写法能做到绝对防反射只能说加了判断之后常规攻击已经被挡掉了剩下的属于更高阶的安全对抗范畴。4.3 序列化反序列化会绕过构造器如果单例类实现了Serializable又埋了一个大坑。反序列化时不会调用构造器而是根据字节流重新创建对象所以从流里读出来的“单例”和当前内存中的实例是两个不同的对象。修复的方法是在单例类里实现protected Object readResolve() { return instance; }readResolve()是序列化机制预留的钩子反序列化完成后JVM会调用它我们用这个方法直接返回内存中已有的instance替换掉新创建出来的对象。加了它反序列化就破坏不了这个单例了。枚举不需要考虑这个问题因为JVM对枚举的序列化专门有保护逻辑。4.4 类加载器一个“单例”在不同ClassLoader眼里并不唯一最后一个很多人不知道的拆台角度是类加载器。类加载器不同同一个类在JVM里会被加载出多份Class对象每份Class各自的静态变量互相独立。换句话说如果同一个类被两个ClassLoader分别加载了一遍那就会有两份“饿汉单例”同时存在。这种情况最容易出现在使用Tomcat等Web容器的项目里比如父ClassLoader和子ClassLoader各加载了一遍类。对这个问题认知比防御更重要——单例的“全局唯一”本来就限定在同一个ClassLoader内。分布式环境下更要拎清楚多个JVM进程之间不可能靠单例模式共享状态那是分布式缓存、分布式锁的职责不是单例模式能干的事。5. 面试官的追问逻辑与真实项目中的选型建议5.1 面试中最常被打串的六个问题结合我看过的面试记录和平时带新人的经验单例类在面试中的提问基本就是一条追问链写一个单例类。你这个写法线程安全吗为什么懒汉式加synchronized就能保证安全为什么还说它性能差DCL为什么要用volatile去掉会怎么样静态内部类和饿汉式在“加载时机”上有什么区别反射和序列化能不能破坏你的单例怎么防御听说过枚举单例吗它为什么受推荐回答思路我在前面几节已经完整拆开了。这里特别想强调面试官真正在意的不是你能不能背出五种写法而是你能不能解释清楚“为什么”。比如你可以停顿一下说出“volatile解决的是创建对象三步操作中的指令重排”这个深度就已经超过大多数背题选手了。5.2 真实项目里我一般怎么选回到实际开发我一般按照下面这套经验决策如果项目使用了Spring绝大多数场景不需要手写单例直接把Bean交给Spring容器默认为单例。程序员只需要关注Bean内部不要保存有状态的数据。自己写工具类或组件类时优先枚举。代码最短、安全边界最强不用额外操心反射和序列化。如果团队不接受枚举来表示单例选静态内部类方案同样简洁而且没有锁。尽量不要在生产代码里写“懒汉式加方法锁”的版本。虽然安全但性能上不划算只能归为教学写法。还要强调一件事单例模式解决的是“进程内单例”不是“集群内单例”。一个相当常见的错误是把单例当作分布式系统的全局共享状态。我见过有同事把用户信息塞进单例里做缓存上线后因为服务多节点部署用户数据在不同机器上完全错位排查了好久才发现是把单例当成了跨节点缓存。这个认知偏差要是在设计阶段就纠正过来能省下大量线上事故排查的时间。5.3 我踩过的一些单例相关的坑最后分享几个真实踩坑教训。第一个是给单例类加了可变成员变量多线程请求同时在改这个字段导致后续读取的人拿到别人请求的中间状态。单例最大的隐患从来不是创建难而是共享状态难管。如果一个单例一定要保存状态请务必用并发容器、ThreadLocal或者显式加锁把它管好。第二个是序列化问题。我有一次为了把单例对象塞进分布式缓存让它实现了Serializable结果反序列化回来一个新的“假单例”业务数据全错。当时加上readResolve就解决了但排查过程特别折腾因为这种问题不是每次都会触发偶发性很强。第三个是关于枚举单例的认知误区。网上偶尔会讨论“枚举在反序列化时是不是也会创建新实例”这种话题实际上去翻JDK规范和源码就能确认枚举常量在序列化机制里有特殊处理保证全局唯一所以可以放心用。如果把这些经验浓缩成一句话单例模式的本质最终要归结到“一个类到底因为什么只能有一个实例”。把这个问题想清楚写法选型、面试答题、线上排障都会顺很多。
返回列表