JVM调优经验
# 上线前与取证基线(所有场景通用)
启动基线(容器/云原生,JDK8+)
# 容器感知 + 内存按百分比分配 + 必备取证
-XX:+UseContainerSupport \
-XX:InitialRAMPercentage=50 -XX:MaxRAMPercentage=50 -XX:MinRAMPercentage=50 \
-Xss512k \
-XX:MetaspaceSize=200M -XX:MaxMetaspaceSize=400M \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/ \
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution \
-XX:+PrintHeapAtGC -XX:+PrintReferenceGC -XX:+PrintGCApplicationStoppedTime \
-Xloggc:/logs/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=14 -XX:GCLogFileSize=100M \
-Dfile.encoding=UTF-8 -Djava.awt.headless=true -Djava.net.preferIPv4Stack=true
# 建议垃圾收集器:G1(JDK11+ 默认),低停顿可评估 ZGC(JDK17+)
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
线上低侵入取证 4 连
jps -l # 确认 PID
jstat -gc <pid> 1000 10 # 1 秒采样 10 次看 GC 态势
top -Hp <pid> -b -n 1 | head -n 30 # 线程 CPU Top
jinfo -flags <pid> && jmap -histo <pid> | head -n 50
1
2
3
4
2
3
4
配合工具:🧰 ⛳️ Arthas、📊 ⛳️ GC 日志阅读要点
分析的理论依据与判读要点
- 为什么一定要打开年龄分布与停顿日志
依托“动态年龄判断(Dynamic Tenuring)”机制,JVM 会根据 Survivor 压力动态提前晋升;如果没有
PrintTenuringDistribution
,就无法判断“是对象真的老了,还是被迫提前晋升”。 同理,PrintGCApplicationStoppedTime
能把“感知到的卡顿”与 STW 停顿精确挂钩,避免把业务抖动误判成 GC 问题。 - 容器百分比分配的理论基础
容器内总内存 = 堆 + 线程栈 + 直接内存 + JIT 代码缓存 + Metaspace + 其他本地内存。用
MaxRAMPercentage/InitialRAMPercentage
是为本地内存留出生存空间,降低 cgroup 下的 OOMKill 风险。 - 四连取证能快速建立“证据闭环”
jstat -gc
看宏观趋势(YGC/FGC、OU 斜率)→top -Hp
定位热点线程 →jinfo
还原真实启动参数(很多问题是“被调参”导致)→jmap -histo
识别“谁在吃内存”。
# 场景 1:Young GC 频繁(抖动明显)
识别
jstat -gc
里 YGC 快速累加、Eden 使用 EU 常接近 EC;平均 YGCT/YGC 不高但次数过多。
先看
- 对象瞬时创建速率、Eden/Survivor 是否太小、是否有大批量短命对象(JSON/日志/DTO)。
命令
jstat -gc <pid> 1000 20 # 看 YGC 增速、EU/EC 波动
jmap -histo <pid> | head -n 50 # Top 类;短命对象明显
1
2
2
判断 → 快速修复
- Eden 太小 → 适度放大年轻代(G1 下提升总堆或 MaxRAMPercentage;或显式 -Xmn)。
- Survivor 顶不住 → 调整晋升阈值/Survivor 比例,避免过早晋升。
- 业务侧批量创建 → 分批/复用缓冲、减少临时对象(JSON/日志降频、StringBuilder 复用)。
可落地参数
# G1:更偏向吞吐的年轻代设置(先小步)
-XX:MaxRAMPercentage=60
-XX:SurvivorRatio=8 -XX:TargetSurvivorRatio=80
1
2
3
2
3
验证指标
- 观察 1 个高峰周期:YGC 次数下降≥30%、P95 延迟更稳。
分析的理论依据与判读要点
- 理论钩子:Eden 太小/瞬时分配速率高 → Minor GC 触发频繁;Survivor 目标占用(TargetSurvivorRatio)过低会放大提前晋升,进一步加重后续 GC 压力。
- 关键证据怎么看:
jstat -gc
中 EU/EC 长时间贴近,YGC 计数增长快且单次 YGCT 不高。PrintTenuringDistribution
里年龄低的段位被频繁晋升(S 区压力导致动态年龄提前)。
- 为什么这些调参有效:
- 适度放大年轻代(尤其 Eden)降低 Minor 触发频率;
- 把
TargetSurvivorRatio
提到 75~85 能增加 S 区的“缓冲带”,减少“被迫晋升”。
- 现场小技巧:估算晋升量(见文末通用公式),若“YGC 多 + 晋升量也大”,优先从 Survivor/S 配比 & 阈值 下手,而不是盲目增堆。
# 场景 2:Full GC 频繁 / 老年代压力大
识别
FGC
快速增加;OU
持续上升;日志见频繁 Full GC 或 Mixed GC 占比异常。
先看
- 是否有
System.gc()
、老年代晋升量、Metaspace 是否顶格。
命令
jstat -gc <pid> 1000 20
jinfo -flags <pid> | grep -i ExplicitGC || true
jmap -histo <pid> | head -n 50
1
2
3
2
3
判断 → 快速修复
- 有显式 GC → 禁用:
-XX:+DisableExplicitGC
。 - 晋升过快 → 放大年轻代/Survivor,减少进入老年代的流量。
- 老年代被长生对象占满 → 排查缓存/集合上限;必要时分层缓存(本地 + Redis),给本地缓存上限+淘汰策略。
可落地参数
-XX:+DisableExplicitGC
# G1 提前并发标记、减少 Full GC 概率(先小步)
-XX:InitiatingHeapOccupancyPercent=40
1
2
3
2
3
验证指标
- 1 小时窗口 FGC≈0 或极少;老年代增长斜率下降。
分析的理论依据与判读要点
- 理论钩子:
- 空间担保(Promotion Guarantee):Minor 结束前要确保老年代能接住待晋升对象,否则晋升失败→Full GC/退化收集;
- 过早晋升:S 区扛不住,年轻对象涌入老年代,导致 OU 斜率变陡。
- 关键证据怎么看:
- GC 日志里
Promotion failed
/Allocation Failure
、FGC cause; - OU(老年代使用量)在每次 Minor 后阶梯式抬升;
jmap -histo
出现大量中寿命对象(非真正“老”对象)。
- GC 日志里
- 为什么这些调参有效:
- 禁用显式 GC可去掉“人为制造”的 Full GC;
- 提升 Survivor 承载 + 延后晋升阈值能直接削弱晋升流量;
- G1 的 IHOP(InitiatingHeapOccupancyPercent)下调到 ~40-45% 可更早启动并发标记,降低被动 Full GC 概率。
# 场景 3:堆占用稳步上升 / 周期性 Full GC / OOM
识别
- 堆曲线只升不降;Full GC 后回收有限;最终 OOM: Java heap space。
先看
- 是否存在集合/缓存无限增长、异步堆积、队列无上限等模式。
命令
# 连续对比对象直方图
for i in {1..3}; do jmap -histo <pid> | head -n 30 | tee /tmp/histo-$i.txt; sleep 60; done
# 必要时抓堆(高峰前后各一次)
jmap -dump:format=b,file=/tmp/heap-$(date +%s).hprof <pid>
1
2
3
4
2
3
4
判断 → 快速修复
- Top 类是集合(ArrayList/HashMap/ConcurrentHashMap)→ 加上限 + 淘汰;或换 Caffeine/Ehcache。
- 某业务批处理累积 → 切片处理(分页/流式)。
- 反序列化对象体量巨大 → 引入压缩/流式解析,避免一次性 materialize。
验证指标
- Full GC 后可回收比率↑;老年代“锯齿”恢复;OOM 消失。
分析的理论依据与判读要点
- 理论钩子:这是“活跃度提升或泄漏”的经典特征,与回收器无关;Full GC 后回收有限说明堆里“真有活对象”。
- 关键证据怎么看:
- 多次
jmap -histo
做“差分对比”,若某些集合类(ArrayList/HashMap/C.H.M.
)持续增长,基本可判定是无限增长容器/缓存无上限; - 若对象体量巨大(大数组/大字节串),考虑大对象策略(见场景 4/6 的 Humongous)。
- 多次
- 为什么这些动作有效:
- 给缓存/队列加上限与淘汰策略,从“源头”降低活跃对象的基数;
- 通过流式/分页把“尖峰物化”为“细水长流”。
# 场景 4:CPU 飙高(但未必是 GC)
识别
- load/cpu 高,GC 指标正常;多半是热方法/死循环/锁竞争。
先看
- 哪些线程在忙、热点方法在哪、是否有频繁 GC 安全点或日志 I/O。
命令
top -Hp <pid> -b -n 1 | head -n 20 # 找到高 CPU TID(十进制)
printf "0x%x\n" <tid> # 转十六进制
jstack <pid> | grep -A30 <hex_tid> # 精确到代码行
# 或直接用 Arthas
# as.sh -> thread -n 5; profiler start; <压测或等待>; profiler stop
1
2
3
4
5
2
3
4
5
判断 → 快速修复
- 业务死循环/热方法 → 修正逻辑;必要时限流/降级。
- 序列化/日志热点 → 降采样/降级日志级别,复用对象(缓冲区/ThreadLocal)。
- 自旋/锁竞争 → 降低锁粒度/缩短临界区;考虑无锁结构。
验证指标
- 线程 Top 平稳;CPU 恢复;P95 延迟下降。
分析的理论依据与判读要点
- 理论钩子:GC 并非万金油。高 CPU 很多是业务自旋/热循环/锁竞争/序列化热点。
- 关键证据怎么看:
- GC 指标正常(YGCT/FGCT 占比不高),但
top -Hp
显示单线程占用高; jstack
定位到具体方法或锁等待;- 若 GC 相关,通常会伴随
Safepoint
尖刺或 Remark/Mixed 高占比。
- GC 指标正常(YGCT/FGCT 占比不高),但
- 为什么这些动作有效:
- 热点在业务,靠“降采样/限流/缩短临界区/复用缓冲区”比调 GC 参数更立竿见影。
# 场景 5:Metaspace 撑爆 / 触发 Full GC
识别
- 报错
OutOfMemoryError: Metaspace
或 GC 日志显示 Metaspace 接近上限并触发 Full GC。
先看
- 是否热加载频繁、动态代理/字节码生成泛滥、类卸载受阻。
命令
jstat -gcmetacapacity <pid> 1000 10
jinfo -flags <pid> | egrep -i "MetaspaceSize|MaxMetaspaceSize"
1
2
2
判断 → 快速修复
- 调大初始/最大 Metaspace(先小步);排查框架(频繁生成代理类/脚本引擎)。
- 关闭无意义的热加载;定期重用 ClassLoader(插件/脚本场景)。
可落地参数
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=512M
1
验证指标
- Metaspace 使用稳定,相关 Full GC 消失。
分析的理论依据与判读要点
- 理论钩子:Metaspace 用尽同样会触发 Full GC,但这不是“老年代满”,而是类元数据(类加载、动态代理、脚本引擎)的问题。
- 关键证据怎么看:
jstat -gcmetacapacity
曲线顶格;FGC 原因与 Metaspace 相关;- 结合业务时间点,是否有热加载/代理类不断生成。
- 为什么这些动作有效:
- 增大
MetaspaceSize/MaxMetaspaceSize
只是“腾时间”,根因是ClassLoader 不释放或代理泛滥; - 稳态系统尽量避免频繁热加载,插件/脚本用可控生命周期的 ClassLoader。
- 增大
# 场景 6:停顿长 / 卡顿明显(STW 问题)
识别
- 用户体感“卡”;GC 日志有长时间停顿;
PrintGCApplicationStoppedTime
有尖刺。
先看
- 是否频繁 Full GC / Remark;是否并发阶段遭遇晋升失败;Safepoint 触发频繁。
命令
# 先开启/确认 GC 日志(见基线)
grep -E "Pause|Stopped" /logs/gc-*.log | tail -n 100
jstack <pid> > /tmp/jstack-$(date +%s).txt
1
2
3
2
3
判断 → 快速修复
- 切换/优化回收器:优先 G1;极致低停顿评估 ZGC(JDK17+)。
- 减少大对象突发:分批、批次限额;调大
-XX:G1HeapRegionSize
(适度)有时能减少 humongous。 - 热路径减少
synchronized
长临界区;I/O 超时设置合理。
验证指标
- P95/P99 停顿显著下降;长尾抖动消失。
分析的理论依据与判读要点
- 理论钩子:
- 长停顿常见于 Full GC、Remark、Mixed 回收负载重;
- Humongous(G1 下≥一半 Region 的对象)会带来额外的标记/回收复杂度,间接放大停顿。
- 关键证据怎么看:
PrintGCApplicationStoppedTime
出现尖峰;- GC 日志出现“Humongous allocation/reclaim”密集记录,或 Mixed/Remark 时间异常长。
- 为什么这些动作有效:
- 业务侧切片/控峰是第一优先级;
- 适度增大
G1HeapRegionSize
可提高“成为 Humongous”的阈值,但要监控回收效率变化; - ZGC 适合极低停顿 SLA,但迁移成本与观测手段要同步跟上。
# 场景 7:吞吐上不去(GC 占比高)
识别
- GC 总耗时占比高;吞吐低。
先看
- GC 算法是否匹配目标(低停顿 vs 吞吐)、并行度是否拉满。
命令
jstat -gc <pid> 1000 10 # (Y|F)GCT 累计/比例
jinfo -flags <pid> | egrep -i "UseG1GC|ParallelGCThreads|ConcGCThreads"
1
2
2
判断 → 快速修复
- 吞吐优先:并行度到位(核数一半到等同核数),适当增大堆,降低 GC 频率。
- 若是 G1:调
-XX:MaxGCPauseMillis=200~500
让收集器更偏吞吐;并确认ParallelGCThreads/ConcGCThreads
合理。
可落地参数
-XX:ParallelGCThreads=<cpu/2~cpu> -XX:ConcGCThreads=<ParallelGCThreads/4~1/2>
-XX:MaxGCPauseMillis=300
1
2
2
验证指标
- GC 占比下降;TPS/QPS 提升。
分析的理论依据与判读要点
- 理论钩子:暂停目标越紧(如 G1 的
MaxGCPauseMillis
很低),年轻代越小、回收越勤,GC 开销比例越高;并行线程数不足也会拖吞吐。 - 关键证据怎么看:
- (Y|F)GCT/进程运行时长 比例偏高;
ParallelGCThreads/ConcGCThreads
与 CPU 不匹配。
- 为什么这些调参有效:
- 放宽暂停目标让 G1 更偏向吞吐、扩大年轻代;
- 合理配置并行/并发 GC 线程让 CPU 利用率更高。
# 场景 8:容器内存打架(RSS>cgroup,进程被 OOMKill)
识别
- Pod/容器被 OOMKill;Java 内部看起来还有余量。
先看
- 是否用固定
-Xms/-Xmx
抢满内存;本地内存(DirectBuffer、线程栈、JNI)是否超出预期。
命令
# Java 堆 vs 进程 RSS
jcmd <pid> VM.native_memory summary | head -n 50 # JDK11+ NMT(若可用)
jinfo -flags <pid> | egrep "Xms|Xmx|MaxRAMPercentage|MaxDirectMemorySize|Xss"
1
2
3
2
3
判断 → 快速修复
- 改用 MaxRAMPercentage 按配额分配堆;降低
-Xss
(例如 512k);限制直接内存:-XX:MaxDirectMemorySize
。 - 评估线程数(池大小、Tomcat/Netty),避免线程栈撑爆。
可落地参数
-XX:MaxRAMPercentage=50 -XX:InitialRAMPercentage=50 -Xss512k -XX:MaxDirectMemorySize=256m
1
验证指标
- 进程 RSS < cgroup 限额,OOMKill 消失。
分析的理论依据与判读要点
- 理论钩子:cgroup 下的 OOM 与 Java 堆不一定相关,更多发生在本地内存(线程栈、直接内存、JNI、代码缓存)。
- 关键证据怎么看:
- 堆看似有余量,但容器仍 OOMKill;
jinfo
显示-Xms/-Xmx
抢满内存或-Xss
偏大; - 线程数偏多(每个线程栈默认 1M 甚至更大)。
- 堆看似有余量,但容器仍 OOMKill;
- 为什么这些动作有效:
- MaxRAMPercentage 为非堆留出生存空间;
-Xss
降到 512k 对常见 Web 服务足够;- 限制
MaxDirectMemorySize
,避免 Netty/IO 跑飞。
# 一键排查脚本(复制即用)
#!/usr/bin/env bash
set -euo pipefail
PID=${1:-$(jps -l | awk '/\.jar|org\.springframework|tomcat|jetty/{print $1; exit}')}
ts(){ date "+%F %T"; }
echo "[$(ts)] PID=$PID"
echo -e "\n== jstat -gc (1s*10) =="; jstat -gc $PID 1000 10
echo -e "\n== top threads =="; top -Hp $PID -b -n 1 | head -n 30
echo -e "\n== jinfo flags =="; jinfo -flags $PID
echo -e "\n== jmap histo TOP50 =="; jmap -histo $PID | head -n 50
echo -e "\n== gc last 200 lines =="; ls -1t /logs/gc-*.log 2>/dev/null | head -n 1 | xargs -r tail -n 200 || echo "no gc log"
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
分析的理论依据与判读要点
- 输出顺序等同“先宏观后微观”的证据闭环:
jstat -gc
判断“谁在往老年代灌”;top -Hp
识别是否业务热点导致假性“卡”;jinfo
还原真实调参(很多线上问题来自“默认值误解/运维模板误改”);jmap -histo
锁定增长最快的类型族群。
- 建议把脚本扩展一行晋升量估算(如下公式),形成“晋升量曲线”。
通用晋升量估算(贴到博客显眼处)
本轮近似晋升量(MB) ≈ 年轻代释放量 − 堆整体释放量 读一次 Minor GC 汇总:
- 年轻代释放量 = “PSYoungGen: A→B(C)” 的
A−B
- 堆整体释放量 = “Heap: X→Y(Z)” 的
X−Y
大于 0 说明老年代被“灌入”了这么多对象;连成时间序列,就得到晋升量曲线。
# 验证与固化(所有场景收尾动作)
- 指标对比(变更前后同一负载 30–60 分钟):
- GC:
YGC/FGC
次数与平均耗时、最大停顿、老年代斜率。 - 业务:P50/P95/P99 延迟、吞吐(QPS/TPS)。
- GC:
- 小步快跑:一次只改一个维度(堆/年轻代/阈值/并行度/代码),保留回滚。
- 沉淀基线:把 final 参数与编码规范(批处理切片、缓存上限、日志降噪)写进团队模板。
分析的理论依据与判读要点
- 只改一件事就能建立“因果对应”:堆/新生代/阈值/并行度/代码,每次只动一个旋钮,观察 30–60 分钟,形成A/B 对照;
- 指标分三类对齐:
- GC 侧:YGC/FGC 次数、YGCT/FGCT 总占比、最长停顿、OU 斜率、晋升量曲线;
- 业务侧:P50/P95/P99、QPS/TPS;
- 资源侧:RSS、线程数、DirectMemory 使用。
- 把“理论→证据→动作→结果”写进团队模板:例如“新服务基线参数 + 日志开关 + 排查脚本 + 回滚点”,形成可复用的“上线包”。
上次更新: 2025/10/14, 20:08:40