第047篇 垃圾回收基础:可达性分析与 GC Roots

简介: 本文深入剖析JVM垃圾回收核心——可达性分析机制,厘清GC Roots四类(栈变量、静态字段、JNI引用、活跃线程)作为泄漏排查入口;对比引用计数缺陷,详解强/软/弱/虚四级引用的本质差异在于“回收时机”;直击Android开发中静态缓存、Context泄漏等高频坑点,并给出支配树定位、显式限容、弱引用替代等工程化解决方案。

垃圾回收这题有个尴尬:几乎人人都能答出"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-的演进思路

有任何问题欢迎在评论区留言交流。

相关文章
|
13小时前
|
缓存 测试技术 API
第043篇 CAS 与原子类:无锁编程的第一课
CAS是Compare-And-Swap的缩写,一种硬件级原子指令,实现“比较并交换”的乐观并发控制。它通过volatile保证可见性、CAS保证原子性,但存在ABA问题(用AtomicStampedReference加版本号解决)、高竞争下自旋耗CPU(LongAdder分段累加优化)及仅支持单变量更新等局限。
17 0
|
15小时前
|
安全 Java 编译器
第013篇 final 的四种用法:变量、方法、类与参数
本文深度解析 Java 中 `final` 的四大语义:变量(引用/值仅赋值一次)、参数(方法内不可重赋值)、方法(禁止重写)、类(禁止继承)。厘清“引用不可变≠内容不可变”“final 不保证线程安全”“this 逸出破坏可见性”等高频误区,结合源码、面试话术与工程实践,助你透彻掌握 final 的原理、坑点与最佳用法。
17 0
|
13小时前
|
缓存 网络协议 测试技术
第052篇 Socket 与 HTTP:网络编程的两层视角
本文深入解析Android网络底层:Socket(传输层字节流)与HTTP(应用层语义协议)的本质区别及协作关系;聚焦高频面试题——TCP连接池设计、HTTP队头阻塞、TIME_WAIT端口耗尽、TLS握手时机、四层超时分级等;结合OkHttp源码与实战案例,讲清弱网优化、连接复用、缓冲背压与避坑要点。
25 0
|
13小时前
|
缓存 Java API
第077篇 集合操作符链:filter、map 与 fold
本文深入剖析 Kotlin 集合操作符(`map`/`filter`/`flatMap`/`groupBy`)的**真实性能成本**:每次调用均创建新集合、遍历全量数据。重点揭示“何时不该用”——如十万级数据链式调用致内存暴增、GC 频繁、UI 卡顿;详解三大优化路径:①调整顺序(`filter`前置)、②惰性化(`asSequence`)、③削平链路(`mapNotNull`/`for`),并明确选型依据。
21 0
|
13小时前
|
自然语言处理 Java Android开发
第072篇 中缀表达式与运算符重载:可读性的双刃剑
Kotlin运算符重载比Java更彻底:不仅支持`+ - * /`等映射为`plus`/`minus`等约定函数,还允许任意单参函数通过`infix`声明实现中缀调用(如`a to b`)。核心原则是——符号语义必须与原始含义一致(`+`即相加,`-`即取反或相减),滥用将损害可读性。
22 1
|
13小时前
|
缓存 Java 编译器
第067篇 data class:一行顶 Java 一百行
Kotlin `data class` 高频却易出事故:编译器自动生成 `equals`/`hashCode`/`copy` 等方法,但行为隐式、易踩坑——数组字段引用比较致去重失效、`equals`/`hashCode` 不配套致 `HashMap` 查不到、`copy` 浅拷贝引发数据污染、解构依赖参数顺序、序列化需 `@JvmField`。核心原则:字段须不可变、集合用只读类型、契约必须守恒。
21 0
|
13小时前
|
并行计算 Java 测试技术
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
本文深入解析`CountDownLatch`、`CyclicBarrier`与`Semaphore`的核心差异与实战陷阱:前者为一次性事件门闩,后者为可复用集合点,`Semaphore`则基于独占模式限流。重点剖析“计数未归零阻塞”“屏障损坏”“许可泄漏”等生产级坑点,并给出`finally`防护、超时兜底、参与方一致性等落地解法,助你面试直击采分关键。
18 0
第042篇 CountDownLatch 与 CyclicBarrier:等待与协作
|
14小时前
|
缓存 安全 API
第033篇 TreeMap 与排序:Comparable 与 Comparator
Android高阶面试常以TreeMap排序为切入点,层层追问原理、应用与坑点。它基于红黑树实现按键有序,支持O(log n)增删查及首/尾/范围查询;排序依赖Comparable或Comparator,须避减法溢出、key不可比、compare与equals不一致等典型陷阱。真题需结合多字段排序实战讲透。
15 0
|
14小时前
|
缓存 算法 安全
第030篇 HashMap 扩容与 rehash:为什么容量是 2 的幂
Android面试高频考点:HashMap扩容与rehash。容量恒为2的幂,触发条件为size &gt; capacity×0.75;扩容翻倍,JDK8通过`(e.hash & oldCap)`按高位比特拆分链表,保持顺序、避免成环。预设合理初始容量可规避频繁rehash与内存抖动。
16 0
|
13小时前
|
安全 Java 编译器
第062篇 val 与 var:不可变优先的工程哲学
`val` 与 `var` 是 Kotlin 不可变性设计的基石:`val` 仅保证**引用不可重绑定**,不约束对象内部状态(如 `val list = mutableListOf()` 仍可 `add`);`var` 允许引用变更。真正安全需结合只读类型(`List`)、`toList()` 拷贝、不可变数据类及并发原语——默认用 `val`,变则审慎用 `var`。
41 0