GC 调优的核心不是调参数,而是先诊断问题,再针对性优化。本文梳理 JVM GC 日志的解读方法、常见 GC 问题的根因定位,以及 G1/ZGC 的实用调优策略。
目录
| 章节 | 说明 |
|---|---|
| GC 调优的基本原则 | 先量化,再优化 |
| GC 日志解读 | 关键字段含义 |
| 常见 GC 问题诊断 | Full GC、内存泄漏、停顿尖刺 |
| G1 调优实践 | 参数与场景 |
| ZGC 调优实践 | 亚毫秒停顿的代价 |
| Go GC 调优 | GOGC 与 GOMEMLIMIT |
GC 调优的基本原则
三个指标先量化
在调任何参数之前,先确定当前 GC 的基准数据:
- GC 停顿时间(P99 / P999)
- GC 频率(每分钟触发次数)
- GC CPU 占比(GC 消耗的 CPU 时间 / 总 CPU 时间)
目标参考:
- 低延迟服务:P99 停顿 < 200ms,GC CPU < 10%
- 批处理任务:GC CPU < 20%,停顿可接受数秒
调优优先级
-
减少对象分配(业务层) ← 最有效,治根本
- 复用对象(对象池)
- 避免不必要的中间对象
- 用原始类型替代包装类
-
调整堆大小← 简单有效
- 堆太小:GC 频繁
- 堆太大:GC 停顿长(G1)/ 内存浪费
-
选择合适的 GC 算法← 针对场景
- 延迟敏感:ZGC / Shenandoah
- 吞吐优先:G1 / Parallel GC
-
精细调参← 最后手段,调参不当可能适得其反
GC 日志解读
开启 GC 日志(JDK 9+)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
# 详细 GC 日志,记录到文件,保留 5 个,每个 20MB
G1 GC 日志示例
[2024-01-15T10:30:45.123+0800][1234ms][info][gc] GC(42) Pause Young (Normal)
(G1 Evacuation Pause) 1234M->567M(2048M) 45.123ms
[2024-01-15T10:31:00.456+0800][info][gc] GC(43) Pause Young (Concurrent Start)
字段含义:
| 字段 | 含义 |
|---|---|
GC(42) |
第 42 次 GC |
Pause Young |
Young GC(STW) |
Normal |
正常触发(非 System.gc()) |
1234M->567M |
GC 前后堆使用量 |
(2048M) |
堆总大小 |
45.123ms |
停顿时间 |
Concurrent Start |
并发标记周期开始(触发 Mixed GC 的前置工作) |
关键日志模式
| 日志关键字 | 含义 | 关注点 |
|---|---|---|
Pause Young |
Minor GC | 频率是否过高 |
Pause Mixed |
Mixed GC | 是否有效回收了老年代 |
Pause Full |
Full GC | 必须优化! |
Concurrent Mark Abort |
并发标记被中断 | 内存分配速度太快 |
to-space exhausted |
To Space 耗尽 | 需要增大堆或降低晋升率 |
GC 日志分析工具
- GCViewer:图形化展示 GC 日志,可视化停顿分布
- GCEasy:在线 GC 日志分析(gcEasy.io)
- 命令行快速分析:awk/grep 提取关键统计信息
# 提取 GC 停顿时间(从小到大排序,查看最大值)
grep "Pause" gc.log | awk '{print $NF}' | sort -n | tail -20
常见 GC 问题诊断
问题一:Full GC 频繁
诊断步骤:
- 确认是真正的 Full GC 还是
System.gc()显式调用:
grep "Pause Full" gc.log | head -5
# 日志中含 "(System.gc())" 则为显式调用
- 查看 Full GC 前后的堆状态:
grep -A3 "Pause Full" gc.log | head -20
- 检查 Metaspace(类加载过多也会触发 Full GC),设置合理上限:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
常见原因与对策:
| 原因 | 对策 |
|---|---|
| 老年代持续增长(内存泄漏) | heap dump 分析,找泄漏对象 |
| 分配速率过快 | 减少对象创建,增大年轻代 |
| Metaspace 溢出 | 增大 MetaspaceSize,检查类加载泄漏 |
| Humongous 对象频繁分配(G1) | 增大 G1HeapRegionSize |
代码里有 System.gc() |
禁用:-XX:+DisableExplicitGC |
问题二:GC 停顿尖刺(偶发长停顿)
# 找出超过 500ms 的停顿
grep "Pause" gc.log | awk -F'ms' '{print $1 "ms"}' | \
awk '{if($NF+0 > 500) print}'
常见原因:
-
操作系统换页(Swapping)
- 症状:GC 日志时间戳跳跃,系统 IO 飙升
- 对策:增加物理内存,服务器环境建议禁用 swap
-
JVM 安全点等待过长
- 症状:safepoint 日志显示等待时间长
- 诊断:
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
-
代码缓存(Code Cache)不足
- 对策:
-XX:ReservedCodeCacheSize=512m
- 对策:
问题三:内存泄漏定位
- OOM 时自动生成 heap dump:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
- 主动抓取两份快照做对比(不等 OOM):
jmap -dump:format=b,file=heap1.hprof <pid>
# 等待一段时间后再抓一次
jmap -dump:format=b,file=heap2.hprof <pid>
- 用 MAT(Memory Analyzer Tool) 分析:查看 Retained Heap 最大的对象,追踪 GC Roots 引用链确认对象为何无法被回收。
G1 调优实践
基础配置模板
# 通用低延迟 Java 服务配置(8GB 堆)
-Xms8g -Xmx8g # 固定堆大小,避免动态扩展
-XX:+UseG1GC # JDK 9+ 默认,显式写出更清晰
-XX:MaxGCPauseMillis=200 # 停顿目标(合理值,不要太激进)
-XX:InitiatingHeapOccupancyPercent=40 # 早触发并发标记(默认 45%)
-XX:G1HeapWastePercent=5 # 允许 5% 的堆浪费(避免过多 Mixed GC)
-XX:G1MixedGCCountTarget=8 # 混合 GC 分 8 次完成(降低单次停顿)
# 日志
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=50m
场景化调优
场景一:Young GC 停顿过长(> 200ms)
# 原因:Eden 区太大,复制对象太多
-XX:MaxGCPauseMillis=100 # 更严格的停顿目标,迫使 G1 减少 Eden Region 数量
场景二:Mixed GC 效果差(老年代持续增长)
# 降低触发阈值,更早开始并发标记
-XX:InitiatingHeapOccupancyPercent=30
# 增加每次 Mixed GC 回收的 Old Region 比例
-XX:G1OldCSetRegionThresholdPercent=15 # 默认 10%
场景三:Humongous 对象问题
# 诊断:查看 Humongous 分配日志
-Xlog:gc+humongous=debug
# 对策:增大 Region 大小
-XX:G1HeapRegionSize=16m # 大于 8MB 的对象才进 Humongous
ZGC 调优实践
ZGC 的设计哲学是自适应,最少调参:
# ZGC 基础配置
-XX:+UseZGC
-Xms16g -Xmx16g # 固定堆大小(ZGC 受益于充足的内存)
-XX:ConcGCThreads=4 # 并发 GC 线程数(建议 = CPU 核数 / 4)
# JDK 21+ 分代 ZGC
-XX:+UseZGC -XX:+ZGenerational
ZGC 的主要调优维度
ZGC 的停顿几乎与堆大小无关,主要调以下三个方向:
1. 堆大小:ZGC 需要更多"余量"供并发 GC 使用,经验值为活跃对象的 3~4 倍:
-Xmx32g # 活跃对象 ~8GB 时建议 32GB 堆
2. 并发线程数:GC 线程太少会导致并发失败(Allocation Stall):
-XX:ConcGCThreads=8 # CPU 密集型应用可适当减少
3. 触发阈值:JDK 16+ 已自适应,通常不需要手动设置;突发分配场景可调:
-XX:ZAllocationSpikeTolerance=2.0 # 允许 2 倍的分配尖刺
Go GC 调优
两个关键参数
# GOGC:控制 GC 频率(默认 100)
GOGC=200 ./myapp # 更少的 GC 频率,更高的内存占用
GOGC=50 ./myapp # 更多的 GC 频率,更低的内存占用
# GOMEMLIMIT:控制内存上限(Go 1.19+)
GOMEMLIMIT=500MiB ./myapp # 接近上限时自动触发更积极的 GC
内存使用分析
// 代码中打印内存统计
import "runtime"
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆分配: %v MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("GC 次数: %v\n", m.NumGC)
fmt.Printf("STW 总时间: %v ms\n", m.PauseTotalNs/1e6)
# pprof 内存分析
go tool pprof http://localhost:6060/debug/pprof/heap
# 查看分配最多的函数(top10)
# 查看调用链(web 可视化)
减少堆分配的实践
// 1. 使用 sync.Pool 复用对象
var bufPool = sync.Pool{
New: func() interface{} { return make([]byte, 4096) },
}
buf := bufPool.Get().([]byte)
// 使用 buf...
bufPool.Put(buf) // 归还,下次 GC 前可复用
// 2. 避免接口类型导致的逃逸
func process(data []int) int { // 值类型参数,可能留在栈上
sum := 0
for _, v := range data { sum += v }
return sum
}
// 3. 使用逃逸分析工具
go build -gcflags="-m" ./... // 查看哪些变量逃逸到堆
参考资料
- Oracle JVM GC 调优指南
- 《深入理解 Java 虚拟机》— 周志明(第 5 章:调优案例)
- Go 性能优化指南:golang.org/doc/gc-guide
- GCEasy:gcEasy.io(在线 GC 日志分析)
评论 (0)