题干与适用场景
一个延迟敏感的 Java API 运行在 HotSpot JDK 25 上,使用 G1,堆参数为 -Xms12g -Xmx12g。 一次缓存功能发布后,请求 p99 每隔 3 到 5 分钟从 120 毫秒升到 2 至 4 秒。尖峰期间主机 CPU 没有打满。监控还显示:旧年代回收后的占用在两小时内从 6.1 GiB 升到 9.2 GiB,应用分配率约从 600 MiB/s 升到 1.4 GiB/s。团队因此把事故称为“GC 问题”,并准备先把堆加到 24 GiB。
题目中的数字都是面试假设。候选人需要说明如何证明延迟尖峰是否真的对应 JVM 停顿,怎样用统一 GC 日志、Java Flight Recorder(JFR)和有边界的堆诊断区分高分配率、存活集增长、G1 超大对象、显式 System.gc() 与非 GC 安全点,并给出可回滚的修复和验证计划。重点不是背诵收集器参数,而是建立从 现象、证据、假设、实验到 SLO 的完整因果链。
公开的 Java GC 面试资料把 stop-the-world 与 JVM pause time 列为核心问题;Oracle 的 HotSpot 长停顿 排障资料则直接讨论堆不足、碎片、操作系统活动和显式 GC。本题据此作为 JVM 性能排障的代表题。 公开来源没有可靠公司归属,因此 companyName 留空。
面试官考察点
第一项信号是候选人会不会先校准时间线。请求 p99、JVM GC 日志、JFR 事件、容器 CPU throttling、磁盘 和宿主机时间都要在同一时间基准上对齐。堆使用量呈锯齿形只说明发生过回收,不能证明某一次 3 秒延迟 就是 GC;反过来,G1 有大量并发工作,整个 GC 周期很长也不等于应用被暂停了同样久。
第二项信号是能否区分“单次停顿太长”和“总停顿占比太高”。前者要检查 pause 类型及其阶段、回收前后 存活数据、对象复制量和 OS 时间;后者常与分配率和回收频率有关。只看平均 GC 时长会同时掩盖长尾停顿 和大量短停顿。
第三项信号是能否从证据选择修复方向。分配压力应先定位分配热点;回收后旧年代基线持续上升,要验证 是否有不再需要但仍可达的对象;Humongous regions 增长要追踪超过 G1 半个 region 的对象;日志若标明 System.gc(),要找到调用者。给所有现象都套用“加堆、改暂停目标、换 ZGC”不是诊断。
最后看生产风险意识。JFR 的堆统计会触发额外 old GC,jcmd GC.class_histogram 和 heap dump 的官方 说明也标为高影响,默认 heap dump 还会请求 Full GC。强回答会先收集低开销证据,在副本、低峰期或可控 环境获取重型证据,并通过同流量 canary 证明修复没有把暂停换成吞吐、CPU 或内存风险。
回答前需要澄清的问题
- “停顿”具体指什么? 是服务端 handler 延迟、客户端端到端延迟、线程完全不运行,还是仅部分请求
排队。还要确认尖峰是否集中在同一实例,以及负载均衡、下游和网络是否同时异常。
- 日志与指标能否精确关联? 需要同一实例 ID、UTC 时间、JVM 启动时间和发布版本。若日志只保留分钟级
时间,先完善观测,不能凭两条曲线“看起来差不多”下结论。
- G1 日志记录了什么? 至少需要 GC ID、cause、pause 类型、回收前后堆占用、duration,并按需开启
gc+heap、gc+phases、gc+cpu。不能只有进程 RSS 或堆百分比。
- 回收后的旧年代基线是否随相同负载持续上升? 若在缓存预热后稳定,它可能是预期 live set;若继续
上升且每次回收释放越来越少,才更像保留或泄漏问题。
- 容器和宿主机是否有限制? 检查 CPU quota/throttling、swap、page fault、内存压力、磁盘日志阻塞和
noisy neighbor。GC 的 wall time 很长而 CPU time 很短,可能是 JVM 没拿到 CPU 或页面被换出。
- 发布改变了哪些分配与引用关系? 缓存容量、TTL、键值大小、序列化缓冲区、批大小、并发度,以及对象
是否被 ThreadLocal、listener、队列或静态集合持有。
- 延迟与吞吐目标是什么? 需要明确 pause p99/max、请求 p99、吞吐、错误率、CPU 和内存上限。
-XX:MaxGCPauseMillis 是 G1 的目标提示,不是逐次停顿的硬保证。
- 能否安全采集重型证据? 若只有一个生产实例,先扩容或导流。不得在事故高峰对 12 GiB 堆直接执行
heap dump,再把诊断导致的 Full GC 当成原故障。
30 秒回答框架
“我不会先改堆参数。先按实例和时间戳把请求尖峰与 -Xlog:gc*、JFR 的 jdk.GCPhasePause、CPU throttling、page fault 和下游延迟对齐,证明应用实际暂停了多久。随后把问题拆成三组指标:单次 pause 的类型与阶段、单位时间总暂停占比,以及 allocation rate、promotion rate、old-after-GC 和 humongous regions 的趋势。
如果新缓存把分配率推高但回收后基线稳定,我会用 JFR 找分配热点并减少临时对象;如果 old-after-GC 持续上升,就比较类直方图并在受控副本获取 heap dump,找 dominator 和保留路径;如果 Full GC 前出现 evacuation failure 或大量 humongous region,就检查并拆分大数组、缓冲区和批次;如果 cause 是 System.gc(),就定位调用者。每个修复都用相同流量 canary 验证请求 p99、pause p99/max、总停顿占比、 分配率、回收后存活集、CPU、吞吐和错误率,满足门槛后再扩大。”
分步骤深入解答
第一步:建立可证伪的时间线,先证明是不是 GC。
为每个延迟尖峰记录实例、请求窗口、发布版本和 UTC 时间。把它与统一日志和 JFR 的 pause 事件按 GC ID 关联。基础启动配置可以记录带时间、级别和标签的 GC 信息,并保留滚动文件:
-Xlog:gc*,safepoint:file=/var/log/app/gc-%p.log:time,uptime,level,tags:filecount=10,filesize=100m这是诊断示例,实际路径、保留期和磁盘预算要按环境调整。JFR 中重点看 jdk.GCPhasePause 的 duration, 同时查看 CPU load、线程、socket/file I/O 与 allocation 事件。Oracle 文档强调,对并发收集器而言,GC 周期持续多久不如应用真正暂停的总时长有意义。
做三个结果分支:尖峰与 JVM pause 一一重合,进入 GC 根因分析;JVM pause 很短但 safepoint 很长,检查 安全点原因和到达安全点前的阻塞;两者都不重合,转查 CPU throttling、锁、I/O、网络和下游。这样“GC 嫌疑”可以被推翻,而不是成为所有延迟的默认解释。
第二步:用一组指标描述 GC 症状,而不是只看一条曲线。
至少保留以下时间序列,并按同一负载窗口比较发布前后:
请求:p50 / p95 / p99 / max、吞吐、超时、错误率
停顿:pause p50 / p95 / p99 / max、每分钟总暂停时间、pause cause
堆:young / old 使用量、old-after-GC、回收量、promotion rate
分配:bytes/s、按类与线程的主要分配点、TLAB 内外分配
G1:young / mixed / Full 次数、evacuation failure、humongous regions
系统:进程 CPU、GC CPU、CPU throttling、RSS、swap、major page fault、磁盘延迟回收前后格式 used-before → used-after (heap-capacity) 能回答本次回收释放了多少,但一个点不等于趋势。 old-after-GC 随相同负载单调上升,说明 live set 或晋升压力在增长;基线稳定但回收越来越频繁,通常更 接近高分配率。gc+cpu=info 中 real time 远大于 user+sys 时,要优先调查调度、限额或换页,而不是继续 增加 GC 线程。
第三步:按证据区分五条根因路径。
- 高分配率。 old-after-GC 稳定,young pause 次数和每分钟总暂停时间上升,JFR allocation 事件集中
在新缓存的键构建、序列化或临时集合。先减少复制、复用有明确生命周期的缓冲区、控制批大小,不能盲目 对象池化并引入共享状态。
- 存活集增长或内存泄漏。 old-after-GC 持续上升、回收收益下降。先在多个时点比较类直方图;只有在
低峰副本或流量回放环境才抓 heap dump,用 retained size、dominator tree 和 GC roots 区分合理缓存与 无界保留。缓存达到容量后稳定属于配置问题;失效对象仍被引用才是泄漏。
- G1 超大对象。 G1 把大小大于或等于半个 region 的对象视为 humongous,并直接放入连续的 old
regions。若 Humongous regions: X → Y 高企,追踪大 byte[]、char[]、压缩块或批次;优先缩小单对象 和批次。调整 region size 只在测量证明有效时作为实验,因为它也改变 region 数量和回收粒度。
- 并发标记来不及与 evacuation failure。 若日志出现 destination space 不足、region 无法搬迁,
最坏情况会进入整个堆的 Full GC。应降低老年代分配/晋升,给并发标记留出余量,检查堆与 live set 的 安全间隔;不能只把 MaxGCPauseMillis 调得更小,迫使每次做更少工作后让系统更接近耗尽。
- 显式 GC 或非 GC 安全点。 GC cause 若为
System.gc(),通过调用栈、依赖和运维命令找到来源,再
决定删除调用、修改库配置或隔离任务。DisableExplicitGC 只能在确认语义影响后使用。若 safepoint 长但 GC pause 短,检查反优化、类重定义或其他 VM operation 等实际 cause,不能用换收集器解决。
第四步:分层使用诊断工具,控制观测风险。
先用持续低成本层:应用 SLI、统一 GC/safepoint 日志和标准 JFR。需要实时确认时,可用同一用户在同机运行 jcmd PID JFR.start、JFR.check 和 JFR.dump;具体命令先以 jcmd PID help COMMAND 核对当前 JVM 支持的选项。
再进入高成本层。官方 jcmd 文档把 GC.classhistogram 标为高影响;GC.heapdump 同样是高影响,且 默认会请求 Full GC。JFR 的 heap statistics 也会在开始和结束触发额外 old GC。因此排查延迟时不要默认 打开 heap statistics。先导流或在副本重现,确认磁盘空间与敏感数据处理,再采集直方图或 heap dump。 对比两份相隔稳定负载窗口的证据,比只看一份“最大对象列表”更能说明增长来源。
第五步:修代码、容量或收集器参数时保持因果单一。
本题首先审查新缓存:是否无界、TTL 是否真正生效、权重是否按字节而非条目、value 是否复制大数组、并发 miss 是否同时构建相同对象。代码修复可能是限制最大权重、合并同 key 的并发加载、流式序列化、缩小批次, 或让临时对象更快失去引用。每次实验只改变一个主要变量。
加堆可以增加 GC 间隔,却也可能掩盖无界保留、提高容器内存风险,并让需要处理的 live data 更多。修改 IHOP、young generation、region size 或暂停目标前,先指出日志中哪个阶段或资源不足会被该参数改变。 换 ZGC 之类的收集器是架构级实验,需要重新验证吞吐、CPU、堆余量、容器限制和运维能力,不能作为第一条 事故处置命令。
第六步:用同流量 canary 和反事实验证关闭问题。
用生产形状的请求、对象大小、缓存命中率和并发度回放基线版本与修复版本。canary 至少跨越原来多个 3 至 5 分钟尖峰周期,并加入缓存冷启动和稳定态。验收门槛在实验前写明,例如:请求 p99 小于 200ms、 GC pause p99 小于 100ms 且最大值小于 500ms、每分钟总暂停低于 1%、old-after-GC 在预热后不再持续 上升,同时吞吐不降、CPU 不超预算、OOM 和错误率不增加。
然后做反事实检查:回滚新缓存是否让分配率和尖峰同时恢复;只限制缓存权重是否让 old-after-GC 稳定; 只缩小批次是否让 humongous regions 下降。指标同时按预期变化,才能把修复与根因建立联系。扩大流量时 分阶段发布并保留自动回滚条件。
高质量示范回答
“现有数据只能说明 GC 值得怀疑,还不能证明 2 到 4 秒请求尖峰由 GC 导致。我会先按实例和 UTC 时间把 请求 SLI、-Xlog:gc*,safepoint、JFR 的 jdk.GCPhasePause、CPU throttling、page fault 和下游延迟对齐。 如果尖峰与 pause 重合,再看是单次 pause 过长,还是许多短 pause 累积;如果只看到长 safepoint,则按 safepoint cause 排查;如果两者都不重合,就退出 GC 路径。
本题发布后分配率从 600 MiB/s 升到 1.4 GiB/s,同时 old-after-GC 从 6.1 GiB 升到 9.2 GiB,这至少提出 两个假设:缓存引入大量临时分配,以及缓存或关联对象扩大了 live set。我要看 young/mixed/Full 的 cause、 回收前后占用、promotion、evacuation failure 和 humongous regions。JFR 的 allocation 事件用于定位类、 线程和调用点;多时点直方图用于确认哪些类持续增长。heap dump 只在导流后的副本或回放环境采集,因为它 是高影响动作,默认还可能请求 Full GC。
如果回收后基线稳定但 young pause 很频繁,我会减少缓存键构建、序列化和临时集合造成的分配;如果基线 持续上升,我会用 retained size 和 GC root 路径区分有界缓存与泄漏;如果大数组跨过半个 G1 region 并 伴随 humongous region 增长,就拆小缓冲区和批次;如果日志 cause 是 System.gc(),就定位调用者;如果 real time 远大于 GC CPU,则检查 quota、swap 和宿主机竞争。
我不会把 24 GiB 堆直接当修复。每个候选改动都在同一流量 canary 单独验证,并覆盖缓存冷启动和多个原 尖峰周期。最终同时检查请求 p99、pause p99/max、总停顿占比、分配率、old-after-GC、humongous regions、 吞吐、CPU、RSS、错误率和 OOM。满足预先设定门槛后才逐步放量,并保留回滚。”
常见错误
- **错误表现:看到堆锯齿就断定延迟来自 GC → 失败原因:锯齿只能证明发生回收,不能证明请求尖峰与
stop-the-world 在同一实例同一时间重合 → 修正方法:用实例、GC ID 和统一时间线关联请求、日志与 JFR。**
- **错误表现:只看平均 GC 时长 → 失败原因:平均值会掩盖单次 3 秒长尾,也会忽略大量短暂停顿的总占比
→ 修正方法:同时统计 pause 分位数、最大值、每分钟总暂停和 cause。**
- **错误表现:把 GC 周期时长当成应用停顿时长 → 失败原因:G1 的标记等工作大多可与应用并发 → 修正方法:
以实际 pause 事件和请求 SLI 为准。**
- **错误表现:事故时直接把堆从 12 GiB 加到 24 GiB → 失败原因:可能掩盖无界保留、增加内存风险,并未
证明 live set 有合理上限 → 修正方法:先区分分配率与回收后存活集,再做有回滚的容量实验。**
- **错误表现:在唯一生产实例立即抓 heap dump → 失败原因:命令是高影响动作,默认可能请求 Full GC,
还会产生大文件与敏感数据风险 → 修正方法:先导流,在副本或可控回放中采集。**
- **错误表现:看到大对象就调整 G1 region size → 失败原因:大对象不一定跨过半 region 门槛,参数还会
改变整个堆的 region 粒度 → 修正方法:先用 gc+heap 和分配证据确认 humongous object,再对比实验。**
- **错误表现:把
-XX:MaxGCPauseMillis=50当 SLA → 失败原因:它是 G1 的目标提示,G1 不是实时收集器
→ 修正方法:用真实 pause 分布验收,并在目标、吞吐和堆余量之间做容量权衡。**
- **错误表现:发现
System.gc()就全局禁用 → 失败原因:调用可能来自依赖或运维语义,直接禁用会改变行为
→ 修正方法:先定位调用者和意图,再删除、配置或隔离。**
- **错误表现:换收集器后只确认 p99 下降 → 失败原因:暂停可能换成更高 CPU、吞吐下降或更大内存占用
→ 修正方法:在同流量下同时验证延迟、吞吐、CPU、RSS、错误率和恢复边界。**
追问及应对
追问一:如何区分内存泄漏和正常缓存预热?
看相同负载下多次 old GC 之后的存活集,而不是进程刚启动的一段斜率。正常有界缓存会在达到最大权重和 命中率稳定后形成平台,条目淘汰与 TTL 也可被观测;泄漏会让不再有业务价值的对象仍沿 GC root 路径可达, 基线继续上升。用多个时点的类直方图找增长类,再在受控 heap dump 中检查 dominator、retained size 和 引用路径。即使缓存最终稳定,若平台 9.2 GiB 让 12 GiB 堆没有并发标记与突发分配余量,它仍是容量设计问题。
追问二:为什么不能先调小 MaxGCPauseMillis?
它是 G1 用来调整每次回收工作量的目标,不是强制上限。若根因是 live set 太大、并发标记赶不上或容器拿 不到 CPU,调小目标可能让每次 mixed collection 回收更少,增加回收频率,甚至更快逼近 evacuation failure。 应先从 phase timing、堆余量和分配/晋升速率提出参数为何有效的假设,再在同负载下单变量验证。
追问三:GC 日志显示 real time 很长,但 user 和 sys time 很短,说明什么?
说明 wall-clock 期间 GC 线程没有一直消耗 CPU。候选原因包括容器 CPU throttling、宿主机争用、swap 或 major page fault、虚拟化停顿和日志 I/O。检查 cgroup quota/throttled time、运行队列、page fault、swap、 磁盘延迟和同宿主机事件。增加并行 GC 线程可能让竞争更严重;先解决 JVM 外的资源供应证据。
追问四:什么时候才考虑从 G1 换成 ZGC?
当业务有明确的低延迟目标,已经控制分配与 live set,G1 在目标堆和流量下仍无法满足 pause SLO,并且团队 能承担额外 CPU、堆余量、版本与运维验证时。用生产形状负载 A/B 比较,包含冷启动、稳态、流量突发、 故障恢复和容器限制。收集器迁移是容量与运行模型选择,不替代修复对象泄漏或无界缓存。
追问五:怎样证明修复长期有效,而不是把尖峰推迟了?
canary 必须运行足够久,覆盖多个原尖峰周期、缓存达到稳态以及预期高峰。比较 old-after-GC 的斜率、每小时 Full GC/evacuation failure、分配率、humongous regions、总暂停占比和请求 SLI,并做超过预计峰值的余量 测试。若加堆只让第一次尖峰从 5 分钟推迟到 10 分钟,基线斜率没有改变,就应判定修复失败。