对象何时可以回收
JVM 主流实现通过可达性分析判断对象是否存活。从 GC Roots 出发沿引用关系遍历,不可达对象才具备被回收的条件。GC Roots 常见来源包括线程栈中的引用、静态字段引用、JNI 引用和 JVM 内部引用。
引用计数无法可靠处理循环引用,因此不是 HotSpot 判断对象存活的主要算法。
三类基础回收算法
- 标记—清除:标记存活对象后清理其余空间,速度直接但会产生碎片。
- 标记—复制:把存活对象复制到另一块区域,分配简单但需要预留空间。
- 标记—整理:将存活对象向一端移动,消除碎片,但移动对象有成本。
分代回收根据对象生命周期采用不同策略。年轻代对象死亡率高,适合复制;老年代存活率高,更偏向标记清除或标记整理。
Stop The World 与安全点
部分 GC 阶段需要暂停应用线程以获得一致的对象关系视图,这就是 STW。停顿时间不只受堆大小影响,也受存活对象数量、引用处理、类卸载和系统负载影响。
线程通常在安全点暂停。排查长停顿时要区分“到达安全点耗时”和“GC 工作耗时”。
常见收集器
G1
G1 将堆划分为多个 Region,跟踪各区域的回收价值,并以可预测停顿为目标选择回收集合。它仍可能发生 Full GC;MaxGCPauseMillis 是软目标,不是严格保证。
ZGC
ZGC 通过并发标记、并发转移和染色指针等技术减少停顿,适合大堆和低延迟场景。选择前要确认 JDK 版本、平台支持、吞吐损耗和团队运维经验。
Parallel GC
Parallel GC 强调吞吐量,适合批处理等对单次停顿不敏感的任务。
如何选择与调优
先定义目标:吞吐量、P99 延迟、内存成本还是启动速度。然后使用与生产相同的流量模型压测。不要一开始就堆叠大量 JVM 参数,现代 JDK 的默认选择通常是合理起点。
排查顺序建议:
- 开启统一 GC 日志并记录时间戳。
- 观察分配速率、晋升速率、堆使用量和停顿分位数。
- 判断是内存泄漏、堆配置不足、对象分配过快还是收集器不匹配。
- 必要时结合堆转储、对象直方图和线程 dump 定位根因。
常见误区
System.gc()只是请求,具体行为受 JVM 参数和实现影响。- 对象进入老年代不代表永远不会回收。
- 堆越大不一定越好,更大的堆可能增加内存成本和某些阶段的处理量。
- Minor GC、Major GC、Full GC 的术语在不同收集器和工具中可能含义不同,应以日志事件为准。
核心考点清单
- 可达性分析从 GC Roots 出发,不可达对象才具备回收条件。
- 回收算法包括标记清除、复制和标记整理,各有碎片与移动成本。
- G1 面向可预测停顿,ZGC 面向超低停顿,Parallel GC 强调吞吐。
- 调优必须先定义吞吐、P99 延迟和内存成本目标,再用日志和压测验证。
高频追问与参考回答
追问:什么对象可以作为 GC Root?
线程栈中的引用、静态字段引用、JNI 引用以及 JVM 内部持有的关键对象等。具体集合随实现与阶段而变化。
追问:堆越大停顿一定越长吗?
不一定,停顿还受收集器、存活对象量和回收阶段影响;但更大堆会提高内存成本,并可能增加部分阶段工作量。
机制全景图
下面把「JVM 垃圾回收算法与收集器选择」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["对象持续分配"]
A --> B["可达性标记"]
B --> C["存活对象复制或整理"]
C --> D["回收不可达空间"]
D --> E["调整代际与并发周期"]
完整链路:从输入到结果
沿着「对象持续分配 → 可达性标记 → 存活对象复制或整理 → 回收不可达空间 → 调整代际与并发周期」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 对象持续分配
分配速率、对象大小和引用关系决定 GC 压力,吞吐问题常先从分配端产生。
2. 可达性标记
GC Roots 沿引用图标记存活对象,引用类型、类加载器和线程局部变量都会影响可达性。
3. 存活对象复制或整理
复制算法适合低存活率区域,标记整理减少碎片但移动成本更高,标记清除可能留下碎片。
4. 回收不可达空间
回收阶段可能 Stop-The-World 或与应用并发;并发收集仍有短暂停顿和额外 CPU。
5. 调整代际与并发周期
分代假设让年轻对象高频回收,晋升与老年代并发周期需要根据实际存活率和暂停目标调整。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| -Xlog:gc* | 停顿、阶段与堆区域 |
| JFR ObjectAllocationSample | 对象分配热点 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
java -Xms4g -Xmx4g -XX:+UseG1GC -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags -jar app.jar
以真实对象存活率跑 30 分钟稳态和 5 倍峰值;对比 GC 前后占用、分配、晋升和业务 P99。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「GC 后堆」为主基线,记录值应满足「稳态水平线」;同时保存 分配速率、停顿 P95/P99,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「-Xlog:gc*」确认请求确实进入「停顿、阶段与堆区域」对应的实现,再沿「JFR ObjectAllocationSample」观察「对象分配热点」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「只调停顿目标不降低分配速率」,并把单一变量逐级放大,直到「GC 后堆」越过「持续阶梯上涨」。随后再分别验证「堆设得过满导致并发周期来不及」和「用平均停顿掩盖 Full GC 长尾」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「先降低分配热点」,确认它能控制影响范围;第二轮应用「为并发 GC 留 CPU 余量」,验证核心链路恢复;最后落实「按 SLO 选择收集器」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「GC 后堆」回到「稳态水平线」、「暂停 P99」回到「低于 SLO 10%」、「分配速率」回到「容量内稳定」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| GC 后堆 | 稳态水平线 | 持续阶梯上涨 | 泄漏/缓存增长 |
| 暂停 P99 | 低于 SLO 10% | 突破预算 | 收集器/存活集 |
| 分配速率 | 容量内稳定 | 翻倍伴随发布 | 代码分配回归 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:低延迟服务 P99 周期性抖动
监控只显示平均响应正常,GC 日志却表明每两分钟出现 Mixed GC,原因是缓存批量刷新制造大量中等寿命对象并集中晋升。拆散刷新批次、减少临时对象后,比先改收集器参数更直接。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 只调停顿目标不降低分配速率 | 分配速率 | 先降低分配热点 |
| 堆设得过满导致并发周期来不及 | 停顿 P95/P99 | 为并发 GC 留 CPU 余量 |
| 用平均停顿掩盖 Full GC 长尾 | 晋升与存活率 | 按 SLO 选择收集器 |
发布与回滚检查点
- 发布前:确认「-Xlog:gc*」对应实现和上述配置在目标版本仍然有效,并保存「GC 后堆」基线。
- 灰度中:同时观察 分配速率、停顿 P95/P99、晋升与存活率;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「先降低分配热点」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「只调停顿目标不降低分配速率」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| G1 | 中大堆与通用暂停目标 | Region 化、成熟通用 | 极低停顿目标下并发余量有限 |
| ZGC | 大堆与亚毫秒级停顿诉求 | 并发移动、停顿短 | 需要较新 JDK 与足够 CPU/内存余量 |
| Parallel GC | 吞吐优先的离线计算 | 实现简单、吞吐高 | 停顿随堆和存活对象增长 |
选型至少带上 堆与非堆容量、对象分配速率、存活率和停顿目标,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
收集器选择服从 SLO、堆规模和 CPU 预算;先确认对象分配与存活模式,再调整参数,不能把 GC 调优等同于换算法。
工程落地遵循:以可观测证据驱动参数调整,避免脱离业务负载套用参数模板。回答时直接引用「-Xlog:gc*」、配置实验和事故数据,比复述固定模板更有说服力。