单例模式算是我在面试别人时几乎必问的一个点,也是Java基础里最容易被看穿功底的内容。说实话,很多写了三五年代码的人,让他手写一个线程安全的懒汉式单例,照样会卡壳——要么忘了加volatile,要么说不清为什么双重检查锁能保证线程安全。这模式看起来简单,背后的类加载机制、JMM内存模型、指令重排、反射和序列化攻击,每一层都能挖出不少东西。这篇文章我打算把饿汉式和懒汉式从头到尾拆一遍,重点把线程安全优化的演进过程讲透,最后聊一聊实际项目里我一般怎么选型,以及面试官最爱的几个追问。
1. 单例模式的核心思路与适用场景
1.1 单例模式到底解决什么问题
单例模式的定义很朴素:保证一个类在整个JVM生命周期里只有一个实例,并提供一个全局访问入口。但很多人没想明白的是,它解决的不是"创建对象"的问题,而是"重复创建带来的资源浪费和状态不一致"的问题。
举个例子,假设你写了一个配置管理器,启动时从配置文件加载一堆参数。如果每次用到配置的地方都new一个ConfigManager,那就得反复读文件、反复解析,浪费IO不说,万一某个模块改了配置对象的属性,其他模块拿到的还是旧值,这种状态不一致的问题在线上排查起来非常恶心。单例模式就是让所有模块共享同一个实例,数据天然一致。
从JVM层面看,单例的实现思路无非两条:要么利用类加载机制天然保证只初始化一次(饿汉式、静态内部类),要么用加锁的方式手动保证多线程下只创建一次(懒汉式)。理解了这个底层逻辑,后面那些眼花缭乱的写法就都好记了。
1.2 实际项目里哪些场景该用单例
我归纳了一下,最常见的单例使用场景大致有五类:
- 全局配置类:配置信息只需要加载一份,全应用共享,比如Spring的Environment、自定义的配置中心客户端。
- 连接池/线程池:数据库连接池、HTTP连接池、线程池这类资源密集型对象,重复创建成本极高,必须全局复用。
- 工具类/基础设施:比如ID生成器、缓存管理器、日志门面,它们本身无状态或共享缓存,没必要多个实例。
- 访问共享资源:比如写文件、操作打印任务,多个实例并发操作容易产生冲突,单例可以配合内部锁做统一管理。
- 框架层面的入口:比如Spring的ApplicationContext,你肯定不希望一个应用里有好几个互相不通的容器。
不过也要泼一盆冷水:单例不是万能药。滥用单例最大的问题在于它本质上是"全局状态",会让类之间的依赖关系变得隐蔽,测试时也难替换。我见过有人连一个简单的计算工具类都写成单例,完全没必要——如果你的类没有任何成员变量要维护,直接定义成static方法反而更干净。记住一个原则:先确认"这个类确实需要全局唯一",再考虑用单例,而不是为了用模式而用模式。
1.3 单例的"三要素"和常见误区
无论哪种实现,单例类都有三个共同点:私有构造函数、静态成员变量、静态获取方法。私有构造函数是强制别人不能new,静态成员变量是保存唯一实例,静态获取方法是全局入口。
但这里有一个常见误区:很多人以为构造函数私有就真的没人能创建对象了。实际上,反射机制可以通过setAccessible(true)直接绕过私有构造器的限制,序列化也会在反序列化时重新创建对象。这俩是单例的两大天敌,后面我会专门写一节讲怎么防御。所以面试时如果能主动提一句"我的写法能防御反射和序列化攻击",这绝对是加分项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 饿汉式单例:最朴素的实现
2.1 饿汉式的基本写法与原理
饿汉式是所有单例写法里最直白的一种,直接上代码:
java复制public class ConfigManager {
private static final ConfigManager INSTANCE = new ConfigManager();
private ConfigManager() {
// 加载配置文件、初始化连接等
}
public static ConfigManager getInstance() {
return INSTANCE;
}
public String getConfig(String key) {
// ...
return null;
}
}
原理一句话:静态成员变量的初始化发生在类加载阶段,而类加载的"初始化"阶段由JVM保证只有一个线程执行,天然线程安全。换句话说,饿汉式不需要做任何同步处理,VM已经帮你把线程安全的问题解决了。
这里要注意一个细节:INSTANCE是在类加载时创建的,不管你有没有调用getInstance()。所以"饿汉"这个名字很形象——它很饿,类一加载就急着把对象创建好。如果你的单例对象初始化成本很低,应用启动时创建也没什么副作用,那饿汉式就是最简单可靠的选择。
2.2 饿汉式的静态代码块写法
除了直接赋值,还有一种常用的变体——用静态代码块初始化。这种写法在需要初始化逻辑较多、比如读取配置后再创建对象的场景下更灵活:
java复制public class Con
