JVM(二):垃圾回收算法与收集器
JVM(二):垃圾回收算法与收集器
导语:GC 是 JVM 面试的正面战场。本篇从「对象死活怎么判」讲起,梳理三大回收算法与分代理论,逐个拆解 Serial 到 ZGC 的收集器特性,并补齐「CMS 与 G1 的区别」「Humongous 大对象」「各版本默认 GC」等高频追问,共 23 题。
一、对象存活判定与 GC 算法
1. 如何判断对象是否可回收?
答: 两种思路:
- 引用计数法:对象被引用则计数 +1,为 0 可回收。优点简单;缺点是无法解决循环引用(A↔B 互相引用但外部已无人使用,计数仍不为 0),Java 不采用;
- 可达性分析(Java 采用):以 GC Roots 为起点向下搜索引用链,不可达的对象被标记为可回收,可解决循环引用。
2. 什么是 GC Roots?包括哪些?
答: GC Roots 是可达性分析的起点集合,包括:
- 虚拟机栈(栈帧局部变量表)中引用的对象;
- 本地方法栈中 JNI 引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量(如字符串常量池)引用的对象;
- 被同步锁(synchronized)持有的对象;
- JVM 内部引用(如基本类型对应的 Class 对象、常驻异常对象、系统类加载器);
- 反映 JVM 内部情况的 JMXBean、JVMTI 回调、本地代码缓存等。
3. Java 有哪四种引用类型?分别何时被回收?
答:
- 强引用(Strong):
Object o = new Object(),只要可达永不回收,是 OOM 主因; - 软引用(Soft):
SoftReference,内存不足、即将 OOM 之前才回收,适合做内存敏感的缓存; - 弱引用(Weak):
WeakReference,下次 GC 无论内存是否充足都会回收,如ThreadLocalMap的 key、WeakHashMap; - 虚引用(Phantom):
PhantomReference,不影响对象生命周期,必须配合ReferenceQueue,用于跟踪对象被回收的时机(如堆外内存清理、Cleaner)。
回收时机口诀:强引用「不可达才收」、软引用「要 OOM 才收」、弱引用「GC 就收」、虚引用「随时可收(无法通过它取到对象)」。
4. finalize() 方法有什么用?对象如何「自救」一次?
答: 对象被标记不可达后,若重写了 finalize() 且未被调用过,会被放入 F-Queue,由低优先级 Finalizer 线程执行一次。对象可在 finalize() 中重新与 GC Roots 建立引用实现「自救」(仅一次,下次再被标记就真回收了)。
但 finalize() 执行时机不确定、会拖慢 GC、且可能导致对象复活,Java 9 起已标记为 deprecated,不应依赖。资源释放请用 try-with-resources 或 java.lang.ref.Cleaner。
5. 标记-清除(Mark-Sweep)算法的优缺点?
答: 分标记、清除两步。
- 优点:实现简单,不需要移动对象;
- 缺点:① 效率低(需遍历全部对象);② 产生大量不连续内存碎片,可能导致后续大对象分配失败而提前触发 GC。
6. 复制(Copying)算法?
答: 将可用内存分为两块,只使用一块;GC 时将存活对象复制到另一块,原块整体清空。优点:无碎片、效率高(存活对象少时尤其划算);缺点:浪费一半空间。
HotSpot 新生代(Eden + Survivor)即用其变体:Eden : Survivor = 8 : 1 : 1,每次使用 Eden + 1 个 Survivor,存活对象复制到另 1 个 Survivor,因此在理想情况下只「浪费」10% 的空间。
7. 标记-整理(Mark-Compact)算法?
答: 标记后,将存活对象向内存一端移动并紧凑排列,然后清理边界外的内存。优点:无碎片;缺点:需移动对象并更新所有引用,开销比复制算法更高。
老年代多用此算法(或 CMS 的标记-清除 + 碎片整理兜底)。
8. 分代收集理论是什么?为什么要分代?
答: 基于两条经验假说:
- 弱分代假说:绝大多数对象都是朝生夕死的;
- 强分代假说:熬过越多次 GC 的对象,就越难以死亡。
由此得出:不同生命周期的对象应放到不同区域,用最适合的算法分别回收。
- 新生代:对象存活率低、回收频繁 → 用复制算法(只搬运少量存活对象,高效、无碎片);
- 老年代:对象存活率高、没有额外空间做担保 → 用标记-清除或标记-整理。
分代让「每次只回收一小片区域」成为可能,从而把单次 GC 的停顿与扫描范围控制在可接受范围内。
9. 什么是 Minor GC / Major GC / Full GC?何时触发?
答: 经可达性分析不可达(且 finalize 已无自救可能)的对象会被回收;按回收范围分三类:
- Minor GC / Young GC:回收新生代,Eden 满时触发。频率高、停顿短、速度快;
- Major GC / Old GC:仅回收老年代,老年代满时触发。目前只有 CMS 有独立的老年代回收行为,因此常被混用;
- Full GC:回收整个堆 + 方法区(元空间),停顿最长。触发条件:
- 老年代空间不足(晋升失败
Promotion Failed); - 空间分配担保失败(见《JVM(一)》分配策略一题);
- 元空间不足;
- 显式调用
System.gc()(可被-XX:+DisableExplicitGC禁用); - CMS 出现 Concurrent Mode Failure 降级为 Serial Old 全堆回收。
- 老年代空间不足(晋升失败
常见误区纠正:「Major GC」与「Full GC」常被混为一谈。严格说,只有 Full GC 会回收整个堆和方法区,停顿影响远大于 Major GC;G1 中「回收新生代 + 部分老年代 Region」的方式则称为 Mixed GC。
二、垃圾收集器
10. 串行收集器 Serial / Serial Old?
答: 最基础、单线程收集器,GC 时暂停所有用户线程(STW)。Serial 用于新生代(复制),Serial Old 用于老年代(标记-整理)。
简单高效、没有线程交互开销,适合单核、客户端模式、桌面小应用;也是 -XX:+UseSerialGC 时新旧生代的默认组合。
11. ParNew 收集器?
答: Serial 的多线程并行版本,用于新生代(复制算法),常与老年代 CMS 搭配(CMS 只能与 ParNew 或 Serial 配合)。
- 并行线程数默认 = CPU 核数(
-XX:ParallelGCThreads可调); - 注意区分:并行(Parallel)指多条 GC 线程同时工作但用户线程仍暂停;并发(Concurrent)指 GC 线程与用户线程同时运行。ParNew 是「并行」,CMS 的并发标记/清除才是「并发」。
12. Parallel Scavenge / Parallel Old 收集器?
答: 关注吞吐量(吞吐量 = 用户代码时间 /(用户代码时间 + GC 时间))的并行收集器。
- Parallel Scavenge:新生代(复制),也叫 Throughput 收集器;
- Parallel Old:老年代(标记-整理);JDK 6 之前只有 Serial Old 与之搭配,导致吞吐型组合在老年代「拖后腿」;
- 关键参数:
-XX:MaxGCPauseMillis(最大停顿)、-XX:GCTimeRatio(吞吐量目标,默认 99,即 GC 时间占比 ≤ 1%)、-XX:+UseAdaptiveSizePolicy(自适应调节新生代大小与晋升年龄); - 适合后台计算、批处理等对停顿不敏感、追求 CPU 利用率的场景,是 JDK 8 及之前 Server 模式的默认组合。
13. CMS(Concurrent Mark Sweep)收集器?
答: 以获取最短回收停顿时间为目标的老年代收集器(标记-清除),工作分 4 个阶段:
- 初始标记(STW):仅标记 GC Roots 直接关联的对象,很快;
- 并发标记(与用户线程并发):从直接关联对象出发遍历整个对象图,耗时最长但不暂停;
- 重新标记(STW):修正并发期间因用户线程继续运行而变动的标记(用增量更新),耗时比初始标记长、比并发标记短;
- 并发清除(与用户线程并发):清除判定为垃圾的对象。
优点:并发收集、低停顿。缺点:
- CPU 敏感:并发阶段会占用 CPU 资源(默认启动
(核数+3)/4个线程),核数少时用户线程吞吐下降明显; - 浮动垃圾(Floating Garbage):并发清除阶段用户线程新产生的垃圾只能留到下次回收,因此不能在老年代满时才启动,需
-XX:CMSInitiatingOccupancyFraction提前触发(JDK 6 起默认约 92%); - 内存碎片:标记-清除不整理空间,碎片过多时可用
-XX:+UseCMSCompactAtFullCollection(默认开)+-XX:CMSFullGCsBeforeCompaction控制整理频率; - 并发模式失败(Concurrent Mode Failure):并发回收期间老年代空间不够用,CMS 会退化为 Serial Old 单线程 Full GC(停顿飙升,是 CMS 调优要重点监控的现象);
- 依赖写后屏障(Post-Write Barrier)维护卡表(Card Table)记录跨代引用,带来每次引用赋值的额外开销(详见第 23 题)。
重要纠正(JDK 版本):CMS 在 JDK 9 被标记为废弃(deprecated,JEP 291),并在 JDK 14 被正式移除(JEP 363)。现代 JDK 不应再依赖 CMS,应转向 G1/ZGC;面试需说明其历史地位与淘汰现状。
14. G1(Garbage First)收集器?
答: JDK 7 引入、JDK 9 起成为默认 GC。核心思想:把堆划分为约 2048 个大小相等的 Region(单个 1~32MB,2 的幂),不再有物理连续的固定分代边界——Eden/Survivor/Old/Humongous 都只是逻辑上的 Region 集合。
特点:
- 可预测停顿模型:通过
-XX:MaxGCPauseMillis(默认 200ms)设定目标停顿,G1 维护一个优先队列,优先回收价值最大(垃圾占比最高、回收收益最大)的 Region——「Garbage First」由此得名; - SATB + Remembered Set(RSet):每个 Region 维护一份 RSet,记录「谁引用了我」,避免回收时全堆扫描。代价是内存占用高,极端情况可达堆的 20%;
- 整体基于标记-整理(Region 之间复制存活对象),基本无碎片;
- Mixed GC:除回收新生代 Region 外,还按收益回收部分老年代 Region(默认最多 8 次 Mixed GC 后触发一次 Full GC,G1 的 Full GC 是单线程 Serial Old 式回收,要尽量避免)。
G1 的垃圾回收过程(四个阶段):
- 初始标记(STW):标记 GC Roots 直接关联对象,极短,通常「借道」一次 Young GC 完成;
- 并发标记:与用户线程并发遍历对象图,用 SATB 记录并发期间被删除的引用;
- 最终标记 / 重新标记(STW):处理 SATB 缓冲区中残留的引用变更,修正标记结果;
- 筛选回收(Evacuation,STW):根据停顿目标挑选收益最高的 Region,把存活对象复制到空 Region,回收旧 Region(这也是 G1「整理 + 基本无碎片」的来源)。
适合大堆(数 GB ~ 数十 GB)且要求可控延迟的场景;
-XX:+UseG1GC开启(JDK 9+ 默认)。
15. CMS 与 G1 的区别是什么?
答: 这是「老年代收集器换代」必考题,从 6 个维度对比:
| 维度 | CMS | G1 |
|---|---|---|
| 堆布局 | 物理分代(新生代/老年代各自连续) | Region 划分,逻辑分代 |
| 回收算法 | 标记-清除(有碎片) | 整体标记-整理 + Region 间复制(基本无碎片) |
| 设计目标 | 最短停顿(不可预测) | 可预测停顿(-XX:MaxGCPauseMillis) |
| 并发标记 | 增量更新(Incremental Update,写后屏障) | SATB(原始快照,写前屏障) |
| 跨代/跨区引用 | 卡表(Card Table) | 卡表 + RSet(Remembered Set) |
| 空间压缩 | 不压缩,碎片多了靠 Full GC 整理 | 每次 Evacuation 都复制压缩 |
| 适用堆规模 | 中小堆(经验值 4~6G 以内) | 大堆(数 GB ~ 数十 GB) |
| 现状 | JDK 9 废弃、JDK 14 移除 | JDK 9+ 默认 |
一句话记忆:CMS 是「低停顿 + 有碎片 + 靠卡表」,G1 是「可预测停顿 + 基本无碎片 + 靠 RSet」;两者并列出现的价值在于「大堆场景下 CMS 碎片与并发失败问题难解,G1 用 Region + 复制解决了它」。
16. 什么是 Humongous 对象?G1 如何处理大对象?
答: G1 中,大小 ≥ 单个 Region 的 50% 的对象被称为 Humongous(巨型)对象,会被直接分配到连续的 Humongous Region 中(逻辑上属于老年代),完全不经过新生代。
- 超过一个 Region 大小的对象会占用多个连续的 Region;最后一个 Region 的剩余空间会被浪费(内部碎片);
- 分配成本高:需要查找连续可用 Region,容易触发并发标记周期甚至 Full GC,是 G1 下「明明堆还够却频繁 GC」的常见原因;
- 优化手段:通过
-XX:G1HeapRegionSize(1~32MB,2 的幂,默认由堆大小与 2048 个 Region 目标推算)把 Region 调大,让原本的 Humongous 对象「降级」为普通对象;根本上应从代码层避免产生超大数组/超大集合。
面试延伸:这也是「大对象」在 G1 与 Parallel/ParNew 下的不同处理方式——前者用 Humongous Region,后者用
-XX:PretenureSizeThreshold(仅 Serial/ParNew 生效,且 G1 不支持该参数)。
17. ZGC 与 Shenandoah 是什么?
答:
- ZGC:JDK 11 引入(实验)、JDK 15 生产可用(JEP 377),JDK 21 引入分代 ZGC(JEP 439)。基于 着色指针(Colored Pointer,借用 64 位指针中的若干位存放标记/重映射状态) + 读屏障(Load Barrier),几乎全部阶段(标记、转移/重定位)都并发进行,停顿时间 < 10ms 且不随堆大小增长,最大支持 16TB 堆,是超低延迟首选;
- Shenandoah:Red Hat 主导,JEP 189 在 JDK 12 集成(实验特性,JDK 15 生产可用),目标是极低停顿。用 Brooks Pointer(对象内前向指针)+ 读写屏障实现并发压缩,与 ZGC 思路不同但目标一致。
对比要点:ZGC 用「着色指针 + 读屏障」,Shenandoah 用「Brooks Pointer + 转发指针」;两者都把「标记 + 整理」的停顿压缩到毫秒级,代价是内存开销与吞吐量略降(屏障与额外指针带来成本),因此「吞吐优先」场景仍应选 Parallel/G1。
18. 如何选择垃圾收集器?
答: 经验法则:
- 单核/小应用/客户端 → Serial;
- 追求吞吐(批处理、离线计算)→ Parallel Scavenge + Parallel Old;
- 中低延迟、JDK 8 时代 → ParNew + CMS(历史方案,JDK 14 后已移除);
- 通用、大堆、需要可控停顿 → G1(JDK 9+ 默认);
- 超大堆、亚 10ms 超低延迟 → ZGC(JDK 11+,推荐 JDK 17+)/ Shenandoah。
19. 各 JDK 版本的默认垃圾收集器是什么?
答:
- JDK 8 及之前:Server 模式默认 Parallel Scavenge + Parallel Old(即 Parallel GC / Throughput GC),偏重吞吐量;Client 模式默认 Serial;
- JDK 9 起:默认 G1(JEP 248,统一为 G1,不再区分 Client/Server 默认);
- 可选但非默认:
- ZGC:JDK 11 实验 / JDK 15 生产可用 / JDK 21 引入分代 ZGC,开启
-XX:+UseZGC(分代再加-XX:+ZGenerational); - Shenandoah:JDK 12 实验 / JDK 15 生产可用,需显式
-XX:+UseShenandoahGC; - CMS:JDK 9 废弃、JDK 14 移除,现代 JDK 不应再使用;
- Serial:
-XX:+UseSerialGC,小内存容器场景仍在使用。
- ZGC:JDK 11 实验 / JDK 15 生产可用 / JDK 21 引入分代 ZGC,开启
面试要点:被问「默认 GC」务必带上版本号,避免笼统回答「G1」或「CMS」。JDK 8 答 Parallel GC、JDK 9+ 答 G1 是最常见的标准答案;能补一句「JDK 8 默认与 Client/Server 模式有关」则更完整。
三、并发标记与 GC 支撑机制
20. 三色标记法与「漏标」问题?
答: 并发标记用三色抽象:白(未访问/最终待回收)、灰(已访问但其引用尚未扫完)、黑(已访问且其引用已全部扫完)。
- 漏标(误删存活对象):并发标记期间,黑色对象新增了对白色对象的引用,同时白色对象到灰色对象的原引用被删除,导致这个白色对象既从灰色不可达、又没被重新扫描,最终被误回收;
- 漏标必须同时满足两个条件:① 黑色对象插入指向白色对象的引用;② 从灰色对象到该白色对象的引用被删除(两者都是「删旧边 + 加新边」的组合)。
解决方案:
- 增量更新(Incremental Update,CMS 用):记录黑色对象新增的白色引用,把黑色对象重新变灰(后续重新扫描),破坏条件 ①;
- 原始快照 SATB(Snapshot-At-The-Beginning,G1/ZGC 用):在引用被删除前把旧引用记录下来(快照),让白色对象仍按「开始时活着」看待,破坏条件 ②,代价是会产生「已死但被判活」的浮动垃圾。
与写屏障的关系:为在并发标记中捕捉引用变化,收集器借助写屏障(Write Barrier)在引用赋值处埋点——CMS 用增量更新(写后屏障,记录黑色新增的白色引用);G1/ZGC 用 SATB(写前屏障,在引用被覆盖前记录旧值)。写屏障是把「并发标记正确性」落到实现的底层机制,也是分代/分区收集器避免全堆扫描的关键(见第 23 题)。
21. 什么是安全点(Safepoint)与安全区域?
答: 安全点是代码中某些特定位置(方法调用、循环回边、异常抛出等),线程执行到此处才可安全地挂起进行 GC。GC 需要 STW 时,让所有线程「主动跑到最近的安全点再停下」。
- 为什么不能任意位置暂停:暂停点必须保证「引用关系是已知且稳定的」,否则正在更新引用的指令被中断会导致对象被漏标记;
- 如何让线程主动中断(抢占式中断):JVM 设置一个「中断标志」,各线程轮询该标志(poll 指令)到达安全点后主动挂起,而不是被 OS 强行挂起;
- 安全区域(Safe Region):一段代码中引用关系不变(如
sleep/blocked),线程即使长时间不执行也安全。线程进入时声明、离开时检查 GC 是否完成,从而让处于阻塞状态的线程也能进入 GC 的安全状态。
22. 什么是 STW(Stop-The-World)?
答: STW 指 GC 时暂停所有用户线程,使其到达安全点静止,以便进行对象标记/整理等需要「一致性快照」的操作。
停顿时间是 GC 调优的核心指标。现代收集器(G1/ZGC)通过并发标记、并发转移把 STW 压缩到极短(G1 的初始/最终标记与筛选回收仍需 STW;ZGC/Shenandoah 把标记与转移都做成并发,只剩极短的初始标记停顿)。
补充:「STW 期间能否继续分配对象」——不能,所有用户线程都被挂起;因此 STW 时长直接体现为业务请求的毛刺。
23. 什么是卡表(Card Table)与写屏障(Write Barrier)?为什么分代/分区 GC 需要它们?
答: 分代/分区 GC 在回收部分区域(如新生代、某 Region)时,必须知道其他区域是否有对象引用了本区域对象,否则就只能扫描整个堆。为此引入:
- 卡表(Card Table):一块稀疏标记位图,把老年代/其他 Region 按固定大小(HotSpot 为 512 字节)划分为「卡页(Card Page)」,每个二进制位标记对应卡页是否含有指向目标区域的引用。回收时只扫描被标记(dirty)的卡页,而不必全堆扫描;
- 写屏障(Write Barrier):在对象引用赋值前后自动插入的 JVM 层面钩子(注意:不是 OS 内存屏障),负责在跨区域引用写入时把对应卡页标记为 dirty,从而维护卡表;
- 各收集器差异:
- CMS:用写后屏障维护卡表(配合增量更新);
- G1:每个 Region 有 RSet(Remembered Set)记录「谁引用了我」,除写后屏障更新 RSet 外,还需要写前屏障配合 SATB 记录并发标记期间被删除的引用(见第 20 题;G1 的 RSet 在实现上仍以卡表为存储粒度);
- ZGC:用读屏障 + 着色指针实现并发标记与转移,不需要传统卡表;
- 代价:写屏障带来每次引用赋值的额外开销;G1 的 RSet 内存占用较高(极端情况可达堆的 20%)。
记忆:卡表解决「跨代引用怎么找」,写屏障解决「卡表怎么维护」;G1 把它升级成以 Region 为单位的 RSet + 双向屏障。
下一篇:《JVM(三)》进入类加载机制与 Java 内存模型——类加载生命周期、双亲委派及其打破、类加载器隔离,以及 JMM、重排序与内存屏障。
