单例模式是设计模式里最容易被追问到底的一个。它的写法有六种,每种都能延伸出三四个追问:懒加载怎么保证线程安全、双重检查锁为什么要加 volatile、静态内部类是什么时候初始化的、枚举为什么天然防反射与序列化、单例持有 Context 怎么避免泄漏。答到第五层的人不多,但这一层恰恰是区分"看过书"和"踩过坑"的地方。
先把结论放在前面:单例的核心约束是私有构造 + 全局唯一访问点 + 唯一实例保证。 六种写法按"实例创建时机"与"线程安全手段"两个维度排列:饿汉式(类加载即建)、懒汉式(用时才建,需同步)、双重检查锁(懒加载 + 局部加锁 + volatile)、静态内部类(类加载时懒初始化,靠 JVM 类初始化锁保证)、静态内部类 + 双重检查锁(极端场景加固)、枚举(由 JVM 保证唯一且防反射反序列化)。 Android 上的推荐选择:不关心 Context 的纯工具用静态内部类;需要 Context 的用 Application 本身或持 getApplicationContext();能用 DI 容器管理的就别硬写单例。
机制拆解
每种写法的差异,根源都在三个机制上。第一,类初始化锁。Java 规范保证类的静态初始化(static 字段赋值、静态块)只会被执行一次,并且是线程安全的——同一时刻只有一个线程执行初始化,其他线程阻塞等待。 静态内部类的单例正是靠这个:Holder 类没人访问时不会被初始化,第一次 getInstance() 触发初始化,JVM 保证这一步的原子性,于是既实现了懒加载又不用自己加锁。这是最推荐的一种写法,因为它把并发问题交给了语言规范。
第二,Java 内存模型与指令重排。构造对象在字节码层面是"分配内存 → 赋初值 → 调用构造方法",JIT 与 CPU 都可能重排。若没有 volatile,另一个线程可能看到"引用非空但对象还没构造完"的半初始化状态,于是拿到一个字段全为默认值的对象。加上 volatile 后,写入引用前的动作不会被重排到引用写入之后,读到引用就能看到完整状态。
第三,JVM 对单例的破坏手段。setAccessible(true) 能让私有构造被反射调用,于是 newInstance() 出第二个实例;反序列化也会绕过构造方法重建对象。枚举因为由 JVM 在类加载时创建、无法反射重建(连 name 字段都是 final 反射改不了)、反序列化返回缓存实例,所以被《Effective Java》推荐。
这些坑的正确绕法
最常见的坑是双重检查锁漏掉 volatile,导致别的线程拿到"半初始化"对象。 表现是偶发的 NullPointerException,堆栈指向构造函数里还没执行到的字段,字段值是 null 或 0。这类 bug 在低并发下不出现,测试环境几乎抓不到,上线后在高并发场景才暴露,排查成本极高。 修法很简单:加 volatile,但很多人不知道它为什么必须加——面试时能解释清楚"重排导致引用先发布",这题就过关了。
其次是单例持有 Activity/View 造成内存泄漏。 静态字段引用了界面对象,引用链从静态区一直连到整棵视图树,GC 根可达,于是界面销毁后对象仍然驻留;连续打开详情页,内存曲线呈阶梯上升。 对策有三条:单例只持 Application;需要绑定界面时用 WeakReference 并在 onDestroy 里解除订阅;把这类状态放到 ViewModel 里,随作用域自动清理。
还有一个更隐蔽的坑:静态单例里的状态跨页面共享,导致数据串台。 比如一个 UserCache 单例在登录后被清空,但某个界面还持有了旧数据的引用;或者用户在 A 页面修改资料后,B 页面因单例缓存没刷新而显示旧值。 Android 上更妥当的做法是作用域明确的状态容器:ViewModel 管界面级、Singleton 管应用级、Activity 作用域的缓存用 onSaveInstanceState 或直接随生命周期释放。能用作用域解决的状态,不要塞进单例。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 饿汉式:类加载即创建,线程安全,但不用也占内存
public class Eager {
private static final Eager I = new Eager(); private Eager(){
}
public static Eager get() {
return I; } }
// 2) 懒汉式(线程不安全):用时才建,并发下会建出多个实例
// private static Lazy inst; public static synchronized void check() {
if (inst == null) inst = new Lazy(); }
// 3) 懒汉式 + synchronized:安全,但每次获取都加锁
public class SyncLazy {
private static SyncLazy inst;
private SyncLazy(){
}
public static synchronized SyncLazy get() {
return inst == null ? (inst = new SyncLazy()) : inst; } }
// 4) 双重检查锁:volatile 不可省,省了会拿到半初始化对象
public class Dcl {
private static volatile Dcl inst; // 重排屏障,保证发布有序
private Dcl(){
}
public static Dcl get() {
Dcl local = inst; // 局部变量,减少一次读
if (local == null) synchronized (Dcl.class) {
local = inst;
if (local == null) local = inst = new Dcl(); // 二次检查
}
return local;
}
}
// 5) 静态内部类:Android 推荐写法,懒加载 + 靠类初始化锁保证唯一
public class Holder {
private Holder(){
}
private static class Inner {
private static final Holder I = new Holder(); }
public static Holder get() {
return Inner.I; } // 首次调用才加载 Inner
}
// 6) 枚举:JVM 保证唯一,且防反射与反序列化破坏
public enum EnumSingleton {
INSTANCE;
public void doWork() {
}
}
// EnumSingleton.INSTANCE.doWork();
这段代码值得盯三处:第一处,DCL 里 volatile 与两次检查缺一不可,局部变量则是为了减少 volatile 读并配合后续链式调用;第二处,静态内部类的懒初始化靠"Inner 类第一次被引用时才初始化"这个 JVM 语义,比手写同步更简洁;第三处,枚举式的独特之处不在于懒加载,而在于无法被反射或反序列化创建出第二个实例。
这题在面试里怎么问、怎么答
"六种写法有什么区别,怎么选?"给一张对照表思路:创建时机(类加载 / 首次调用)、同步方式(无 / synchronized / 类初始化锁 / JVM 强制)、是否防反射破坏(只有枚举是)、是否可继承(枚举与静态内部类可被继承实现,饿汉式可继承但私有构造会挡住子类)、使用成本。结论落在选择:默认用静态内部类,需要防序列化与反射就枚举,需要 Context 的单例要额外约束持有对象。
"静态内部类为什么是线程安全的?"答:JVM 规范保证类的静态初始化过程只执行一次且线程安全,其他线程阻塞等待初始化完成。所以 Inner.I 的赋值只有一次,天然可见。
"为什么加 volatile 就能避免半初始化?"答:对象构造在字节码层是"分配内存 → 赋默认值 → 执行构造",没有内存屏障时 CPU 与 JIT 可能重排,导致引用先于内容发布;volatile 的写语义(写之前的操作不被重排到写之后)加上读语义(读到新值能看到此前所有写入),建立了 happens-before 关系。
"Android 上 Application 算不算单例,怎么用?"答:进程内系统只创建一个 Application 实例,天然全局唯一;在 AndroidManifest 声明后由框架创建,attachBaseContext 之后可用;自定义 MyApp extends Application 是标准做法,第三方 SDK 的初始化都放在这里。 也可以用 ViewModelProvider.AndroidViewModelFactory 这类官方机制拿 Context。注意多进程场景下每个进程各有自己的 Application 实例,跨进程共享要用 ContentProvider 或独立服务。
"使用中遇到过什么问题?"案例一:静态 AppContext 字段误写成 Activity 引用,详情页反复打开后内存不降;定位到 getInstance(Context) 里直接存了传入的 Context;修复为统一 getApplicationContext()。 案例二:单例里的缓存用户在退出登录后没清,下一个账号短暂看到上一个账号的数据;定位到 UserCache 是进程级状态;修复为把登录态缓存改成随登录域管理的实例,或在登出时显式清理。
再补一个工程上值得讲清的点:Android 上真正的"全局单例"不止是 Java 单例。 ContentProvider 在应用启动早期(Application 之前)初始化,常用来做跨进程初始化的入口;WorkManager 自己就是一个内部单例式的调度中心;Service 同样是跨组件的全局存在。 理解这些系统级"天然单例"和用 object 写单例的区别,才能在架构讨论时不把两件事混为一谈。另外要记住一条纪律:单例负责"唯一",不负责"万能"。往单例里塞状态、工具、缓存三样东西,测试就无法替换、生命周期无法管理,这是 Android 项目里最常见的"上帝类"来源。
给正在准备面试的你
把这题画成一张二维表:横轴是"创建时机"(类加载时 / 首次调用时),纵轴是"同步手段"(无 / synchronized / volatile + 局部锁 / JVM 类初始化锁 / JVM 强制唯一)。 把六种写法放进对应格子并标上关键缺陷——饿汉式"不用也占内存"、懒汉式"并发建多个"、同步版"每次加锁"、DCL"漏 volatile 出半初始化"、静态内部类"推荐"、枚举"防反射但不能懒加载"。表格下方画一条线,写"Android 默认选静态内部类;需要防反射/序列化选枚举;能用 DI 就不手写"。这张表在面试里说三分钟,比罗列二十个名字有用。
再补工程案例与踩坑——应用落点是审查项目里所有静态字段,确认是否捕获了界面上下文、状态是否有明确的作用域;把工具类里的缓存拆出去交给 DI 或 ViewModel;对确实需要全局唯一的对象补上 Application 而非 Activity 的约束。
复习时别孤立刷题:设计模式入门——单例是创建型里最常被追问的,策略与工厂才是 Android 里性价比更高的两个。
划两句重点:默认用静态内部类(懒加载 + JVM 保证唯一);DCL 的 volatile 不能省,原因是引用先发布导致半初始化;枚举唯一真正防反射与反序列化;单例只持 Application,状态作用域别乱塞。
下一篇聊建造者模式:链式调用为何无处不用在哪些地方——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:设计模式入门:单例、工厂、观察者的-Android-落地
下一篇预告:建造者模式:链式调用为何无处不用在哪些地方
有任何问题欢迎在评论区留言交流。