第 02 篇 · GC 算法与性能调优
本篇把 GC 看成「目标函数 + 证据」问题:可达性与算法 → 收集器选型 → 故障诊断工具链 → 指标闭环与分代 ZGC。
骨架见 M10 / M12.04。本篇只补领域细枝,框在 核心方法论 · 开篇。
GC 工程化主线是「目标函数 → 收集器能力边界 → 证据链诊断(日志/Arthas/JFR)→ 变更可回滚 → 监控预防」,而不是单点调参。
- 可达性与三种基础算法:GC Roots + 标记-清除 / 复制 / 标记-整理是所有收集器的认知地基;逃逸分析/标量替换从源头减分配压力。
- 收集器谱系与选型:CMS/G1/ZGC/Shenandoah 的停顿机制与代价不同;再按堆大小、延迟、吞吐、JDK 版本落到场景配置。
- 参数规划与容器约束:整体性 JVM 参数组合对齐应用特征与 cgroup;错配是 FullGC 频发与 OOMKilled 的高频根因。
- 线上故障证据链:CPU 100%(top→jstack)与 FullGC 频繁(jstat/GC 日志/jmap/MAT)分型排查,禁止先拍脑袋加堆。
- 诊断工具与持续分析:Arthas 解决「看不清/改不了/抓不到」;JFR 与 async-profiler 做低开销持续采样,事件集按版本证据化启用。
- 调优闭环与分代 ZGC:监控告警→定位→灰度→全量;Generational ZGC 按 JDK 21/23/24 版本差异评估,指标看板把救火升级为预防。
- GC 调参必须带对照实验:基线 GC 日志 + 业务延迟直方图 + 错误率;禁止拍脑袋加堆或换收集器,实验记录归档可复盘。
- 收集器与关键参数写入服务基线(含 JDK 版本),变更走灰度与回滚条件;容器环境显式核对 ActiveProcessorCount 与内存 limit。
- CPU 100% 与 FullGC 各有标准 Runbook:工具顺序、证据产物、升级条件写死,值班不得跳步。
- Arthas 生产使用规范:权限、审计、只读优先、变更类操作双人确认;禁止无审批 retransform 进核心路径。
- JFR/async-profiler 按环境分级启用:事件集、采样间隔、保留周期与隐私脱敏事先约定,开销用压测证据而非口头承诺。
- 逃逸分析相关优化(栈上分配/标量替换)不作为容量承诺依据;大对象与分配热点仍用分配剖析验收。
- Generational ZGC 升级单独立项:对照 JEP 版本默认值、停顿/吞吐/内存足迹与回退收集器方案。
- GC/JVM 指标进统一看板与告警:次数/停顿、分区用量、分配速率、Humongous;趋势恶化先于故障触发容量评审。
- 先定目标函数:延迟敏感、吞吐优先还是足迹受限,目标不清不调参;三角不可同时极致。
- 算法先于收集器名:先理解存活判定与三种回收代价,再谈 G1/ZGC 工程组合。
- 证据链诊断:日志、直方图、转储、线程栈对齐同一时间窗,才允许改参或换收集器。
- 工具分层:救急用 jstack/jmap/Arthas,稳态用 JFR/火焰图;工具选错会误导根因。
- 版本即配置:同一收集器名在不同 JDK 默认行为不同,分代 ZGC 尤甚。
- 闭环优于单点:调优必须能回滚、能量化、能进监控;否则只是另一次赌博。
- 源头减负:分配速率与逃逸失败的大对象,往往比「再调一点停顿」更值得先治。
这道题考察 GC 的两个根基:一是"如何判断对象存活"(可达性分析 + GC Roots),二是"如何回收死亡对象"(标记-清除 / 复制 / 标记-整理三种基础算法各自的代价)。它是所有 GC 收集器(含已淘汰的 CMS,以及 G1/ZGC)的认知地基——后续所有收集器都是在这三种算法上的工程组合与并发化改造。
引用计数法(早期 / Objective-C ARC、Squirrel 等;Python CPython 以引用计数为主,辅以分代 GC 检测循环引用,但仍存在循环引用扫描开销):对象被引用时计数 +1,引用失效时 -1,归零即回收。优点是可立即回收、无 STW;致命缺陷是循环引用(A 引用 B、B 引用 A,计数永不归零)。JVM 早期评估后放弃此方案。
可达性分析(Reachability Analysis,JVM 选择):从 GC Roots 出发,沿引用链遍历,不可达对象即死亡。天然解决循环引用问题,代价是需要枚举所有 Root 并暂停所有应用线程(STW)。
三种基础算法的演进:标记-清除(Mark-Sweep)是最早的算法,实现简单但产生碎片;复制(Copying)算法解决碎片但浪费一半空间;标记-整理(Mark-Compact)兼顾无碎片与空间利用率,但移动对象代价高。三者各有取舍,没有银弹。
分代收集理论(弱代假说 Weak Generational Hypothesis):基于"绝大多数对象朝生夕死"与"熬过越多次 GC 的对象越可能继续存活"两条经验,将堆分新生代(用复制算法)与老年代(用标记-清除/整理),是 HotSpot 分代设计的理论根基。
未来趋势:分代假说在大堆 + 长生命周期对象场景下收益递减,ZGC/Shenandoah 走向"无分代或软分代 + Region 化 + 并发整理",Generational ZGC(JDK 21)重新引入分代但以染色指针实现,标志着分代理论与并发收集器的深度融合。
GC Roots 的类型(可作为可达性分析起点的对象):
- 虚拟机栈中的引用:各线程方法栈帧中的局部变量表、操作数栈引用(正在执行的方法所引用的对象)。
- 本地方法栈中 JNI 引用:native 方法持有的 Java 对象。
- 方法区中的类静态变量引用:类的 static 字段。
- 方法区中的常量引用:运行时常量池中的引用(如 String 常量池)。
- Java 虚拟机内部引用:基本类型对应的 Class 对象、常驻异常对象(NullPointerException 等)、系统类加载器。
- 同步锁持有的对象:synchronized 持有的 monitor 对象。
- JMXBean、JVMTI 等 JVM 内部结构:反映虚拟机内部状态的对象。
基本类型 Class/异常对象] ROOT --> R6[synchronized monitor] R1 --> A[对象 A] A --> B[对象 B] B --> C[对象 C] R3 --> D[对象 D] E[对象 E 环形引用
不可达] -.-> E F[对象 F 孤岛
不可达] -.-> F style E fill:#fee2e2,stroke:#dc2626 style F fill:#fee2e2,stroke:#dc2626
三不可达对象的两次标记:真正回收前需经过两次标记。第一次标记后,筛选出有必要执行 finalize() 的对象(重写了且未被调用过),放入 F-Queue,由 Finalizer 线程执行;finalize() 执行中若重新建立与 Root 的引用链则"自救"成功,否则第二次标记后回收。finalize() 已被官方明确不推荐(JEP 421 于 JDK 18 标记为 Deprecation for Removal,已不推荐使用),应使用 Cleaner / try-with-resources 替代。
三种 GC 算法本质权衡:
| 维度 | 标记-清除 Mark-Sweep | 复制 Copying | 标记-整理 Mark-Compact |
|---|---|---|---|
| 过程 | 标记存活 → 清除死亡 | 存活对象复制到另一半 → 清空原区 | 标记存活 → 向一端移动 → 清理边界外 |
| 碎片 | 有碎片(内存不连续) | 无碎片 | 无碎片 |
| 空间代价 | 低(不需额外空间) | 高(浪费 50%,需存活区) | 低 |
| 时间代价 | 中(清除快但碎片导致分配慢) | 低(复制快,存活少时高效) | 高(需移动对象并更新引用) |
| 移动对象 | 不移动 | 移动 | 移动 |
| 典型应用 | CMS 老年代(CMS 已 JDK14 移除) | 新生代(Serial/ParNew) | Parallel Scavenge 老年代 / Full GC |
分代收集的理论依据:新生代 98% 对象朝生夕死,适合复制算法(只需复制 2% 存活对象,且 HotSpot 用 Eden:S0:S1 = 8:1:1,实际浪费仅 10%);老年代存活率高、没有额外空间担保,适合标记-清除或标记-整理。跨代引用问题用记忆集(Remembered Set)+ 卡表(Card Table)解决,避免全堆扫描老年代来确认新生代对象的引用。
HotSpot 中 OopClosures 遍历 Root 集合,GenCollectedHeap 编排分代回收。安全点(Safepoint)是可达性分析的前提——JVM 必须在所有线程都处于"已知一致状态"时才能准确枚举 Root,这就是 STW 的根本原因。现代收集器(G1/ZGC)的核心创新正是把原本 STW 的工作拆成多个可并发阶段。
高级开发 · 编码实战:
- 避免显式
System.gc():默认触发 Full GC,可在启动参数加-XX:+DisableExplicitGC屏蔽,防止业务代码误触发全堆整理。 -
大对象直接进老年代:
-XX:PretenureSizeThreshold控制超阈值的对象直接在老年代分配,避免新生代复制开销(仅 Serial/ParNew 生效,G1 走 Humongous Region)。 - 对象年龄动态判定:
-XX:MaxTenuringThreshold=15是上限,但 Survivor 区中相同年龄对象大小总和超过 Survivor 一半时,该年龄及以上对象直接晋升,无需等到 15 岁。
架构师 · 设计决策:
- 分代参数规划:传统分代 GC 下,
-XX:NewRatio=2(老:新=2:1)适合缓存类应用(老年代大);-XX:SurvivorRatio=8调节 Eden 与 Survivor 比例。G1/ZGC 下分代参数作用弱化,应优先调 Region 大小与停顿目标。 - 跨代引用治理:高频跨代引用(如老年代缓存引用新生代对象)会导致卡表维护开销与 RSet 膨胀,G1 下表现为 Region 的 RSet 过大、Mixed GC 停顿延长。架构上应避免"老年代集合持有大量新生代短期对象"。
追问 1:为什么 JVM 不用引用计数法?
核心是循环引用无法处理,需要额外引入"环检测"算法(如追踪式备份),复杂度反超可达性分析。此外引用计数的每次赋值都要原子更新计数器,在多线程高频赋值场景下同步开销巨大;而可达性分析只需在 GC 时枚举一次 Root,正常执行路径零额外开销。可达性分析虽需 STW,但与现代并发收集器(G1/ZGC)结合后 STW 已可压到亚毫秒。
追问 2:跨代引用如何避免全堆扫描?
用记忆集(Remembered Set)记录"哪些老年代 Region 引用了新生代对象",Minor GC 时只扫描这些 Region 而非全堆。HotSpot 用卡表(Card Table)实现:堆按 512B 切成 Card,老年代写引用时通过写屏障把对应 Card 标脏,GC 时只扫描脏 Card。G1 进一步为每个 Region 维护 RSet,记录"谁引用了我",支持 Region 级独立回收。
追问 3:为什么新生代用复制算法而老年代不用?
新生代存活率低(约 2%),复制代价小且无碎片;老年代存活率高(90%+),复制代价等于拷贝大半个堆,且老年代没有额外空间做担保。所以老年代只能用标记-清除(CMS,快但有碎片;CMS 已淘汰,JDK 14 移除,当前推荐 G1/ZGC)或标记-整理(Parallel,慢但无碎片)。
- 把 GC Roots 答成"堆中的根对象":GC Roots 恰恰是堆外的引用起点(栈、本地方法栈、方法区),不是堆内对象。
- 误以为复制算法浪费"一半"内存:HotSpot 新生代用 Eden:S0:S1=8:1:1,实际可用空间是 90%,仅浪费 10%。但若 Survivor 太小放不下存活对象会触发"空间分配担保",直接晋升老年代。
- 忽略安全点(Safepoint):可达性分析必须在安全点进行,长时间循环(counted loop)可能延迟进入安全点导致 STW 时间超预期(JIT 会对 counted loop 优化插入安全点)。
- 混淆"标记-清除"与"标记-整理":CMS(已淘汰)是标记-清除(有碎片),Parallel Old 是标记-整理(无碎片),选错会导致 G1 Full GC 时碎片问题。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察对现代 GC 收集器设计哲学演进的深度理解:从"吞吐优先"到"低延迟"到"无停顿"的演化路径,以及每个收集器为了降低停顿所付出的工程代价(吞吐、内存、CPU、实现复杂度)。能否讲清四者的核心机制差异(并发标记、转发指针、染色指针、读屏障)与适用边界,是区分"用过 GC"与"理解 GC"的分水岭。
Serial / Serial Old(JDK 1.1,1997):单线程 GC,全程 STW。简单、内存占用小,适合客户端或微容器。至今仍是小堆(<100MB)的首选。
Parallel Scavenge / Parallel Old(JDK 1.4+,2002):多线程 GC,吞吐优先(GC 时间占总时间比例最小)。适合批处理、离线计算。JDK 8 默认。
CMS(Concurrent Mark Sweep,JDK 1.5,2004):首个商用并发收集器,目标最低停顿。分四阶段:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 并发清除。致命缺陷:标记-清除产生碎片、Concurrent Mode Failure 退化为 Serial Old 全堆整理。JDK 9 标记废弃,JDK 14 彻底移除。
G1(Garbage First,JDK 1.7u4 引入,JDK 9 默认):堆 Region 化(目标约 2048 个 Region,根据堆大小动态调整),每个 Region 动态归属 Eden/Survivor/Old/Humongous。回收价值优先(Garbage First):优先回收垃圾最多的 Region,用停顿时间模型 -XX:MaxGCPauseMillis 预测可回收的 Region 数。Mixed GC 替代 Full GC。
ZGC(Z Garbage Collector,JDK 11 实验,JDK 15 生产):以染色指针和 load barrier 为核心做并发标记/整理,目标是把停顿与堆容量解耦;实际停顿仍受根集合、线程数、分配速率、NUMA 和平台影响。JDK 21 的 JEP 439 引入 Generational ZGC,JDK 23 起成为默认形态。
Shenandoah(OpenJDK,JDK 12 实验、JDK 15 生产):通过 load reference barrier 与并发复制实现压缩/整理。早期实现曾以 Brooks forwarding pointer 为重要机制,但不应把它说成当前 Shenandoah 不变的总括;不同 JDK 分支的实现细节会演进。JDK 24 引入实验性 Generational Shenandoah,JDK 25 通过 JEP 521 正式提供分代模式;JEP 535 已把 JDK 28 的默认分代模式列为目标。
JDK 25(JEP 521,2025):Generational Shenandoah 转正——引入分代,年轻代/老年代独立并发回收,对标 Generational ZGC,进一步降低分配压力下的 GC 频率。至此两大低延迟收集器(ZGC、Shenandoah)均完成分代化演进。
未来趋势:分代 + 并发整理是低延迟收集器的主流方向;"停顿与堆大小解耦"是设计目标,不是所有负载下免压测的承诺。Native Image 改变的是部署运行时形态,不能与 JVM GC 选型直接等同。OpenJDK 后续 JEP 仍会调整默认形态,版本基线必须先说清。
四个收集器的核心机制对比:
| 维度 | CMS | G1 | ZGC | Shenandoah |
|---|---|---|---|---|
| 堆结构 | 分代(连续) | Region 化(动态调整) | Region 化(ZPage) | Region 化 |
| 算法 | 标记-清除 | 标记-整理(Region 复制) | 染色指针 + 并发整理 | 并发复制/整理 |
| 并发整理机制 | 无(只并发清除) | 部分(转移阶段 STW) | 染色指针 + load barrier | load reference barrier(历史上曾依赖 Brooks forwarding pointer) |
| 停顿时间 | 碎片化后明显恶化 | 由停顿目标驱动,非硬保证 | 目标为低延迟且弱依赖堆容量,需按负载验证 | 目标为低延迟且弱依赖堆容量,需按负载验证 |
| 碎片 | 有(标记-清除) | 无(复制整理) | 无 | 无 |
| 内存开销 | 低 | Remembered Set/RSet 有额外开销 | 染色指针和记忆集有版本相关开销 | 屏障与元数据开销随实现演进,不能套用固定 +8B |
| 调优复杂度 | 高(碎片 + 退化) | 中(停顿目标 + IHOP) | 低(几乎自调优) | 中 |
| 分代支持 | 是(连续分代) | 是(Region 动态归属) | JDK 21 引入分代,JDK 23 起默认分代 | JDK 25 正式分代;JDK 28 目标为默认分代 |
| 状态 | JDK14 移除 | JDK9+ 默认 | JDK15 生产 | OpenJDK 独有 |
G1 的 Region 布局与回收逻辑:
Humongous] O4[O] --- O5[O] --- E5[E] --- E6[E] --- O6[O] end E1:::young E2:::young S0:::survivor O1:::old H:::humongous classDef young fill:#dbeafe,stroke:#2563eb classDef survivor fill:#dcfce7,stroke:#059669 classDef old fill:#f3e8ff,stroke:#7c3aed classDef humongous fill:#fef3c7,stroke:#d97706
G1 通过 CSet(Collection Set)选择本次回收的 Region 组合,优先选垃圾占比高且回收收益大的 Region(Garbage First 得名)。IHOP(Initiating Heap Occupancy Percent)控制何时触发 Mixed GC,JDK 9+ 默认自适应(-XX:+G1UseAdaptiveIHOP)。
ZGC 染色指针的核心:HotSpot 64 位指针中保留颜色位,load barrier 在解引用时检查对象指针是否处于过期颜色,必要时完成自愈/转发。移动对象的大部分工作可与 Java 线程并发进行;STW 只保留根扫描等短阶段。早期 Linux 路径使用过多重映射,平台和 JDK 实现会变化,不要把它写成所有 ZGC 版本的通用细节。
Shenandoah 并发复制的核心:对象访问经过 load reference barrier,被移动对象的新地址可通过转发信息解析,Java 线程继续访问时完成自愈/更新。早期实现广泛以 Brooks forwarding pointer 描述这一模型;现代分支已持续演进,具体对象头/屏障开销要以目标 JDK 源码和 GC 日志为准。
ZGC 与 Shenandoah 都依赖对象读取路径上的 barrier:JIT/运行时插入检查,处理对象是否已被移动、是否需解析新地址。它们与 CMS/G1 关注的写屏障不是同一类屏障。吞吐损失取决于硬件、访问密度和 JDK 版本,经验范围需在真实负载中测得,不能记成固定 5-10%。
专家级 · 选型决策:
- 先锚定 JDK 基线:JDK 8 主要是 Parallel/G1/CMS;CMS 在 JDK 14 已移除。ZGC 和 Shenandoah 都在 JDK 15 生产可用;更高版本才有分代形态。
- 按目标选型:吞吐型批处理优先压测 Parallel/G1;通用服务从 G1 默认形态入手;极低尾延迟才评估 ZGC/Shenandoah。不要用固定 4GB/32GB 阈值替代压测。
- 延迟敏感服务:优先建立 P99/P999、分配速率、promotion 速率和吞吐曲线,再切换 JDK 分代形态。设计目标是低停顿,但 1ms 不能写成无条件承诺。
JDK 21 大堆低延迟:-XX:+UseZGC -XX:+ZGenerational -Xmx16g -Xlog:gc*=info:file=/data/logs/gc.log:time,level,tags(JDK 23+ 默认分代,通常不再需要 -XX:+ZGenerational)
JDK 11/17 均衡:-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -XX:+ParallelRefProcEnabled
追问 1:G1 的停顿时间是怎么"预测"的?
G1 维护每个 Region 的历史回收成本(基于过往 GC 的复制耗时样本),用衰减均值预测本次回收每个 Region 的代价。在 MaxGCPauseMillis 约束下,贪心地选择"垃圾多且回收成本低"的 Region 加入 CSet,直到预测总时间达到阈值。这是预测模型而非硬保证——突发流量或 Region 数据分布变化时可能超时。
追问 2:ZGC 如何做到停顿不随堆增长?
核心是把所有"随堆大小线性增长"的工作都并发化:根扫描只扫描 GC Roots(与堆大小无关,与线程数相关);标记用并发标记 + 染色指针;转移用染色指针 + 读屏障并发移动。STW 阶段只剩"初始标记"和"再标记"等极少数根枚举工作,这些只与线程数和 Root 数量有关,与堆大小解耦。
追问 3:Generational ZGC 为什么重新引入分代?
非分代 ZGC 在高频分配短生命周期对象的场景下,每次并发周期可能要面对大量已死对象,导致 CPU 和分配速率压力。分代后年轻对象按更短周期并发回收,老年代低频参与,通常能减少重复标记量;具体收益和内存开销仍需用分配速率、存活集和 P99/P999 停顿验证,不要引用固定的"减少 80%"。
- 误以为 CMS 还能用:CMS 在 JDK 14 已被移除,新项目绝不应使用,迁移到 G1/ZGC。
- 把 G1 当银弹:G1 在极小堆、超高分配速率或 CSet 选择恶化的场景可能不如 Parallel 或低延迟收集器;超大堆也不是天然失控,但必须按真实负载验证。
- 混淆"染色指针"与"转发指针":ZGC 用染色指针(改指针颜色),Shenandoah 用转发指针(对象头加字段),两者都是并发整理手段但实现路径不同。
- 误以为 ZGC 完全无停顿:ZGC 仍有短 STW 阶段;多数常规堆上可以很小,但停顿不是零,也不会在任何根集合/线程数/硬件下自动等于固定值。
- 忽略 JDK 版本对应关系:ZGC 在 JDK 11 是实验特性(需
-XX:+UnlockExperimentalVMOptions),JDK 15 才生产可用,JDK 21 才有分代版本。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察整体性 JVM 参数规划能力:能否基于应用特征(吞吐/延迟/堆大小/JDK 版本/容器约束)给出系统化的参数组合,而非罗列零散参数。这是架构师做容量规划、线上稳定性治理的核心技能——参数错配是线上 FullGC 频发、容器 OOM Killed 的高频根因。
JDK 8 时代:默认 Parallel Scavenge + Parallel Old(吞吐优先),生产普遍手动切 CMS 或 G1(CMS 已于 JDK 9 废弃、JDK 14 移除,当前推荐 G1/ZGC)。参数体系围绕 -Xmn(新生代大小)、-XX:NewRatio、-XX:SurvivorRatio 等分代比例调优。
JDK 9-16:默认 G1,参数重心转向 -XX:MaxGCPauseMillis、-XX:G1HeapRegionSize、-XX:InitiatingHeapOccupancyPercent(IHOP)。分代比例参数对 G1 影响弱化(Region 动态归属)。
JDK 17-21:容器感知默认开启,-XX:MaxRAMPercentage 取代写死 -Xmx。ZGC/Shenandoah 成为低延迟选项,参数极简(-XX:+UseZGC 即可,几乎自调优)。JDK 21 引入 Generational ZGC(-XX:+ZGenerational)。
未来趋势:参数向"少而精"演进,GC 自调优(Adaptive IHOP、ZGC 动态阈值)成为主流,人工调优重心从"调 GC 参数"转向"调应用分配模式 + 选择合适 GC"。
JVM 参数分类:
- 堆内存:
-Xms(初始堆)、-Xmx(最大堆)、-Xmn(新生代)、-XX:NewRatio、-XX:SurvivorRatio、-XX:MaxMetaspaceSize。 - GC 收集器:
-XX:+UseG1GC、-XX:+UseZGC、-XX:+UseParallelGC、-XX:+UseSerialGC。 - GC 调优:
-XX:MaxGCPauseMillis、-XX:G1HeapRegionSize、-XX:InitiatingHeapOccupancyPercent、-XX:G1MixedGCLiveThresholdPercent、-XX:ParallelGCThreads、-XX:ConcGCThreads。 - 容器/诊断:
-XX:+UseContainerSupport、-XX:MaxRAMPercentage、-XX:+HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath、-Xlog:gc*。
关键参数调优对照表:
| 参数 | 作用 | 默认值 | 调优建议 |
|---|---|---|---|
-Xms / -Xmx |
初始/最大堆 | 容器默认按 RAMPercentage | 生产必须相等,避免动态伸缩导致 FullGC |
-XX:MaxRAMPercentage |
堆占容器内存比例 | 25% | 容器场景设 70-75%,留 25-30% 给堆外 |
-XX:MaxGCPauseMillis |
G1 停顿目标 | 200ms | 不要设太小(<50ms 会降低吞吐),按业务 SLA 设 |
-XX:G1HeapRegionSize |
G1 Region 大小 | 1-32MB 自适应 | 大对象多时调大,减少 Humongous Region |
-XX:InitiatingHeapOccupancyPercent |
IHOP 触发阈值 | 45%(JDK9+ 自适应) | FullGC 频繁时下调,吞吐优先时上调 |
-XX:G1MixedGCLiveThresholdPercent |
Mixed GC 存活阈值 | 85% | 调高避免回收高成本Region(如>85%则回收收益低),调低提升吞吐 |
-XX:ParallelGCThreads |
STW GC 线程数 | ≤8 核 = 核数;>8 核 = 8+(核数−8)×5/8 | 容器内务必按实际核数限制,否则过度抢占 |
-XX:ConcGCThreads |
并发 GC 线程数 | ParallelGCThreads 的 1/4 | ZGC/Shenandoah 调优重点 |
-XX:MaxMetaspaceSize |
元空间上限 | 无上限(本地内存) | 务必设置(256-512m),防类泄漏拖垮容器 |
-XX:MaxDirectMemorySize |
直接内存上限 | 约等于 -Xmx | Netty/Kafka 场景单独评估,加监控 |
GC 选型决策树:
或手动 G1] H1 -- "4-32GB" --> P2[G1
CMS 已 JDK9 废弃/JDK14 移除 勿用] V -- "JDK 11/17" --> H2{延迟需求?} H2 -- "吞吐优先" --> P3[Parallel 或 G1] H2 -- "均衡" --> P4[G1 默认] H2 -- "低延迟" --> P5[ZGC 实验/生产] V -- "JDK 21+" --> H3{堆大小与延迟?} H3 -- "<4GB 或 吞吐优先" --> P6[G1] H3 -- "4GB+ 低延迟" --> P7[Generational ZGC] H3 -- "超大堆 32GB+" --> P8[ZGC] P1 --> OUT[设 -Xms=-Xmx
MaxMetaspaceSize
HeapDumpOnOutOfMemoryError] P2 --> OUT P3 --> OUT P4 --> OUT P5 --> OUT P6 --> OUT P7 --> OUT P8 --> OUT
架构师 · 生产参数模板(容器化 Spring Boot,4G 容器,JDK 17):
# 基础堆与元空间
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70.0
-XX:InitialRAMPercentage=70.0
-XX:MaxMetaspaceSize=256m
# GC 选型:G1(JDK17 默认,显式声明更清晰)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:+ParallelRefProcEnabled
-XX:+G1UseAdaptiveIHOP
# 诊断与故障兜底
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-XX:+ExitOnOutOfMemoryError
# GC 日志(JDK9+ 统一日志格式)
-Xlog:gc*=info:file=/data/logs/gc.log:time,level,tags:filecount=10,filesize=50m
# 容器内 GC 线程数限制(避免过度抢占)
-XX:ParallelGCThreads=4
-XX:ConcGCThreads=1
- -Xms = -Xmx:避免堆动态伸缩触发 FullGC,且省去预扩容开销。
- 容器内必设 MaxRAMPercentage而非写死 -Xmx,适配不同规格实例。
- MaxMetaspaceSize 必设:防止动态类加载泄漏导致容器 OOM Killed。
- HeapDumpOnOutOfMemoryError 必开:故障兜底,配合 ExitOnOutOfMemoryError 让容器重启而非僵死。
追问 1:为什么 -Xms 要等于 -Xmx?
若 -Xms < -Xmx,堆从初始值向最大值扩张时需要申请内存并可能触发 FullGC 来重新划分分代。生产环境流量波动大,扩张时的 FullGC 不可控。相等则堆固定,分代边界稳定,GC 行为可预测。
追问 2:ParallelGCThreads 在容器内为什么不能默认?
JDK 10+ 默认开启 UseContainerSupport,有 CPU quota 时 JVM 按 cgroup 的 ActiveProcessorCount 推导 GC 线程数;未设 CPU limit / 旧 JDK
才可能看到宿主机核数,从而开出远超配额的 GC 线程,STW 时过度抢占。生产仍建议显式设
-XX:ParallelGCThreads 与 -XX:ConcGCThreads。注意:官方默认公式是「可用 CPU ≤8 核时取核数;>8 核时取 8+(核数−8)×5/8」,既不是笼统的「核数×5/8」,也不是「把 limit 再乘 5/8」。
追问 3:MaxGCPauseMillis 设成 10ms 会怎样?
G1 会尝试缩小 CSet(每次回收更少 Region)来满足 10ms,导致回收速度跟不上分配速度,堆占用上升,最终触发 FullGC(退化为 Serial Old 单线程整理)。停顿目标要与吞吐权衡,一般 100-200ms 是甜点区。
- 容器内写死 -Xmx:K8s 调整 Pod 内存 limit 时,JVM 不会自适应,导致内存浪费或不足。
- 不设 MaxMetaspaceSize:动态代理/热部署场景元空间无限增长,最终容器 OOM Killed(不是 Java OOM,是 cgroup kill)。
- 盲目调小 MaxGCPauseMillis:会让 G1 陷入"频繁小回收 + 堆堆积 + FullGC"的恶性循环。
- 忽略 -XX:+ExitOnOutOfMemoryError:OOM 后进程僵死不退出,K8s 不会重启,业务持续不可用。
- 把 JDK8 的 CMS 参数照搬到 G1:CMS 已于 JDK 14 移除,其参数如
-XX:CMSInitiatingOccupancyFraction对 G1 无效。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察线上故障排查的方法论与工具链熟练度:能否在 5 分钟内用标准流程(top → top -Hp → jstack → 线程栈定位)从"CPU 100%"缩小到"具体某行代码"。这是高级开发必备的实战技能,也是高频考点——排查步骤的完整性与工具演进的认知深度都在考察范围内。
jstack 时代(JDK 1.4+):原始手段,手动 top -Hp 找高 CPU 线程 → 十进制转十六进制 → jstack 抓栈 → grep 定位。流程繁琐、需多次手动转换、且 jstack 本身有 STW 副作用(force 时)。
BTrace 时代(2008+):字节码增强,可在不重启应用时动态埋点打印方法耗时。但语法晦涩、风险高(可能搞挂 JVM),逐渐被 Arthas 取代。
Arthas 时代(2018,阿里开源):一站式诊断工具,dashboard 实时看 CPU/线程/GC,thread -n 3 一键找最忙线程,trace
方法调用链耗时。成为线上排查事实标准。
JFR 时代(JDK 11+ 开源):JVM 内建低开销持续采集,jcmd JFR.start
录制,JMC 可视化分析。适合事后复盘而非实时定位。
async-profiler 时代:基于 asyncGetCallTrace 的异步采样,生成火焰图,开销 <1%,是 CPU 性能分析的金标准。
eBPF 时代(内核级无侵入):在内核态采集 syscall/CPU 调度,无需 JVM Agent,跨语言通用。bpftrace、Parca、Pixie 等工具兴起,是云原生可观测性的未来。
CPU 100% 的两类根因:
- 业务线程 CPU 飙高:死循环、正则灾难(回溯)、频繁 FullGC、序列化/反序列化大对象、加密计算。
- GC 线程 CPU 飙高:FullGC 频繁、内存泄漏导致 GC 不断触发。表现为 GC 线程占比高,业务线程 CPU 反而正常。
经典排查五步法(jstack 原生流程):
# 1. 找到 CPU 最高的 Java 进程
$ top -c
# 假设 PID = 12345
# 2. 找到该进程内 CPU 最高的线程
$ top -Hp 12345
# 假设线程 TID = 12390, CPU 95%
# 3. 线程 ID 转十六进制(jstack 栈中 nid 是十六进制)
$ printf "%x\n" 12390
# 输出: 3066
# 4. 抓取线程栈
$ jstack 12345 > /tmp/stack.log
# 5. 定位高 CPU 线程的调用栈
$ grep "nid=0x3066" -A 30 /tmp/stack.log
注意:top -Hp 中的 -H 表示显示线程,PID 列是线程的 LWP(轻量级进程)ID,需转十六进制后与 jstack 输出中的 nid=0x... 匹配。若线程是 VM Thread 或 GC Thread,说明是 GC 问题,走 C02.05 的排查路径。
高级开发 · Arthas 实战(更快、更直观):
# 启动 Arthas attach 到目标进程
$ java -jar arthas-boot.jar 12345
# 1. dashboard 一眼看全貌: 线程/GC/内存
[arthas@12345]$ dashboard
# 2. thread -n 3 找 CPU 最高的 3 个线程(自动完成十进制转栈)
[arthas@12345]$ thread -n 3
# 直接输出线程名 + CPU% + 调用栈,无需手动转换
# 3. trace 方法内部调用链耗时(定位到具体子调用)
[arthas@12345]$ trace com.example.OrderService createOrder -n 5 --skipJDKMethod true
# 4. watch 方法入参/返回值/异常(验证业务逻辑)
[arthas@12345]$ watch com.example.OrderService createOrder "{params, returnObj, throwExp}" -x 2
# 5. profiler 生成火焰图(async-profiler 集成)
[arthas@12345]$ profiler start
[arthas@12345]$ profiler stop --format html
# 生成火焰图,直观看出 CPU 耗时分布
常见 CPU 100% 场景与解法:
-
死循环:
while(true)缺少退出条件,栈顶是业务方法。修代码。 - 正则灾难:恶意输入触发正则回溯(如
(a+)+匹配aaaaaaaaaaaaaaaaaab),CPU 飙到 100%。栈顶在java.util.regex.Pattern。解法:限制正则复杂度、设超时、用 RE2J 替代。 - 频繁 FullGC:栈顶是
VM Thread或GC Thread,jstat 看 GC 频率。走 C02.05 排查。 - 序列化大对象:Jackson/Fastjson 序列化深层嵌套对象,栈顶在序列化库。解法:DTO 裁剪、流式序列化。
- 锁自旋:JDK6+ synchronized 轻量级锁自旋失败前会消耗 CPU,栈顶在业务方法但实际等锁。解法:减少临界区、用读写锁。
线上 CPU 100% 应急:① 立即 Arthas thread -n 5 找最忙线程;② 若是死循环/正则 → 限流 + 热修复;若是 FullGC → 扩容 + dump 排查;③ 火焰图复盘根因;④ 加监控告警(CPU > 80% 持续 1 分钟)。
追问 1:jstack 抓栈时应用卡顿,能否安全抓?
jstack 默认走 SignalDispatcher,向 JVM 发 SIGQUIT 信号触发全线程 dump,本身会触发一次安全点,对低延迟应用有几十毫秒影响。Arthas 的 thread 命令用 AsyncGetCallTrace 异步采样,开销更小。频繁 dump 建议用 async-profiler 持续采样而非反复 jstack。
追问 2:CPU 100% 但 jstack 显示所有线程都 BLOCKED,怎么解?
这是死锁或锁竞争。用 jstack -l PID(加 -l 显示锁信息),末尾会自动检测 "Found one Java-level deadlock"。Arthas
thread -b
一键找阻塞源头线程。解法:修复死锁、缩小锁粒度、用并发集合替代 synchronized 块。
追问 3:为什么 async-profiler 比 jstack 更准?
jstack 是瞬时快照,只抓一次栈,若高 CPU 线程此刻不在线程池热路径上会漏掉。async-profiler 是持续采样(默认每 10ms 一次),用
AsyncGetCallTrace 在安全点外异步采集,开销 <1%,能统计出"一段时间内各方法累计 CPU 占比",更接近真实热点。火焰图是其可视化形式。
-
漏掉 -H 参数:
top -Hp显示线程,top -p只显示进程,不加 -H 看不到线程级 CPU。 - 十进制转十六进制忘转:top -Hp 的 TID 是十进制,jstack 的 nid 是十六进制,必须
printf "%x"转换,否则 grep 不到。 - 误判 GC 线程为业务问题:高 CPU 线程若是
"GC task thread"或"VM Thread",是 GC 问题不是业务死循环,排查路径完全不同。 - 只抓一次 jstack:单次快照可能恰好抓到空闲时刻,应连续抓 3 次间隔 1-2 秒,或直接用 async-profiler 采样。
- 线上 jstack -F 强制抓:
-F强制触发,会显著 STW,非必要不用。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察GC 故障排查的系统化思维:能否从"FullGC 频繁"现象出发,用标准工具链(jstat / GC 日志 / jmap / MAT)逐层定位到根因(内存泄漏 / 大对象 / 参数错配 / 元空间溢出),并给出针对不同 GC 收集器的调优策略。这是架构师保障线上稳定性的核心能力——FullGC 是高频线上故障,排查能力直接决定 MTTR。
工具演进:早期靠 jstat -gc 看分代占用 + jmap -histo 看对象分布;JDK 9+ 统一 GC 日志(-Xlog:gc*)替代 -XX:+PrintGCDetails,配合 GCEasy/GCViewer 在线分析;MAT 一直是 dump 分析金标准但偏重;JDK 11+ JFR 提供低开销持续采集;Arthas heapdump + dashboard 简化操作。
调优理念演进:从"调参数凑指标"(调 NewRatio/SurvivorRatio)到"调应用分配模式"(减少大对象、优化缓存策略)。现代 GC(G1/ZGC)下,FullGC 本身应成为罕见事件——频繁 FullGC 往往是应用设计问题而非参数问题。
FullGC 触发的 7 种原因:
- 老年代空间不足:晋升过快(Survivor 太小 / 大对象直接进老年代 / 频繁 Minor GC 晋升)。
- 元空间不足:动态生成类过多(CGLIB/ASM),触发 Full GC 卸载类。Metaspace 持续上涨。
- System.gc():业务代码显式调用,默认触发 Full GC。
- CMS Concurrent Mode Failure:CMS(JDK 14 已移除)并发回收跟不上分配速度,退化为 Serial Old(JDK14 前的 CMS 特有)。
- 空间分配担保失败:Minor GC 前检查老年代连续空间是否够容纳所有新生代对象,不够则 Full GC。
- G1 的 Humongous 分配失败 / Evacuation Failure:大对象超过 Region 一半进 Humongous Region,回收不及时;或转移时 Survivor/Old 不够。
- RSet 退化:G1 下跨 Region 引用过多导致 RSet 维护成本爆炸。
排查流程图:
# 1. jstat 持续观察 GC 与各代占用(每秒一次)
$ jstat -gcutil 12345 1000
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 92.44 45.12 87.66 94.55 91.30 234 12.45 18 8.92 21.37
# 老年代 O=87.66% 且 FGC 持续增长 → 老年代问题是 FullGC 根因
# 2. jmap 看堆内对象直方图(按占用排序)
$ jmap -histo:live 12345 | head -20
# 找出占用最大的对象类型,如 byte[]/HashMap$Node 异常多
# 3. Arthas heapdump 一键导出 dump(更安全,不触发 FullGC 风险小)
[arthas@12345]$ heapdump --live /tmp/heap.hprof
# 4. MAT 打开 dump,用 Dominator Tree 看对象支配树
# Leak Suspects 报告自动识别泄漏嫌疑
# 重点看 GC Roots 到大对象的引用链(Path to GC Roots)
不同 GC 收集器的调优参数差异:
| 收集器 | FullGC 主因 | 关键调优参数 |
|---|---|---|
| CMS(JDK14 移除) | Concurrent Mode Failure / 碎片 | -XX:CMSInitiatingOccupancyFraction、-XX:+UseCMSCompactAtFullCollection |
| G1 | Humongous 分配 / Evacuation Failure | -XX:G1HeapRegionSize(增大减少大对象)、-XX:InitiatingHeapOccupancyPercent(下调提前 Mixed GC)、-XX:G1ReservePercent |
| ZGC | 极少(设计目标) | -XX:ZAllocationSpikeTolerance(分配突增容忍度) |
| Parallel | 老年代满 / 担保失败 | -XX:MaxGCPauseMillis、-XX:GCTimeRatio(吞吐比) |
架构师 · 常见 FullGC 场景与解法:
- 缓存未限制大小:本地
HashMap当缓存只进不出。解法:换 Caffeine(maximumSize+expireAfterWrite)或 WeakHashMap。 - ThreadLocal 泄漏:线程池复用线程,ThreadLocal 未
remove(),Entry 的 value 无法回收。解法:finally 中 remove,或用TransmittableThreadLocal。 - 大查询未分页:一次性
SELECT *百万行映射成对象,瞬时撑爆老年代。解法:强制分页 / 流式查询(MyBatis Cursor)。 - 元空间泄漏:动态代理类无限生成(每次创建新 Enhancer)。解法:缓存代理类、设
-XX:MaxMetaspaceSize=512m兜底。 - 大对象直接进老年代:批量处理大 byte[]/String,超
PretenureSizeThreshold或 G1 Humongous。解法:分片处理、调大 Region。
线上 GC 日志必开:-Xlog:gc*=info:file=/data/logs/gc.log:time,level,tags:filecount=10,filesize=50m。用 GCEasy 在线上传分析,看吞吐率、停顿分布、FullGC 原因(Cause
字段:Allocation Failure / Metadata GC Threshold / System.gc / G1 Humongous Allocation)。
追问 1:jmap -histo:live 的 :live 有什么作用?
:live 会先触发一次 Full GC 再统计存活对象,能过滤掉即将回收的垃圾,看到的是"真正占住内存的对象"。但代价是会触发一次 FullGC,线上慎用。无 :live 的 jmap -histo 不触发 GC,但数据包含死亡对象,干扰判断。生产建议直接 Arthas heapdump --live(同样会 GC)或在低峰期操作。
追问 2:G1 下还有 FullGC 吗?
有,但应极罕见。G1 的设计目标是用 Mixed GC 替代 FullGC,但当 Evacuation Failure(转移时无空间)或元空间不足时仍会退化为 Single-Thread Full GC(JDK 10 前)或 Parallel Full GC(JDK 10+)。G1 频繁 FullGC 通常是 IHOP 过高、Humongous 过多、或堆过小,调 InitiatingHeapOccupancyPercent 下调、G1HeapRegionSize
调大。
追问 3:如何区分"内存泄漏"与"内存不足"?
关键看FullGC 后老年代占用是否回落。若 FullGC 后老年代降到 30% 以下 → 内存不足(堆小或瞬时大对象),加堆或优化分配即可;若 FullGC 后老年代仍 80%+ → 内存泄漏,必须 dump + MAT 找引用链修复代码。jstat 连续观察 FG 前后的 O 列变化是判断依据。
- 把 MinorGC 频繁当 FullGC:jstat 看 FGC 列才是 FullGC 次数,YGC 是 Minor GC。两者排查路径完全不同。
- 生产直接 jmap -dump:
jmap -dump:live会触发 FullGC 且 dump 期间 STW,大堆可能卡几分钟,应在低峰或摘流后操作。 - 只看对象数量不看支配树:jmap -histo 只看类型数量,看不到引用关系。MAT 的 Dominator Tree 才能定位"谁持有了大对象"。
- 忽略 System.gc():第三方库(如 Kafka 某些版本、Guava Cache)可能调用
System.gc(),GC 日志 Cause 会显示System.gc(),务必-XX:+DisableExplicitGC。
能"读懂"GC 日志是 GC 调优的基本功,先掌握日志开关与关键字段:
- 日志开关演进:JDK 9+ 统一日志格式
-Xlog:gc*:file=gc.log:time,uptime,level,tags,替代 JDK 8 的-XX:+PrintGCDetails -XX:+PrintGCDateStamps。 - Pause:STW 暂停时间,如
Pause (G1 Evacuation Pause) (young) 23.456ms——括号内是 GC 类型,末尾是耗时,是延迟优化的核心指标。 - GC Cause:触发原因,常见值
System.gc()(显式调用)、Metadata GC Threshold(元空间不足)、Allocation Failure(分配失败)、GCLocker Initiated GC(JNI 临界区阻塞),Cause 往往直接指向问题根因。 - Regions:G1 Region 数量变化,如
23M->12M(64M)表示占用从 23M 回收到 12M,Region 池总量 64M。 - Heap / Metaspace:堆与元空间的 before/after 变化,用于计算单次回收效率与判断元空间是否持续增长。
- G1 日志关键阶段:Young Only(年轻代回收)→ Concurrent Mark(并发标记,含 root scan / remark / cleanup)→ Mixed GC(回收老年代 Region)→ Full GC(并发标记跟不上或晋升失败时的兜底,出现即需调优)。
- 分析工具:GCViewer(离线图形化)、GCEasy(在线上传分析)、arthas
dashboard(实时观察 GC 次数与耗时)。
生产案例:某服务周期性出现数百毫秒 Full GC 停顿,吞吐下降。分析 GC 日志发现 Cause 固定为
System.gc(),排查确认是依赖库中手动调用了 System.gc();加 -XX:+DisableExplicitGC 后 Full GC 完全消失。这体现了"日志驱动排查"的方法论——先看 Cause 定位触发源,再看 Pause 分布评估影响,最后看 Regions/Heap 变化判断回收效率,避免盲目调参。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
考察GC 选型的决策框架:能否基于堆大小、延迟需求、吞吐需求、JDK 版本四维度给出明确的收集器选择与参数配置。这是 C02.02(原理)与 C02.03(参数)的落地——高级开发需要能在新项目启动时给出标准化的 GC 配置方案,而不是盲目沿用默认或照搬他处配置。
选型趋势:从"分代比例精调"(JDK8 时代调 NewRatio/SurvivorRatio)转向"选对 GC + 调停顿目标"(G1/ZGC 时代)。JDK 8 默认 Parallel、JDK 9-20 默认 G1、JDK 21+ 仍默认 G1 但 ZGC 分代版成为低延迟首选。整体趋势是选型简化:小堆用 Parallel/G1,大堆或低延迟用 ZGC,CMS 已于 JDK 9 废弃、JDK 14 正式移除,退出历史舞台。
选型决策矩阵:
| 场景 | 堆大小 | 延迟需求 | 推荐收集器 | 关键参数 |
|---|---|---|---|---|
| 微容器/小服务 | <1GB | 无强约束 | Serial / Parallel | -XX:+UseSerialGC 或默认 |
| 普通 Web 服务 | 1-8GB | P99 <200ms | G1(默认) | -XX:MaxGCPauseMillis=200 |
| 大内存服务 | 8-32GB | P99 <200ms | G1 | -XX:G1HeapRegionSize=16m+ |
| 超大堆/低延迟 | 32GB-16TB | P99 <10ms | ZGC(JDK21 分代) | -XX:+UseZGC(JDK 21 追加 -XX:+ZGenerational;JDK 23+ 已默认分代可省略) |
| 金融交易/游戏 | 4-32GB | P99 <1ms | Generational ZGC | -XX:+UseZGC(JDK 21 追加 -XX:+ZGenerational;23+ 省略;26 起移除) |
| 离线批处理 | 任意 | 吞吐优先 | Parallel | -XX:+UseParallelGC -XX:GCTimeRatio=99 |
| 大数据/Spark | 32GB+ | 吞吐优先 | G1 或 Parallel | -XX:+UseG1GC -XX:G1HeapRegionSize=32m |
| Serverless/冷启动 | 小 | 启动优先 | Serial + GraalVM | Native Image 无 GC 压力 |
选择原则:
- 吞吐 vs 延迟权衡:Parallel 吞吐最高但停顿大;ZGC 停顿最低但吞吐折损 5-10%;G1 居中。批处理选吞吐,交互式选延迟。
- 堆大小与停顿解耦:G1 在 32GB+ 停顿会失控(Region 多、RSet 大);ZGC/Shenandoah 停顿与堆解耦,大堆必选。
- JDK 版本约束:JDK 8 无 ZGC(仅有 G1/CMS,CMS 已 JDK14 移除),JDK 11 ZGC 实验,JDK 15+ ZGC 生产,JDK 21+ Generational ZGC。
高级开发 · 场景化推荐配置:
# 场景1: 普通 Spring Boot 微服务(4GB 容器,JDK17)
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=70
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
# 场景2: 大内存低延迟服务(16GB 堆,JDK21)
-XX:+UseZGC
-XX:+ZGenerational # JDK 21 专用;JDK 26+ 删除此行
-XX:SoftMaxHeapSize=12g
-XX:ZAllocationSpikeTolerance=2.0
-XX:+HeapDumpOnOutOfMemoryError
# 场景3: 离线批处理(吞吐优先,JDK17)
-XX:+UseParallelGC
-XX:GCTimeRatio=99
-XX:MaxGCPauseMillis=5000
-XX:+UseContainerSupport -XX:MaxRAMPercentage=80
# 场景4: 小容器微服务(512MB,JDK17)
-XX:+UseSerialGC
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75
JDK 17+ 新项目默认 G1 起步,遇到延迟瓶颈(P99 >200ms)或堆 >32GB 再切 ZGC。JDK 21+ 低延迟场景直接 Generational ZGC。不要为"用最新"而用 ZGC——小堆下 ZGC 的固定开销反不如 G1。
追问 1:为什么小堆不推荐 ZGC?
ZGC 的染色指针 + 读屏障有固定开销(读屏障每次字段访问都插入检查代码),小堆下这个固定开销占比大,而 G1 的 STW 在小堆下本就短(<50ms),ZGC 的低延迟优势发挥不出来,反而吞吐受损。经验阈值是堆 >8GB 才考虑 ZGC。
追问 2:GCTimeRatio 是什么?
Parallel GC 的吞吐参数,表示"GC 时间占 1/(1+GCTimeRatio)"。-XX:GCTimeRatio=99
意味着 GC 时间不超过 1%,是吞吐优先配置。默认 99(即 1% GC 时间)。它通过调整分代大小让 GC 频率降低,代价是单次 GC 停顿可能更长。
追问 3:ZGC 的 SoftMaxHeapSize 与 -Xmx 区别?
-Xmx 是硬上限(超过抛 OOM),-XX:SoftMaxHeapSize
是软目标——ZGC 会尽量把堆占用控制在软目标以下,鼓励更积极地回收,但在内存压力下可临时超过软目标直到硬上限。用于"想给堆留余量但不希望 GC 太激进"的场景。
- 盲目追新用 ZGC:小堆或吞吐场景下 ZGC 反而劣于 G1/Parallel,不是越新越好。
- 把 G1 的 MaxGCPauseMillis 当硬保证:它是预测目标,突发流量时可能超时,不能作为 SLA 承诺。
- 忽略 JDK 版本对应的 ZGC 状态:JDK 11 的 ZGC 是实验性,需
-XX:+UnlockExperimentalVMOptions,生产慎用。 - 选型不看堆外内存:Netty/Kafka 大量直接内存的场景,堆选再好也会 OOM(Direct buffer memory),需单独规划
-XX:MaxDirectMemorySize。
考察线上诊断工具的实战掌握度:能否熟练使用 Arthas 的核心命令(dashboard/thread/trace/watch/jad/retransform)解决"看不清、改不了、抓不到"三类线上问题。它考察的是工具链熟练度而非原理深度,但能否讲清 Arthas 底层(Java Agent + Instrumentation)与工具演进脉络,体现的是技术视野。
BTrace(2008):首个 Java 动态追踪工具,基于 Instrumentation + ASM 字节码增强。功能强大但语法晦涩、脚本需编译、风险高(可能搞挂 JVM),门槛极高。
Arthas(2018,阿里开源):BTrace 思路的工程化、产品化。命令式交互、开箱即用、内置安全限制。成为线上诊断事实标准。核心能力:实时监控(dashboard)、方法追踪(trace/watch)、反编译(jad)、类热更(jad/mc/retransform;旧版和部分场景仍有 redefine,但新流程优先 retransform)。
JFR(JDK 11 开源):JVM 内建的低开销持续采集,不是交互式诊断而是事后分析。与 Arthas 互补:Arthas 解决"实时定位",JFR 解决"历史复盘"。
async-profiler:专注性能 profiling(火焰图),不提供方法级追踪,采样开销通常低于插桩型工具,但真实成本仍受事件、采样间隔、平台与容器权限影响。Arthas 已集成(profiler 命令)。
eBPF(云原生未来):内核态采集,无需 JVM Agent,跨语言通用。bpftrace、Parca、Pixie 等兴起,是云原生可观测性的演进方向,但在 JVM 内部方法级追踪上仍不如 Arthas 精细。
Arthas 底层原理:基于 Java Agent + Instrumentation API。通过 VirtualMachine.attach attach 到目标 JVM,加载 Agent,优先利用 Instrumentation.retransformClasses 在运行时注入埋点代码;retransform 受"不能新增字段/方法"等 Instrumentation 约束。命令交互通过 Telnet/WebSocket 连接到 Agent 内嵌的 Server。
字节码增强] E --> F[注入埋点代码] F --> G[方法执行时收集数据] G -- Telnet/WS --> A
核心命令分类:
- 监控类:
dashboard(全局看板)、thread(线程)、jvm(JVM 信息)、memory(内存)。 - 方法诊断:
watch(观察入参/返回值/异常)、trace(调用链耗时)、stack(调用栈)、monitor(方法执行统计)。 - 反编译/热更:
jad(反编译)、mc(内存编译)、retransform(官方推荐流程);旧命令redefine需按所用 Arthas 版本文档确认行为。 - 诊断辅助:
heapdump、profiler、sc(查类)、sm(查方法)、ognl(执行表达式)。
高级开发 · 实战命令集(示例对齐 Arthas v4.3.3 官方文档;不同版本先执行 version 并查对应文档):
# 1. 全局看板:CPU/线程/GC/内存 一目了然
[arthas@12345]$ dashboard
# 2. 找最忙线程(自动转栈,无需手动十进制转换)
[arthas@12345]$ thread -n 5
# 找阻塞线程
[arthas@12345]$ thread -b
# 当前官方 thread 命令文档未见 --deadlock 参数;
# 先看状态汇总,再用 -b 查 synchronized 阻塞源头(不支持 JUC Lock)
[arthas@12345]$ thread
[arthas@12345]$ thread -b
# 3. 方法调用链耗时(定位慢方法)
[arthas@12345]$ trace com.example.OrderService createOrder -n 5 --skipJDKMethod true
# 输出每层子调用的耗时占比,快速定位瓶颈
# 4. 观察方法入参/返回值/异常
[arthas@12345]$ watch com.example.OrderService createOrder \
"{params, returnObj, throwExp}" -x 3 -n 3
# -x 3 展开对象层级, -n 3 只抓 3 次后自动停止
# 5. 条件过滤(只看异常情况)
[arthas@12345]$ watch com.example.OrderService createOrder \
"{params[0], throwExp}" "throwExp != null" -x 2
# 6. 反编译线上运行类(确认版本是否正确)
[arthas@12345]$ jad com.example.OrderService
# 7. 热更新(应急手段,谨慎操作)
[arthas@12345]$ jad --source-only com.example.OrderService > /tmp/OrderService.java
# 编辑修复后
[arthas@12345]$ mc /tmp/OrderService.java -d /tmp/output
[arthas@12345]$ retransform /tmp/output/com/example/OrderService.class
# 回滚需删除对应 retransform entry 后再触发;必要时 reset/重启进程
# 8. 火焰图(性能分析)
[arthas@12345]$ profiler start --event cpu
# 运行 30 秒
[arthas@12345]$ profiler stop --format html
# 9. OGNL 执行表达式(动态调用方法查看状态)
[arthas@12345]$ ognl '@com.example.Config@getInstance().getCacheSize()'
# 10. 退出并清理增强(务必 stop,否则字节码增强残留)
[arthas@12345]$ stop
线上排障优先用 dashboard 看全局 → thread -n 3 找忙线程 → trace 定位慢方法 → watch 验证入参返回值 → profiler 火焰图复盘。每次用完务必 stop 清理。
追问 1:Arthas 热更新(retransform)的风险与限制?
风险:① retransform 不能新增字段/方法,也不能改变既有类结构(受 Instrumentation 限制);② 正在执行的方法不会立即使用新方法体;③ 若不删除对应 entry,Arthas stop 后增强仍可能保留;④ 热更后与源码版本不一致,排查时易混淆。生产慎用,仅作应急止血,正式修复必须重新发版。
追问 2:Arthas 与 BTrace 的本质区别?
BTrace 是"脚本化"工具,需写 Java 脚本编译后注入,强约束(只读、不能调用业务方法、不能循环)保证安全但牺牲灵活性。Arthas 是"交互式"工具,命令行实时输入,内置安全护栏,提供 ognl 可动态调用业务方法。本质都是 Instrumentation 字节码增强,但 Arthas 在易用性与功能性上做了工程化封装。
追问 3:Arthas 会对线上应用有影响吗?
有。① attach/retransform 会对目标进程产生短暂影响;② trace/watch 注入的埋点在高频方法上开销明显,具体取决于调用频率、表达式复杂度和输出量;③ 热更和 ognl 副作用风险最高。建议低峰期使用,限制 -n/-c 条件,用完 stop 清理,避免长期挂载。
- 用完不 stop:字节码增强残留,长期挂载持续损耗性能,且可能与后续 redefine 冲突。
- trace 在高频方法上不限次:默认执行次数依版本而定,持续输出会刷爆终端并放大开销。生产先用
-n、#cost、条件表达式限流。 - 把 retransform 当正式修复:热更是应急手段,不替代发版,且需记录 entry、class 版本和回滚步骤便于复盘。
- 生产用 ognl 调用有副作用方法:ognl 可调用业务方法,若调到有副作用的方法(如扣款、发消息)会误触发生产逻辑。
考察对持续性能分析的深度理解:JFR 是 JDK 内建的结构化事件采集机制,需要显式启动;async-profiler 主要通过 HotSpot 内部采样接口生成火焰图。两者的开销都依赖 JDK/版本、事件集、采样间隔和工作负载,不能先验地承诺固定百分比。能否讲清 JFR 的事件模型、版本差异、async-profiler 的采样边界与生产启用策略,体现专家级深度。
JFR 历史:原为 JRockit/Oracle JDK 商业诊断体系的一部分;JEP 328 在 JDK 11 将 Flight Recorder 纳入 OpenJDK/Java SE 标准能力。JFR 不是"JDK 11 起默认自动录制",必须通过启动参数或诊断命令启动。JDK 14 通过 JEP 349 增加 Event Streaming API;后续 JDK 的事件集和工具视图持续变化,回答时应绑定目标 JDK。
async-profiler:常见发行版基于 HotSpot 内部 AsyncGetCallTrace 等非公开接口采样,聚焦 CPU/分配/锁等热点。命令入口、事件名和注入方式随发行版本变化:旧版常用 profiler.sh,新版文档使用 asprof;生产必须先确认目标版本和容器权限。
持续 profiling 演进:从"故障时手动采样"走向"常驻低频采集 + 临时深挖"。JFR/async-profiler 偏 JVM 事件与线程栈;eBPF 平台可从内核观测系统调用和进程资源。三者不是同一层替代品,采集权限、安全边界和成本也不同。
演进口径:JFR、JMX、GC 日志、OpenTelemetry 和持续 profiling 平台各覆盖不同信号;不要把"JFR 支持事件"直接等同于 APM 已自动采集,也不要把内核级 profiling 说成 JVM 结构化事件的替代。
JFR 事件模型:JFR 在 JVM 内部埋点采集事件,每类事件有固定字段。核心事件类型:
- GC 事件:GC 开始/结束、停顿时间、各代变化。
- 分配事件:对象分配热点(TLAB 分配、大对象分配),定位分配热点方法。
- 锁事件:JavaMonitor 竞争、阻塞时间,定位锁瓶颈。
- 方法采样:CPU 采样(执行中方法栈)、内存采样(分配时方法栈)。
- IO 事件:文件读写、Socket 读写耗时。
- 类加载/编译事件:JIT 编译、类加载耗时。
火焰图/事件树/热点方法]
JFR 为什么适合低开销采集?:JFR 采用二进制事件和缓冲机制,采样型事件避免全量插桩,磁盘写出通常是异步的。但实际开销取决于事件阈值、栈深度、采样周期、分配速率、I/O 和 JDK 实现。上线前应按目标 JDK 的模板压测,而不是引用固定"<1%"。
async-profiler 火焰图原理:在采样周期内获取线程调用栈,统计栈帧出现次数,形成以栈为键、宽度近似对应样本占比的火焰图。采样器可能漏掉短事件;某些栈需要安全点/相关平台支持。采样间隔、栈采集能力和输出模式以目标 async-profiler 版本为准。
# async-profiler 生成 CPU 火焰图
$ ./profiler.sh -d 30 -f /tmp/cpu.html 12345
# -d 30 采样 30 秒, -f 输出 html 火焰图
# 分配火焰图(定位内存分配热点)
$ ./profiler.sh -e alloc -d 30 -f /tmp/alloc.html 12345
# 锁竞争火焰图
$ ./profiler.sh -e lock -d 30 -f /tmp/lock.html 12345
# JFR 录制(JDK 11+)
$ jcmd 12345 JFR.start name=profile duration=60s filename=/tmp/profile.jfr settings=profile
# 60秒后自动停止, 用 JMC 打开分析
# JFR 持续录制(低开销, 适合长期开启)
$ jcmd 12345 JFR.start name=continuous settings=profile disk=true maxage=1h maxsize=100m
面向应用的公开入口是 jcmd、jfr 工具、JFR 事件类型和 JDK 14+ 的 jdk.jfr.consumer.EventStream;AsyncGetCallTrace
是 HotSpot 内部接口,不是稳定的 Java API。不要把某个 JFR 事件名说成所有 JDK/厂商版本都一致的公共采样 API。
专家级 · 性能分析工作流:
- 实时定位:Arthas
profiler或 async-profiler 生成火焰图,看 CPU 热点方法。 - 分配热点:async-profiler
-e alloc找分配最多的方法,优化对象分配(如复用、缓存、原始类型)。 - 锁瓶颈:async-profiler
-e lock找阻塞最多的锁,缩小临界区或换并发结构。 - 常驻录制:按目标 JDK 和压测结果选择固定或滚动录制;保留足够窗口观察周期性问题,并用
jcmd JFR.dump保留故障现场。 - GC 复盘:JFR 的 GC 事件比 GC 日志更结构化,JMC 可看每次 GC 的停顿、各代变化、回收量趋势。
JDK 11+ 可用启动参数开启 JFR,例如
-XX:StartFlightRecording=dumponexit=true,filename=/data/jfr/app.jfr,maxsize=500m,maxage=24h(参数可用性以目标 JDK 文档为准)。这是显式开启的"黑匣子",不是默认自动录制;async-profiler 更适合临时深挖,二者保留版本和压测基线。
追问 1:火焰图怎么读?
火焰图横向是方法调用栈(底层在下、上层在上),纵向是调用层级,宽度代表采样次数=CPU 占用。读图技巧:① 找最宽的"平台",是 CPU 热点;② 顺着宽平台往下找根因方法;③ 注意 JDK 方法(黄色)vs 业务方法(蓝色)vs native(红色);④ 若热点在 JIT 编译/GC,说明是 JVM 层而非业务层问题。
追问 2:采样 profiling 会不会漏掉短时热点?
会。采样精度受采样周期、调度、安全点和栈获取能力影响,短于有效间隔的方法调用可能被漏采。这是采样的固有局限,不是全量插桩。可缩短间隔、延长观察窗口,或在定位到方法后用 Arthas trace 限流观测;两者都应控制开销。
追问 3:JFR 与 async-profiler 怎么选?
JFR 覆盖 GC、内存、线程、锁、I/O、类加载、编译等结构化事件,适合保留证据和事后复盘。async-profiler 更偏 CPU/分配/锁的火焰图和实时深挖。两者互补:JFR 常驻或滚动保留,async-profiler 按需采样;是否默认开启由生产开销、留存策略和目标 JDK 决定。
- 把 profiling 开销说成固定值:JFR/async-profiler 可能开销较低,但受版本、事件集、阈值、采样周期和负载影响;上线前要压测。
- 采样时间太短:10 秒采样可能漏掉间歇性热点,建议 30 秒以上。
- 火焰图只看最宽的:宽度是绝对占比,但要结合调用层级看——上层方法宽可能只是因为调用了下层热点,根因在下层。
- 忽略分配火焰图:CPU 火焰图看不到"分配压力"问题(GC 频繁但 CPU 在 GC 线程)。分配火焰图(
-e alloc)才能定位"谁在疯狂 new 对象"。
背景:JFR 的核心能力来自结构化事件。事件数量、默认阈值和可用视图随 JDK 版本变化;JDK 11 建立标准 JFR 后,后续版本陆续新增内存、容器、线程等事件。回答时不要把一张固定事件清单套到所有 JDK 上,应先用目标 JDK 核对。
JFR 事件分类与高频事件:
| 分类 | 核心事件 | 诊断场景 |
|---|---|---|
| GC 垃圾回收 | jdk.GCPhasePause |
STW 暂停时长与阶段分解(Initial Mark → Remark → Final Mark) |
jdk.G1HeapSummary |
G1 各 Region 使用率、Eden/Survivor/Old 区变化 | |
jdk.GCHeapConfiguration |
堆配置快照(MaxHeapSize、NewRatio、UseG1GC 等) | |
| 内存分配 | jdk.ObjectAllocationSample(目标 JDK 支持时) |
对象分配采样(哪个线程/方法在 new 大对象)——定位分配热点;名称与阈值随 JDK 变化 |
jdk.ObjectCount |
GC 结束时各类型对象数量与大小——内存泄漏排查利器 | |
jdk.TLABAllocation |
TLAB 分配速率——判断是否需要增大 TLAB 或调参 | |
| 线程与锁 | jdk.JavaMonitorEnter |
synchronized 进入等待时长——锁竞争热点定位 |
jdk.ThreadPark |
LockSupport.park 等待时长——线程阻塞原因(如 BlockingQueue.take) | |
jdk.ThreadCPULoad |
线程级 CPU 使用率——定位 CPU 热线程(比 top -H 更精确) | |
| 编译 | jdk.Compilation |
JIT 编译事件(方法名、Tier 层次、编译耗时)——分层编译决策 |
jdk.CompilerFailure |
JIT 编译失败(如 CodeCache 溢出导致 C2 停止) | |
| I/O | jdk.SocketRead / jdk.SocketWrite |
Socket 读写耗时——慢 SQL、外部调用超时定位 |
jdk.FileRead / jdk.FileWrite |
文件 I/O 耗时——磁盘瓶颈排查 | |
jdk.JavaExceptionThrow |
异常抛出频率——大量异常创建对象导致 GC 压力 | |
| 类加载 | jdk.ClassLoad |
类加载耗时——启动慢、Metaspace 扩张分析 |
jdk.ClassUnload |
类卸载事件——FullGC 后 Metaspace 回收 |
查询 JFR 事件的方式:
# 1. jfr 工具(JDK 11+ 内置)命令行查询
jfr print --events jdk.GCPhasePause recording.jfr # 只看 GC 暂停
jfr print --events jdk.JavaMonitorEnter recording.jfr # 只看锁竞争
jfr summary recording.jfr # 事件汇总统计
jfr view gc recording.jfr # GC 专用视图
# 2. JDK Mission Control (JMC) GUI
# JMC 7+ 支持事件流浏览、自动分析规则、火焰图
# 下载: https://jdk.java.net/jmc/
# 3. 程序化查询(JDK 14+ JFR Event Streaming API)
// 实时消费 JFR 事件流(无需写文件)
try (var rs = EventStream.openRepository()) {
rs.onEvent("jdk.GCPhasePause", event -> {
System.out.println("GC pause: " + event.getDuration("duration"));
});
rs.start(); // 阻塞消费
}
生产实践要点:
- 显式录制:JDK 11+ 需要
-XX:StartFlightRecording或jcmd <pid> JFR.start;这不是默认自动开启的能力。滚动窗口、留存时长和开销要一起评审。 - 事件阈值:JFR 事件有
threshold(如只记录 >10ms 的锁等待),避免事件过多。默认阈值合理,深度排查时可调低(-XX:StartFlightRecording=threshold=0s)。 -
自定义事件:在支持标准
jdk.jfrAPI 的目标 JDK 中,用@Name/@Category等注解定义业务事件;字段要控制数量和基数,避免把高价值事件变成存储压力。
// 自定义 JFR 事件示例
public class OrderProcessEvent extends jdk.jfr.Event {
@Label("Order ID")
@Description("订单唯一标识")
String orderId;
@Label("Processing Duration")
@Timespan(Timespan.MILLISECONDS)
long duration;
}
// 使用
OrderProcessEvent event = new OrderProcessEvent();
event.orderId = "ORD-001";
event.begin();
// ... 订单处理逻辑 ...
event.commit(); // 提交到 JFR 事件流
掌握要点:回答 JFR 事件体系时强调三点:① 事件覆盖 GC、内存、线程、锁、编译、I/O、类加载,不只看 GC;② JEP 349 在 JDK 14 引入 Event Streaming API,可消费事件仓库用于实时处理;③ 自定义事件能把业务证据与 JVM 事件关联,但默认模板、事件名和阈值必须绑定目标 JDK。
考察JVM 调优的方法论与全流程把控能力:能否从"监控告警 → 问题定位 → 参数调整 → 灰度验证 → 全量发布"给出工程化流程,而非"拍脑袋改参数"。这是架构师区别于高级开发的关键——高级开发会调单个参数,架构师能设计调优的闭环流程与容量规划,并识别过度调优陷阱。
调优理念演进:早期"凭经验调参"(背 NewRatio/SurvivorRatio 口诀)→ 中期"基于 GC 日志调参"(GCEasy 分析)→ 现代"监控驱动 + 容量规划 + 灰度验证"。随着 GC 收集器自调优化(G1 Adaptive IHOP、ZGC 几乎自调优),人工调优重心从"调 GC 参数"转向"调应用分配模式 + 选对 GC"。
工具演进:从 jstat/jmap 手动采样,到 APM(SkyWalking/Pinpoint)持续监控,再到 JFR + OpenTelemetry 标准化可观测性。调优从"被动响应告警"走向"主动持续优化"。
JVM 调优全流程:
APM + JVM 指标 + GC 日志"] --> B{指标是否达标?} B -- "达标" --> Z[持续监控] B -- "不达标" --> C["2、问题定位
jstat/Arthas/JFR/MAT"] C --> D{根因类型?} D -- "参数错配" --> E1["3a. 调整 JVM 参数"] D -- "应用设计" --> E2["3b. 优化代码/架构"] D -- "容量不足" --> E3["3c. 扩容/垂直升配"] E1 --> F["4、灰度验证
单实例灰度 + 压测对比"] E2 --> F E3 --> F F --> G{指标改善且无回归?} G -- "否" --> C G -- "是" --> H["5、全量发布
分批滚动 + 监控"] H --> I["6、复盘归档
调优记录 + 容量基线更新"] I --> Z
核心调优指标:
- 吞吐率:业务 QPS / 资源占用。GC 视角是
1 - GC时间/总时间。 - 延迟:P99 / P999 响应时间,GC 停顿是关键来源。
- GC 频率:Minor GC / Full GC 次数与间隔。
- GC 停顿:单次停顿时长与分布(P50/P99/Max)。
- 内存利用率:堆占用率、各代占用、元空间、直接内存。
调优指标基准(经验值):
| 指标 | 健康 | 告警 | 危险 |
|---|---|---|---|
| GC 停顿 P99 | <100ms | 100-500ms | >500ms |
| Full GC 频率 | <1 次/天 | 1-5 次/天 | >5 次/天 |
| 堆占用率 | <70% | 70-85% | >85% |
| 吞吐率(GC 视角) | >98% | 95-98% | <95% |
| 元空间占用 | <60% | 60-80% | >80% |
架构师 · 调优方法论:
- 先监控后调优:没有数据不调参。先建 JVM 指标监控(Prometheus + Micrometer / APM),至少观察 1 周建立基线。
- 单变量原则:每次只调一个参数,对比效果。多变量同调无法归因。
- 压测验证:灰度实例用真实流量回放或压测工具(JMeter/Gatling)对比调优前后指标。
- 容量规划:基于 QPS 增长预测推算堆大小、GC 频率,提前扩容而非被动救火。
- 调应用优于调 GC:90% 的 GC 问题是应用分配模式问题(大对象、缓存泄漏、批处理不分页),改代码比调参数更治本。
基于"单实例 QPS × 平均对象分配量"估算分配速率,结合 GC 回收速率推算稳态堆占用。例如:QPS=1000,单请求分配 100KB → 100MB/s 分配速率,G1 新生代 2GB → 约 20s 触发一次 Minor GC。若 Minor GC 频率 <10s 一次说明新生代过小或分配过快。
追问 1:什么时候不该调优?
① 指标已达 SLA 且无恶化趋势,不要为"追求完美"而调;② 非瓶颈的参数,如吞吐已 99% 还调 GCTimeRatio 无意义;③ 高频变更期(大促前/版本密集期),调优风险高于收益。过度调优是反模式——复杂的非默认参数组合是未来的技术债。
追问 2:如何评估调优效果?
建立对比基线:调优前后用相同流量压测,对比 P50/P99/P999 响应时间、GC 停顿分布、吞吐率、CPU/内存占用。关键看P99 与尾部延迟而非平均值(平均值会被调优掩盖尾部恶化)。至少观察 24 小时覆盖业务高低峰。
追问 3:JVM 调优与 K8s 资源配置的关系?
JVM 堆受 K8s Pod memory limit 约束,堆 + 堆外(元空间/直接内存/线程栈)+ JVM 自身(CodeCache/JIT)+ 操作系统页缓存 必须 < limit,否则 OOM Killed。经验:MaxRAMPercentage=70 留 30% 给堆外。Pod CPU limit 影响 ParallelGCThreads,务必按 limit 核数设。
- 无监控就调参:凭感觉调
-Xmn或 NewRatio,没有数据支撑,可能越调越差。 - 多变量同调:同时改堆大小、GC、线程数,无法归因哪个起作用。
- 只看平均值:GC 平均停顿 50ms 但 P99 可能 500ms,平均值掩盖尾部问题。
- 过度调优:一堆非默认参数,GC 升级时迁移困难,且参数间可能冲突。
- 调优不灰度:直接全量改参数,一旦恶化无法快速回滚。务必单实例灰度验证。
考察对 JIT 编译优化层级的理解,特别是逃逸分析(Escape Analysis)如何让对象"不进堆"。它解释了为什么"理论上所有对象都在堆上"与"实际很多对象不经过 GC"的矛盾,是连接 JVM 内存模型(C01.02)与 GC 性能(本篇)的桥梁——逃逸分析从源头减少 GC 压力,是 JIT 优化的隐形红利。
逃逸分析演进:这是 HotSpot JIT 的历史性优化,可用 DoEscapeAnalysis 控制;具体默认值和诊断开关必须绑定 JDK/发行版,用 java -XX:+PrintFlagsFinal -version 或目标版本文档确认,不要把 JDK 6/7 的默认状态泛化到现代运行时。
栈上分配口径:HotSpot 常见且可观察到的收益是标量替换——未逃逸对象被拆成标量,不创建完整对象。真正的栈上分配不是 HotSpot 对所有未逃逸对象的通用承诺;不同 JVM、JIT、对象形状和 JDK 版本表现不同。Project Valhalla 仍在演进,其目标是把内联/扁平布局纳入语言与运行时,而不是用现有逃逸分析保证一切。
逃逸分析的三种结果:
- 全局逃逸(GlobalEscape):对象被外部方法或线程访问(如赋值给静态字段、作为返回值、传入未知方法)。通常保留堆分配。
- 方法逃逸(ArgEscape):对象随参数进入被调方法,但不逃逸出线程。优化边界取决于 JIT 的分析能力。
- 未逃逸(NoEscape):对象只在方法内部使用,不传出。JIT 可能做标量替换或同步消除;是否做其他栈式布局是实现决策。
无法优化] C -- "方法逃逸 ArgEscape" --> D2[堆分配
可能同步消除] C -- "未逃逸 NoEscape" --> D3[可优化] D3 --> E1["可能的实现相关布局优化
不代表 HotSpot 通用栈上分配"] D3 --> E2[标量替换
拆解为基本类型字段分散存储] D3 --> E3[同步消除
去除 synchronized 锁] E1 --> F[无 GC 压力] E2 --> F E3 --> G[无锁开销]
三种优化的本质:
- 栈上分配(Stack Allocation):理论上是未逃逸对象随栈帧分配并在返回时释放;HotSpot 不把它作为对 Java 程序的通用承诺,常见实现路径更多体现为标量替换。
- 标量替换(Scalar Replacement):将对象拆解为基本类型字段(标量),分散存储在寄存器或栈帧,根本不创建该完整对象。这是 HotSpot 上最应强调的常见表现。
-
同步消除(Lock Elision):若对象未逃逸(即不可能被其他线程访问),其 synchronized 锁无意义,JIT 直接消除。
StringBuffer.append在单线程下锁被消除就是这个原理。
// 示例: 未逃逸对象, 会被标量替换
public int calculate(int a, int b) {
Point p = new Point(a, b); // p 未逃逸, 不出方法
return p.getX() * p.getY();
}
// JIT 标量替换后等价于:
public int calculate(int a, int b) {
int x = a; // 拆解为标量, 寄存器存储
int y = b;
return x * y; // 无 Point 对象创建, 无 GC 压力
}
// 反例: 逃逸对象, 无法优化
public Point createPoint(int a, int b) {
Point p = new Point(a, b);
return p; // p 作为返回值逃逸, 必须堆分配
}
JIT 编译优化层级:
- 解释执行:字节码逐条解释,无优化。
- C1 编译(Client):快速编译,方法内联、简单优化。
- C2 编译(Server):热点方法编译,逃逸分析、标量替换、锁消除、循环展开等深度优化。
- Graal 编译:用 Java 写的 JIT/Native Image 技术路线;其在某个 JDK 中是否可用、是否默认开启由发行版和版本决定。
逃逸分析在 C2/Graal 编译时进行,只有被 JIT 编译的热点方法才会受益。冷方法仍走解释执行,对象正常堆分配。
诊断输出和日志选择器随 JDK 变化,例如部分版本提供
PrintEscapeAnalysis、编译图日志或 unified logging;先用目标版本确认开关是否需要 unlock diagnostic。jol
适合观察对象布局,不能直接证明“对象未被创建”。关闭
DoEscapeAnalysis 对比分配/GC 时要控制其他变量。
高级开发 · 编码实践:
- 控制对象作用域:对象尽量在方法内创建使用,不要无谓地赋值给成员变量或返回,让逃逸分析能生效。
- 避免返回内部对象:返回对象会逃逸,可考虑返回基本类型或不可变副本。
- 局部 StringBuilder 优于 StringBuffer:单线程下 StringBuffer 的 synchronized 会被同步消除,但 StringBuilder 无锁更直接,且无分析失效风险。
- 不要刻意"优化"小对象:现代 JIT + 逃逸分析下,短生命周期小对象的分配代价极低(TLAB + 标量替换),无需为"减少对象创建"牺牲代码可读性。
架构师 · 验证与调优:
-
确认目标 JDK 的逃逸分析状态:
-XX:+PrintFlagsFinal -version | grep -i EscapeAnalysis这类命令只用于当前 JVM/厂商版本,生产不建议仅凭经验关闭或打开。 -
热点方法预热:JIT 编译阈值由分层编译、方法/循环计数、编译队列和策略共同决定,不同 JDK 和
-XX:+TieredCompilation状态没有固定“10000 次”门槛。冷启动优化可评估 AppCDS/AOT 缓存。 - 监控 GC 频率与分配速率:若分配速率异常高但 GC 频繁,可能是热点方法未编译或逃逸分析失效,用 JFR 分配火焰图定位。
追问 1:为什么有时对象明显没逃逸,却没被标量替换?
常见原因:① 该方法未被 JIT 编译;② 方法体、控制流或对象访问形状使分析和后续优化不划算;③ 反射、JNI、native frame、同步/异常路径导致保守处理;④ 数组、集合、虚调用或库边界扩大了分析不确定度。具体原因要用目标 JDK 的编译日志/诊断开关确认。
追问 2:栈上分配和标量替换哪个更优?
在 HotSpot 常规口径中优先讲标量替换:字段拆成标量存寄存器/栈后不创建完整对象,连对象头开销也省了。真正的栈上分配仍会保留完整对象形态,但 HotSpot 不保证对所有未逃逸对象采用;两者是不同抽象层次,不要混用。
追问 3:Project Valhalla 的值类型与标量替换有什么关系?
标量替换是 JIT 运行时对未逃逸对象的优化,是隐式、不确定、依赖编译器实现的。Project Valhalla 的值/内联类型想把扁平布局与 null 性约束固化到语言、类文件和运行时模型中,但其演进阶段、API 名称和能力必须按当时 JEP/JDK 状态回答,不应提前写成 GA 语义。
当前状态(截至 2026-09):JEP 401(Value Objects,预览)与 JEP 539(JVM 严格字段初始化,预览)已于 2026-08 并入 OpenJDK 主线,随 JDK 28 发布,默认关闭、需 --enable-preview。值类放弃对象标识:字段必须 final、禁止 synchronized、== 改为逐字段比较,JIT 因而可做标量替换与堆扁平化——但扁平化是「JVM 能在安全时应用」的优化,把值对象存入字段或向上转型为接口仍可能真实分配。另需注意 值类仍可为 null(null 限制是后续独立 JEP),Valhalla 的其余能力(null 受限存储、数组增强、原语与类统一、参数化 JVM)尚在 Draft 或原型阶段,不能与 JEP 401 一并当作已交付。
- 误以为优化是保证:Java 语言/规范语义允许 JIT 在不改变可观察行为时省略分配;实际是否标量替换取决于 HotSpot/JIT 版本和对象形状。
- 把"栈上分配"和"标量替换"混为一谈:标量替换不创建对象,栈上分配创建对象但在栈上,两者机制不同。
- 误以为逃逸分析一定生效:只在 JIT 编译的热点方法生效,冷方法或预热不足时不生效。
- 关闭逃逸分析做"公平对比":有人为对比"对象分配开销"关逃逸分析,但这偏离生产实际(默认开),结论无意义。
- 忽略同步消除:未逃逸对象的锁可被消除,但这只是 JIT 的可能优化,不应把“可能接近无锁”当作 API 契约或提前优化理由。
考察 Generational ZGC 的版本化生产调优能力。核心要讲清三件事:① JEP 439 在 JDK 21 以 opt-in 方式引入分代 ZGC;② JEP 474 在 JDK 23 使分代模式成为默认,JEP 490 在 JDK 24 移除非分代模式;③ 分代引用集合、读/写屏障、停顿、吞吐和内存开销如何按目标 JDK 证据化评估。不要把它简化成脱离版本和负载的"当前默认最优选项"。
非分代 ZGC:JDK 11 实验引入,JDK 15 生产可用。它用染色指针和读屏障实现并发整理,目标是把重定位与堆容量解耦。但短命对象也需要纳入同一个回收视野,在高分配速率下回收效率和吞吐会劣化;具体幅度必须用目标堆、分配速率和硬件压测,不能引用固定百分比。
Generational ZGC:JEP 439 随 JDK 21 交付。它按弱分代假设拆分年轻代和老年代的回收工作,减少每次回收需要处理的对象集合。JEP 474 在 JDK 23 把分代模式设为默认;JEP 490 在 JDK 24 移除非分代模式。生产回答必须先给出 JDK 线:JDK 21 opt-in、JDK 23 默认、JDK 24 无非分代 ZGC。
后续口径:参数名、默认值、诊断日志和内部数据结构随 JDK 变化。调优应保留分配画像、GC 日志、业务 SLA 和灰度对比;不要只迁移"经验参数清单"。
染色指针与屏障:ZGC 用指针中的标记/转发状态配合读屏障处理并发移动对象;具体位布局因 32/64 位、平台和 JDK 版本而异,不要把某一版实现当作长期 ABI。分代化后,屏障还会参与跨代引用的发现与更新。
记忆集的作用:分代回收必须知道哪些老年代位置可能引用年轻代,否则 Young GC 要扫描老年代。Generational ZGC 使用记忆集配合屏障维护这些候选引用;实现粒度、占用量和更新方式属于 JDK 版本细节。讲清目标即可:缩小回收集合、避免全堆扫描,同时接受维护成本。
扫 young RS + GC Roots
根处理为短 STW"] C --> D["并发转移 Young 存活对象 → 新 Young/Old"] D --> E["存活多次晋升 Old"] B -- 否 --> F["继续分配"] E --> G["Old 区累积"] G --> H{"Old GC 触发?
占用率/分配速率"} H -- 是 --> I["并发标记 + 转移 Old
全堆范围 低频"] I --> F style C fill:#dbeafe,stroke:#2563eb style I fill:#fef3c7,stroke:#d97706
停顿与吞吐权衡:ZGC 的根处理等短 STW 阶段仍存在,其他重定位和标记主要并发进行。分代 ZGC 通过缩小每次回收集合改善高分配场景,但会带来屏障和记忆集维护成本。收益取决于分配速率、跨代引用密度、根集、容器 CPU 配额和 JDK 实现,必须用版本相同的灰度压测验证。
关键调优参数:
| 参数 | 作用 | 默认 | 调优建议 |
|---|---|---|---|
-XX:+ZGenerational |
选择分代模式 | JDK 21/22 需显式开启;JDK 23+ 已默认分代(无需该参数) | JDK 24 该参数废弃(JEP 490)、JDK 26 起已移除——JDK 26+ 写该参数 JVM 拒绝启动,只保留 -XX:+UseZGC |
-XX:SoftMaxHeapSize |
软上限,提示更积极回收 | 随 JDK/配置而异 | 结合容器 limit、分配峰值和 RSS 目标灰度设定,不用固定 70-80% |
-XX:ZAllocationSpikeTolerance |
分配突增相关诊断参数(目标 JDK 支持时) | 随 JDK 而异 | 仅按目标版本语义评估;升级时确认是否仍存在 |
-XX:ConcGCThreads |
并发 GC 线程数 | 按目标 JDK 自动推导 | 容器配额过小时才显式设置,并与 CPU limit/应用线程模型一起验证 |
-XX:ZUncommitDelay |
空闲内存归还 OS 的延迟 | 随目标 JDK 而异 | 关注节点内存回收和页归还成本,不按固定秒数套用 |
生产参数模板(示例,仅说明版本线;容量和线程数需压测):
# JDK 21 显式开启分代 ZGC;JDK 23 默认分代;JDK 24 该参数废弃(JEP 490),JDK 26 起移除
-XX:+UseZGC
-XX:+ZGenerational # JDK 21 专用;JDK 23+ 可省略;JDK 26+ 必须删除此行,否则启动报错
-Xms16g -Xmx16g
-XX:SoftMaxHeapSize=12g # 示例值,按压测调整
-XX:ConcGCThreads=4 # 示例值,与容器 CPU 配额匹配
-XX:ParallelGCThreads=8 # 示例值,根处理/并行阶段用
# GC 日志(关键:监控停顿分布与分代频率)
-Xlog:gc*=info:file=/data/logs/gc.log:time,level,tags:filecount=10,filesize=50m
-Xlog:gc+phases=debug:file=/data/logs/gc-phases.log:time:filecount=5,filesize=50m
监控指标:① 停顿分布 P99/P999 与超 SLA 事件;② 分配速率、堆/RSS 和回收完成度;③ Young/Old 回收频率、转移量与晋升画像;④ 指针修复、屏障开销和记忆集可观测信号(具体指标/日志名随 JDK 变化)。异常时先看版本对应的 GC 日志,再考虑容量、分配治理和线程配置。
可执行的评估模板:保留 JDK 版本和 GC 模式;采集相同流量下的 QPS/RT、分配速率、Young/Old 回收频率、转移量、RSS 和 CPU 配额;先灰度一个实例对比同峰谷窗口。收益要由业务 SLA 和这些基线证明,不引用无法复现的具体百分比或 0.8ms 停顿。
追问 1:为什么分代 ZGC 比单代吞吐高?
非分代 ZGC 需要在统一回收视野里处理短命对象;高分配速率会放大标记/重定位压力。分代后,Young 回收主要处理年轻代和来自老年代的候选引用,Old 回收低频处理更稳定的老对象,从而减少高频路径的工作集合。是否显著提升吞吐,取决于分配画像、跨代引用和资源配额,不能给固定百分比。
追问 2:Generational ZGC 的记忆集会不会像 G1 RSet 一样膨胀?
记忆集占用取决于实现粒度、跨代引用密度和 JDK 版本;ZGC 的内部结构不必与 G1 RSet 一一类比。可迁移的架构结论是:长生命周期集合持有大量短生命周期对象时,跨代引用和回收压力都会上升。应结合 GC 日志、native/JVM 内存画像和分配评审治理,不引用固定百分比。
追问 3:分代 ZGC 与 G1 在大堆低延迟场景如何选?
先按 JDK 决定可选集合,再按业务目标选择:G1 适合吞吐与延迟均衡、生态成熟、希望减少屏障成本的工作负载;Generational ZGC 更适合大堆、高分配、需要把转移与堆规模解耦的场景。不要按 8GB/16GB 固定分界,也不要说“必然失控”;要用同版本压测和运维约束决策。
- 误以为分代 ZGC 完全无停顿:仍有根处理等短 STW 阶段;时长与根集、线程数、平台和版本有关,不能承诺固定亚毫秒。
- JDK 版本对应错误:Generational ZGC 是 JDK 21 引入、JDK 23 默认、JDK 24 移除非分代模式;JDK 17 的 ZGC 是非分代,
-XX:+ZGenerational不可用。 - 照抄旧参数清单:部分 ZGC 诊断参数、默认值和日志视图随 JDK 变化甚至移除;升级前核对目标版本
PrintFlagsFinal和 GC 文档。 - 忽略 SoftMaxHeapSize 的前提:软上限可能让回收更积极,但过小会提升 GC 频率/CPU;要和容器 limit、RSS、SLA 一起压测。
- 用 GC 次数评估 ZGC 健康度:ZGC Young GC 极频繁(每秒数次)属正常,应看停顿分布与吞吐,而非次数。
上线门禁:GC 停顿 SLO、采集 -Xlog:gc*、压测对照升降配。表达提纲:停顿曲线→收集器/区域证据→调参或代码分配治理。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
这道题考察的不是"单点 GC 调优参数",而是 可观测闭环能力:如何把 JVM/GC 的运行状态(GC 次数/停顿、各内存区用量、线程、类加载)持续采集为指标,经由存储与可视化,转化为趋势判断、告警与调优决策。架构师要从"救火式调优"升级到"指标驱动的预防式治理"。
JMX 手工时代:用 jstat / JConsole / VisualVM 连 JMX 看实时数据,无法长期存储与跨实例聚合。
GC 日志 + 脚本:-Xlog:gc* 落盘,靠 awk/脚本解析,勉强做趋势,但缺乏统一指标模型。
Metrics 框架:Codahale Metrics → Micrometer(门面,屏蔽底层注册中心差异),标准化 Meter 类型(Counter/Gauge/Timer/DistributionSummary)。
Prometheus + Grafana:拉模型(pull)定时抓取 /actuator/prometheus,时序存储 + 强大查询(PromQL)+ 看板与告警。2025-26 进一步与 OpenTelemetry 标准融合。
分层职责:① 采集:Micrometer 的 JVM/GC binder 包装 GarbageCollectorMXBean、MemoryPoolMXBean、BufferPoolMXBean、ThreadMXBean、ClassLoadingMXBean,先形成 jvm.gc.pause 等逻辑指标名;② 暴露:PrometheusMeterRegistry 按目标版本规则转换暴露名,例如常见为 jvm_gc_pause_seconds_*;③ 抓取与告警:Prometheus 定时拉取端点,Grafana 看板 + Alertmanager 规则;④ 闭环:趋势异常 → 告警 → 结合 GC 日志定位 → 调优并回归对比。
| 关注点 | Micrometer 逻辑指标(示例) | 使用思路 |
|---|---|---|
| GC | jvm.gc.pause、jvm.gc.concurrent.phase.time、jvm.gc.memory.allocated、jvm.gc.memory.promoted |
按
gc/action/cause
分组看停顿分布、并发阶段、分配与晋升趋势;停顿目标来自业务 SLA 和基线
|
| 内存 | jvm.memory.used、jvm.memory.max |
按
area/id
下钻内存池;对比同峰谷基线和增长斜率,不设全局默认百分比阈值
|
| 线程 | jvm.threads.live、jvm.threads.daemon、jvm.threads.peak、jvm.threads.states |
持续上涨结合线程 dump 与业务变更判断泄漏或队列堆积 |
| 类加载 | jvm.classes.loaded、jvm.classes.unloaded |
加载/卸载差值异常时排查动态代理、生成器和热部署 |
| 缓冲池 | jvm.buffer.memory.used、jvm.buffer.count、jvm.buffer.total.capacity |
按 id 观察直接/映射缓冲,结合 Netty 与本地内存画像定位 |
# Spring Boot 接入:依赖 + 暴露端点,Prometheus 定时拉取
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# Boot 2.x 常见公共标签配置;Boot 3.x 优先核对该版本的 observation/common-tag 属性
management:
endpoints.web.exposure.include: prometheus,health,info
metrics.tags.application: ${spring.application.name}
# Prometheus scrape 配置
scrape_configs:
- job_name: 'jvm'
metrics_path: '/actuator/prometheus'
static_configs: [{ targets: ['app:8080'] }]
指标用于趋势与告警,GC 日志用于根因定位。以目标 Micrometer/Prometheus 版本的
/actuator/prometheus 输出为准确认指标名;告警先建立业务 SLA、部署形态和同峰谷基线,再看斜率、持续时间和分布,不套用固定使用率阈值。
1. 接入:Spring Boot Actuator + micrometer-registry-prometheus,暴露 /actuator/prometheus;保留 -Xlog:gc*:time:file=gc.log 日志作根因分析。
2. Grafana 看板:直接用官方 "JVM (Micrometer)" dashboard,重点看 GC 暂停分布、各代用量、Prometheus 抓取心跳。
3. 告警规则:以完整集合名和原因标签识别 Full GC 并结合 SLA 判级;对老年代增长建立同峰谷基线、斜率与预计耗尽时间;对抓取失败、up=0
或样本断流单独告警。阈值应随负载、堆型和版本回归调整。
4. 闭环调优:指标预警 → 结合 GC 日志定位(如晋升失败、大对象)→ 调参(堆大小/区域、ZGC 软上限等,见 C02.11)→ 观察指标回归。
5. 容器环境:核对目标 JDK/发行版的容器感知能力和默认状态;同时确认 cgroup 版本、CPU/内存 limit、MaxRAMPercentage 和实际 RSS。不要把 UseContainerSupport 的可用性与默认值固定到所有 JDK。
追问 1:指标、日志、链路追踪如何分工? Metrics 看"宏观趋势与告警"(如 GC 暂停分布);Logs 看"单条根因"(如某次回收原因);Traces 看"一次请求跨服务耗时"。Micrometer/Observation/Micrometer Tracing/OpenTelemetry 的集成边界和默认采集链路随目标版本确认。
追问 2:如何区分"GC 导致停顿"与"业务本身慢"? 把目标版本的实际 GC 停顿指标与请求日志/指标按时间对齐,再看下游、连接池、CPU 和发布事件。时间相关只是线索,不能仅凭曲线错位排除 GC。
追问 3:Micrometer 与 OpenTelemetry 什么关系? Micrometer 是应用指标门面,Prometheus registry 负责对应暴露;OTel 覆盖 Metrics/Traces/Logs 等信号。两者可通过目标版本的桥接或 Spring 观测 API 协作,但不必然由 OTel 统一替换。
- 只看 GC 次数不看分布:ZGC Young GC 每秒数次属正常,应看停顿分布与吞吐。
- 容器 OOMKilled 却看 JVM 堆正常:JVM 堆指标通常不含 Metaspace、线程栈、GC native、直接缓冲和进程原生内存;要对照容器 limit、JVM 输出和 cgroup/RSS 画像。
- 只配日志不配指标:无法做趋势与告警,只能事后救火。
- 指标高基数爆炸:给 Timer 加 user_id 等高热度 label,Prometheus 存储与查询被打爆。
- 照搬看板不针对 GC 暂停:通用模板未突出暂停分布,关键信号被淹没。