5、单例模式(创建型):饿汉式、懒汉式、双重检查锁、静态内部类、枚举实现
单例模式,说白了就是保证一个类在整个系统里只有一个实例。你想想看,有些对象我们真的不需要创建多个——比如配置管理器、线程池、数据库连接池。如果到处 new 出来一堆,内存浪费不说,还容易出各种诡异的问题。
我个人习惯把单例模式分成两大类:饿汉式和懒汉式。名字挺形象的——饿汉就是不管三七二十一,先把对象创建好;懒汉则是等到有人真正要用的时候,才去创建。
下面我把五种实现方式挨个讲一遍,每种我都会说说我在项目中踩过的坑。
5.1 饿汉式(线程安全,但可能浪费资源)
饿汉式是最简单的写法。类加载的时候就把实例创建好,JVM 保证了类加载过程的线程安全,所以天然就是线程安全的。
public class SingletonEager {
private static final SingletonEager INSTANCE = new SingletonEager();
private SingletonEager() {}
public static SingletonEager getInstance() {
return INSTANCE;
}
}
嗯,这里要注意:private 构造方法一定要写,不然别人就能 new 出来了。我见过有人忘了写,结果单例变成了「多例」。
缺点:类加载时就创建对象,如果这个类一直没被使用,就白白占着内存。
我曾经在一个微服务项目里用过饿汉式,结果那个单例类里持有一个 50MB 的缓存。服务启动后虽然没人调用它,但内存已经被占用了。后来我换成了懒汉式,才把启动内存降下来。
5.2 懒汉式(延迟加载,但线程不安全)
懒汉式就是等到 getInstance() 被调用时才创建对象。但问题来了——多线程环境下,两个线程可能同时进入 if (instance == null) 的判断,然后各自 new 出一个对象。
public class SingletonLazy {
private static SingletonLazy instance;
private SingletonLazy() {}
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
}
那怎么解决?加 synchronized 呗。但直接加在方法上,每次调用都要走锁,性能太差了。于是就有了双重检查锁。
5.3 双重检查锁(兼顾性能和线程安全)
双重检查锁的思路是:先判断一次 instance == null,如果为 null 才进入同步块,进入同步块后再判断一次。这样只有第一次创建时需要加锁,后续调用直接返回实例,没有锁开销。
public class SingletonDCL {
private static volatile SingletonDCL instance;
private SingletonDCL() {}
public static SingletonDCL getInstance() {
if (instance == null) {
synchronized (SingletonDCL.class) {
if (instance == null) {
instance = new SingletonDCL();
}
}
}
return instance;
}
}
volatile 关键字不能少。它禁止了指令重排序,确保 instance 在被赋值时已经完成了构造函数的执行。
我记得有一次排查线上问题,发现某个单例对象的字段偶尔是 null。查了半天,就是因为忘了加 volatile。JVM 在 new SingletonDCL() 时,可能会先分配内存、再赋值给 instance、最后才调用构造函数。另一个线程拿到的是「半初始化」的对象,字段自然就是 null 了。
5.4 静态内部类(推荐方式之一)
静态内部类的方式既实现了延迟加载,又保证了线程安全,而且代码非常简洁。它的原理是:外部类加载时,内部类并不会被加载。只有调用 getInstance() 时,内部类才会被加载,此时 JVM 会保证类加载过程的线程安全。
public class SingletonHolder {
private SingletonHolder() {}
private static class Holder {
private static final SingletonHolder INSTANCE = new SingletonHolder();
}
public static SingletonHolder getInstance() {
return Holder.INSTANCE;
}
}
我个人比较喜欢这种写法。它没有锁,没有 volatile,代码干净利落。而且 JVM 层面的线程安全比我们自己加锁要可靠得多。
5.5 枚举实现(最安全的方式)
枚举实现是《Effective Java》里强烈推荐的方式。它天然防止反射攻击和序列化破坏。你想想看,反射可以调用 setAccessible(true) 来调用私有构造方法,但枚举的构造方法反射是拿不到的。序列化也是,枚举的序列化由 JVM 特殊处理,不会产生新实例。
public enum SingletonEnum {
INSTANCE;
public void doSomething() {
// 业务方法
}
}
调用方式也很简单:SingletonEnum.INSTANCE.doSomething()。
我曾经在一个金融项目里用过枚举单例。那个项目对安全性要求极高,不允许任何人通过反射或序列化创建第二个实例。用枚举实现,心里踏实多了。
5.6 五种实现对比
| 实现方式 | 延迟加载 | 线程安全 | 防反射攻击 | 防序列化破坏 | 推荐指数 |
|---|---|---|---|---|---|
| 饿汉式 | 否 | 是 | 否 | 否 | ★★★ |
| 懒汉式(不加锁) | 是 | 否 | 否 | 否 | ★ |
| 双重检查锁 | 是 | 是 | 否 | 否 | ★★★★ |
| 静态内部类 | 是 | 是 | 否 | 否 | ★★★★ |
| 枚举 | 是 | 是 | 是 | 是 | ★★★★★ |
从表格能看出来,枚举在安全性上是最全面的。但实际项目中,静态内部类和双重检查锁用得也很多,因为大部分场景不需要防反射和防序列化。
5.7 知识体系图
下面这张图把五种实现的核心逻辑串起来了,方便你对比记忆:
5.8 避坑指南
- 反射攻击:除了枚举,其他四种方式都可以通过反射调用私有构造方法创建新实例。如果你要防反射,可以在构造方法里加一个标志位,第二次调用时抛出异常。
- 序列化破坏:如果单例类实现了
Serializable,反序列化时会创建新对象。解决方案是添加readResolve()方法,返回已有的实例。 - 类加载器问题:如果系统中有多个类加载器,每个类加载器都会加载自己的单例。这种情况比较少见,但如果你在用 Tomcat 等容器,要注意。
- 双重检查锁的
volatile:这个我前面说过了,忘了加就是 bug。而且这个 bug 不是每次都能复现,非常隐蔽。
好了,单例模式就讲到这里。五种实现各有优劣,没有银弹。我个人建议:不需要延迟加载就用饿汉式,需要延迟加载优先考虑静态内部类,对安全性有极致要求就用枚举。
公众号:蓝海资料掘金营,微信deep3321