线上日志出现 Full GC,最常见的动作是把 -Xmx 调大一档、重启、观察。问题不在保守,而在跳过归因:Full GC 可能是堆不够、晋升太快、或有东西在泄漏——三者对参数的反应完全不同,只有第一种真该动参数。
参数是放大器,不是修复手段:能把余量用得更充分,也能把分配速率问题往后推几小时。先归类再动手。
1. 三类问题:吞吐、停顿、增长
同一句"GC 很频繁",背后可能是三种互斥病因;混淆它们,调优就成赌博。
| 问题 | 可观测证据 | 参数能否解决 | 真正的解法方向 |
|---|---|---|---|
| 吞吐不够 | GC 时间占比高、CPU 有效利用率低 | 部分能(换收集器、调堆) | 降低分配速率、换并行度 |
| 停顿太长 | 单次 STW 长,P99 与均值差距大 | 能(换低延迟收集器、缩堆) | 先确认停顿是否真的影响 SLA |
| 内存持续增长 | 老年代谷底逐轮抬高,Full GC 后不回落 | 不能 | 找持有链、砍缓存、修泄漏 |
第三类是唯一"调大堆注定失败"的一类:堆翻倍只把 OOM 推后。判据是看 Full GC 后的谷底——稳定即容量问题,单调上升即持有问题,参数在替泄漏争取时间。
2. 收集器选型的四个判据
选型是求约束交集,不是选"最新"。四个输入:堆量级、停顿预算、核数、延迟敏感度。
| 收集器 | 适用堆量级 | 典型停顿量级 | 代价 | 不该选它的场景 |
|---|---|---|---|---|
| Serial | 小于约 1–2 GB | 与堆成正比 | 单线程,简单 | 多核机器上浪费核 |
| Parallel | 数 GB 到数十 GB | 数百 ms 级 | 吞吐优先,停顿不控 | 有严格 P99 要求 |
| G1 | 数 GB 到数十 GB | 数十 ms 级(可设目标) | 写屏障、卡表开销 | 堆极大且停顿预算极紧 |
| ZGC / Shenandoah | 数十 GB 以上 | 亚十 ms 级(按版本核对) | CPU 与内存开销换停顿 | 批处理、延迟不敏感 |
JDK 9 之后 G1 是默认;JDK 21 起 ZGC 分代化,低延迟场景的默认答案发生变化。启动参数层面只是换一个开关:
java -XX:+UseParallelGC -Xms8g -Xmx8g -jar app.jar # 吞吐优先
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar # 多数 Web 服务
java -XX:+UseZGC -Xms32g -Xmx32g -jar app.jar # 极低延迟,核要够
-Xms 与 -Xmx 设同一值,避免堆伸缩带来的额外停顿与 RSS 波动;容器里尤其重要,见第 6 节。
3. 停顿预算从 SLA 来,不从感觉来
"停顿要短"不是判据。可执行口径是:一次请求的 SLO 里允许多少时间花在 GC 上。
| SLA 口径 | 可分配 GC 预算 | 单次停顿上限 | 候选 |
|---|---|---|---|
| P99 < 50 ms | 极小,几乎只能靠避免停顿 | < 5 ms | ZGC / 分区化设计 |
| P99 < 200 ms | 约 20–40 ms | < 50 ms | G1,目标设 20–50 ms |
| P99 < 1 s | 宽松 | < 200 ms | G1 默认或 Parallel |
| 批处理,只看总时长 | 不设上限 | 不关心 | Parallel,堆尽量大 |
MaxGCPauseMillis 是软目标:为达标可跳过年轻代完整清扫,代价是晋升更多、混合回收更频繁。设成个位数通常只换来更多次回收,而非更短停顿。
4. 调参前必须先有基线
没有基线,"改完好像快了"只是期望。基线要覆盖正常时段:正常时段的频率与停顿才是判断异常的参照。
# 统一日志(JDK 9+),最少这三类
java -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
-Xlog:gc+heap+exit \
-jar app.jar
# 从日志里取停顿分布,而不是只看均值
jcmd $(pgrep -f app.jar) GC.heap_info
| 基线指标 | 采集方式 | 用途 |
|---|---|---|
| 停顿的 P50 / P99 / 最大值 | 日志解析,按事件分位数 | 判断是否超预算 |
| 每次回收后的老年代占用 | 日志中的 -> 后数值 |
判断谷底是否在抬高 |
至少留一个完整业务周期(含峰值日)。一周基线比一次压测有价值:压测复现不出真实分配形态。
5. 三类参数的方向与相互挤压
参数不是独立旋钮,而是互相挤压的力。
| 参数族 | 典型参数 | 调大的效果 | 副作用 |
|---|---|---|---|
| 堆规模 | -Xmx / -Xms |
回收次数下降 | 单次停顿变长、RSS 吃满限额 |
| 分代比例 | -XX:NewRatio、-XX:SurvivorRatio |
年轻代变大,晋升减少 | 单次年轻代回收变长 |
| 停顿目标 | -XX:MaxGCPauseMillis |
目标收紧,回收更碎 | 回收次数上升、吞吐下降 |
| 并发线程 | -XX:ParallelGCThreads、-XX:ConcGCThreads |
并行阶段更快 | 抢业务 CPU,核数少时反噬 |
必须记住的挤压链:堆变大 → 单次停顿变长 → 收紧停顿目标 → 回收变碎 → 晋升更早 → 混合回收更多。四个动作可以互相抵消,"调了很久没变化"多源于此。
6. -Xmx 与容器限额的关系
容器里最容易被忽略的约束不是堆,而是 cgroup 内存上限。JVM 看到的是限额(UseContainerSupport 默认开),堆只占其中一部分。
| 组成 | 典型占比 | 说明 |
|---|---|---|
| Java 堆 | 限额的 50%–75% | 由 -Xmx 显式钉住,别交给默认值 |
| Metaspace + CodeCache | 数百 MB 级 | 类多、JIT 编译多时更大 |
| 线程栈 | 线程数 × 栈大小 | 每线程约 1 MB 量级 |
| Direct Buffer / mmap | 按使用量 | Netty、文件映射容易失控 |
# k8s 里的对应关系:limit 是硬墙,堆要留出墙外的空间
resources:
limits:
memory: 4Gi
env:
- name: JAVA_TOOL_OPTIONS
value: "-Xms2560m -Xmx2560m -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=512m"
堆设成 limit 的 60%–70% 是常见起点。堆等于 limit 时,任何一点 native 增长都会让容器被 OOMKilled——而 OOMKilled 在 GC 日志里没有任何痕迹,只在 dmesg 和 kubelet 事件里。因此"GC 正常但进程被杀"要先查 cgroup,而不是查 GC。
7. 根因是分配速率,不是参数
同样 8 GB 堆,分配速率 200 MB/s 与 2 GB/s 的差别比任何参数都大:它决定年轻代回收频率,进而决定晋升量与混合回收压力。
| 现象 | 判据 | 归属 |
|---|---|---|
| 年轻代回收极频繁,存活对象很少 | 回收后占用低但次数多 | 分配风暴(临时对象、日志、序列化) |
| 存活对象逐轮抬高后突降 | 有周期性大对象批次 | 批量任务与缓存周期性重建 |
| 老年代缓慢上升且 Full GC 不回落 | 谷底单调上升 | 持有链问题 |
| 老年代上涨后 Full GC 回收但很快回满 | 回收有效但增长快 | 容量或晋升过早 |
"过早晋升"和"晋升失败"经常被混为一谈,但处置相反:
过早晋升:对象没熬到年龄就被推进老年代
证据 Survivor 使用率高 / tenuring 阈值低 / 老年代随年轻代回收同步涨
手段 加大年轻代或 Survivor,或降分配速率
晋升失败:老年代没有连续空间接纳晋升,触发 Full GC
证据 to-space exhausted / promotion failed
手段 加大堆、减 Region 碎片,必要时换收集器
前者是"推得太早",后者是"接不住"。加大年轻代对前者有效,对后者可能更糟(老年代更小)。
8. GC 日志的最小观察集
平均值几乎没有信息量,要的是分布与谷底。
| 观察项 | 读法 | 危险信号 |
|---|---|---|
| 停顿分布 P50/P99/max | 看 P99 与 max 的倍数关系 | P99 是 P50 的 10 倍以上 |
| 回收类型构成 | 年轻代 / 混合 / Full 的比例 | Full GC 出现即需归因 |
| 回收后占用曲线 | 每轮 -> 后的值 |
谷底逐轮抬高 |
| GC 时间占比 | 累计停顿 / 运行时长 | 超过约 5% 就开始吃吞吐 |
"平均停顿 20 ms、P99 800 ms"是正常且常见的形态:多数回收很快,少数撞上大对象或混合回收就长。判断要不要动手,看的是 800 ms 那次是否落在 SLO 违约的请求上,而非均值漂不漂亮。
9. 验证与回退
变更必须"可对照、可回退",否则调优变成不可复现的经验。
# 变更前:记录生效参数,别相信启动脚本里的文本
jcmd <pid> VM.flags | tr ' ' '\n' | grep -E 'Xm|GC|Pause'
# 变更后:同一负载、同一时段、多轮对照(GC 对流量形态极敏感)
| 验证维度 | 要求 | 常见错误 |
|---|---|---|
| 负载一致 | 同一时段、同一流量比例 | 用峰值前后的数据对比 |
| 轮次 | 至少 3 轮,取分布 | 单轮就下结论 |
| 对照 | 保留旧参数机器(灰度) | 全量替换后无参照 |
| 回退 | 参数在配置中心,可秒级回滚 | 写在镜像里,回退靠重新构建 |
灰度的意义不只是"少炸一点",而是同时段对照:没有对照组,流量下降也会被读成调优成功。
10. 排错:五种"调了没用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 加大堆后停顿反而更长 | 单次回收扫描范围变大 | 换低延迟收集器或收紧停顿目标,而不是继续加堆 |
收紧 MaxGCPauseMillis 后吞吐下降 |
回收变碎、晋升提前 | 目标回到 50–200 ms 区间试 |
| Full GC 后老年代仍高 | 持有链问题,非容量问题 | 转堆分析,别再动参数 |
| 容器 OOMKilled 但 GC 日志正常 | 堆外内存或 cgroup 限额 | 查 dmesg、DirectBuffer、Metaspace |
| ZGC 开了但停顿没降 | 核数不足或分配速率过高 | 核对 ConcGCThreads,先降分配速率 |
低延迟收集器把停顿成本换成 CPU 成本:4 核机器上开 ZGC,并发线程与业务线程抢核,停顿降了、吞吐塌了、P99 更差。核数是前置条件。
11. 边界:什么情况本就不该调
| 场景 | 判断 | 理由 |
|---|---|---|
| 堆小于几 GB 的普通服务 | 默认收集器通常够用 | 该量级下差异不显著 |
| 延迟不敏感的批处理 | 不必调停顿目标 | 只看总时长 |
| 没有基线数据 | 先建基线,再谈参数 | 否则无法判断改动是否有效 |
| 问题表现是内存持续增长 | 不属于 GC 调优范畴 | 参数只会推迟爆发 |
| 停顿已满足 SLA | 不动 | 调优的收益为零,风险不为零 |
边界:GC 调优不解决容量问题。回收线程吃掉一半 CPU 时该加机器或降分配。
12. 自测
- Full GC 后老年代谷底逐轮抬高,说明问题属于哪一类?
- G1 的
MaxGCPauseMillis为什么是软目标?收紧它会带来什么连带效应? - 停顿预算应该从哪份文档推出来,而不是从经验值?
- 过早晋升与晋升失败的证据分别是什么?为什么处置方向相反?
- "平均停顿 20 ms、P99 800 ms"该不该动手,取决于什么?
- 容器里堆设成 limit 的 100% 会发生什么,为什么 GC 日志里看不到?