垃圾回收这题有个尴尬:几乎人人都能答出"GC Roots 有哪几类",但真正靠它定位过线上泄漏的人不多。面试官问"你怎么判断一个对象能被回收",答"看引用计数"的会露馅,答"看可达性分析"的才进入下一轮。这题的分水岭在于能不能把可达性分析讲成一条可操作的判断路径。
先把结论放在前面:现代 JVM 判断对象存活用的是可达性分析,不是引用计数。思路是从一组根对象(GC Roots)出发,沿引用关系逐个遍历,能被遍历到的对象就是存活的,遍历不到的就是可回收的。 GC Roots 主要有四类:线程栈里的局部变量(当前活跃的栈帧持有的引用)、静态字段(类加载后长期存活)、JNI 引用(native 代码通过 JNI 持有的 Java 对象)、活跃线程本身。理解这四类,就掌握了排查泄漏的入口——泄漏的对象是被这四类之一"拖住"的。
机制拆解
讲清可达性分析为什么优于引用计数。引用计数有个致命问题:两个对象互相引用时,计数都不为 0,但它们其实已经与程序不可达了,形成"孤岛"再也回收不掉。可达性分析从根出发按图遍历,不受这种环的影响,是现代收集器共同采用的基础。代价是它需要 STW(暂停)来保证遍历期间对象关系稳定——这也是所有收集器都在努力压缩停顿的根本原因,这个因果链能讲出来,说明理解到位了。
再讲清引用分级,这是本篇最有工程价值的一段。强引用是普通引用,只要强可达就不回收;软引用在内存不足时会被回收,常用于做可丢弃的内存缓存;弱引用在下一次回收时就会被清掉,WeakHashMap 就是基于它实现自动清理的 key;虚引用几乎不阻止回收,通常与 ReferenceQueue 配合用于回收后通知。 四级之间的差别不在"能不能读到值",而在"多久会被回收"——面试里常有人答成"软引用不会被回收",这就漏掉了关键点。
这些坑的正确绕法
最常见的坑是把对象置 null 当成释放,只要还有别的强引用可达,GC 照样不回收。 这类"写了等于没写"的代码在 try-finally 里特别常见:把引用置空后又在 finally 里给同一个字段赋回原值,结果等于没做。真正要断开引用,取决于对象被谁引用——被静态字段持有就清静态字段,被集合持有就移除元素,被监听器注册就取消注册。 排查思路是顺着 GC Roots 这四类往回找,而不是盯着被置 null 的那一行。
其次是用软引用做图片缓存不设上限,软引用回收前内存已经超出预算。 软引用的语义是"内存吃紧时才回收",而系统判定吃紧的时机滞后于进程被限制或被杀,导致明明设了软引用缓存,进程还是被系统回收。工程上更稳的做法是把缓存额度换算成显式的字节预算(Android 的 LruCache 就是按 sizeOf 计量并主动淘汰),而不是把希望寄托在 GC 的宽限策略上。
还有一个更隐蔽的坑:静态集合只增不减,没有淘汰策略。 static List 存了对象之后,应用不重启就一直持有着;List 本身是强引用链上的一环,被静态字段拖住,里面的元素就回收不掉。这类问题在 Android 上表现得很隐蔽——不重启看不出问题,进程被系统后台杀掉再冷启动就"正常了",容易被误判为偶发。修法是给缓存类明确上限与淘汰规则。
就垃圾回收基础而言,提前声明前提条件、失败路径写清兜底行为,能把大部分事故挡在上线前。 落地成几条:任何注册监听、添加缓存、持有 Context 的地方,都要配一条对称的解除动作;工具类里的静态集合必须有上限;能用弱引用就不要用强引用;泄漏排查在测试阶段就要用内存快照常态化比对。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 可达性分析 + 引用分级
static List<Object> CACHE = new ArrayList<>(); // 静态字段是 GC Root → 拖住整个集合
CACHE.add(bigObject); // 元素随集合一起泄漏
WeakReference<Object> wr = new WeakReference<>(obj);
obj = null; // 断开强引用
Object back = wr.get(); // 可能为 null:下次 GC 即被回收
wr.clear(); // 主动清除引用条目
SoftReference<byte[]> sf = new SoftReference<>(bitmap);
sf.get(); // 内存吃紧时才会被回收
// 强 → 软(缓存) → 弱(WeakHashMap) → 虚(配合 ReferenceQueue)
这段代码值得盯三处:第一处,静态集合是 GC Root 链上的一环,无界增长就等于泄漏;第二处,WeakReference 的 get() 允许返回 null,调用方必须判空;第三处,软引用的回收时机由 GC 决定,不能当作容量控制手段。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
"请简单介绍一下垃圾回收基础,它在 Android 开发中起什么作用?"先定位:GC 负责回收不可达对象,判定依据是可达性分析。再补场景:Android 上 Activity 泄漏、Fragment 泄漏、单例持有 Context 导致内存涨。落一个细节:泄漏的对象被 GC Roots 之一持有,排查要从这四类入手,而不是凭感觉找。
"垃圾回收基础的底层原理是什么?能不能详细说一下?"按四层答:判定标准(可达性分析,不是引用计数,孤岛场景是原因);GC Roots 有哪四类;为什么需要 STW(遍历期间关系要稳定);回收后内存如何处理(复制算法整理、标记清除留碎片、标记整理消除碎片)。再补引用分级,说明软/弱/虚的差别在于"多久被回收"。讲完这四层,这题可以给高分。
"在使用垃圾回收基础时遇到过什么问题?"拿真实案例。一个典型案例:某个单例持有了 Activity 的 Context,页面关闭后内存不降;定位路径是抓内存快照、看支配树,找到 mContext 字段被静态单例引用;修复为改成弱引用或在 onDestroy 里置空;验证是反复进出页面后老年代占用稳定。这类"用支配树定位到具体字段"的案例,说服力远超"遇到过泄漏"这种笼统表述。
"和相关的替代方案相比,有什么优劣?"对比手动管理(显式 remove)与交给 GC 两条线。结论落在场景:生命周期明确的资源(监听、注册、缓存)用手动解除更可控;短生命周期的临时对象交给 GC 更省事。选型标准是"这个对象是否需要确定的释放时机"——需要就用手动,不需要就交给 GC。选型时把"静态集合无界增长"这类代价摆到台面上,再决定是否引入。
再补一个工程上值得讲清的点:为什么定位泄漏要看"支配树"而不是"对象大小排行"。对象大小排行只能告诉你谁占内存,泄漏的关键是"谁在不该活着的时候还活着"。支配树展示的是对象之间的引用支配关系——如果 A 支配 B,意味着 B 是只能通过 A 到达的,那么 A 泄漏就会连带着 B 一起无法回收。 所以看支配树时要自上而下找那个"本该释放却仍被强引用"的支配者,通常是一个静态集合、单例或长生命周期的监听器。Android 上还可以用 adb shell dumpsys meminfo 观察进程内存构成、用 LeakCanary 之类的工具在 debug 包里自动捕获泄漏引用链。把"支配树 + 引用链"这套方法讲出来,说明解决过真实问题。
顺带补一条 Android 特有的差异:ART 的 GC 触发阈值与堆大小、设备内存等级相关,应用在低端机上更容易被频繁 GC;Application.onTrimMemory 提供的回调就是让应用在这些时机主动释放缓存——把这条讲出来,说明知道移动端的内存约束与桌面 JVM 不同。
给正在准备面试的你
把可达性分析画成一张图:底部四个 GC Roots(栈帧局部变量、静态字段、JNI 引用、活跃线程)向上连出引用边,中间是对象节点,虚线圈出"不可达→可回收"的那批,并在旁边列出强/软/弱/虚四级引用标注"何时被回收"。面试中关于垃圾回收的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是给项目里的静态缓存加上明确上限与清理入口,并在 debug 包里接一套内存泄漏自动检测,抓一次真实泄漏顺着支配树定位到具体字段。
复习时别孤立刷题:JVM 内存区域——堆是 OOM 的主战场,方法区与直接内存各有各的排查路径。
划两句重点:判定存活用可达性分析,GC Roots 是栈帧局部变量、静态字段、JNI 引用与活跃线程;软引用不能当容量控制手段,缓存要显式设上限,泄漏要从 GC Roots 顺引用链找拖住它的那个对象。
下一篇聊 常见垃圾回收器:从 CMS 到 G1 的演进思路——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:类加载机制与双亲委派:热修复的伏笔
下一篇预告:常见垃圾回收器:从-CMS-到-G1-的演进思路
有任何问题欢迎在评论区留言交流。