Java GC 日志深度解读——看懂 GC 日志才能做真正的调优
Java GC 日志深度解读——看懂 GC 日志才能做真正的调优
适读人群:做过 Java 服务但没系统分析过 GC 日志的开发者 | 阅读时长:约 17 分钟 | 核心价值:真正读懂 GC 日志里的每一行,建立 GC 调优的正确分析框架
2021 年,我们有个接口的 P99 延迟莫名其妙地高,平均值很好看,但 P99 稳定地在 800ms 以上。各种代码层面的优化都试了,没什么效果。
后来我把 GC 日志开了,打开分析工具一看,每隔 3 分钟左右就有一次 Full GC,每次 Full GC 停顿约 700ms。那些 P99 高的请求,刚好撞上了 Full GC 的停顿。
这是一个典型的"GC 是性能问题根因"但代码层面找不到答案的场景。从那之后,我养成了凡是排查性能问题,第一步先看 GC 日志的习惯。
先把 GC 日志开起来
很多团队的服务跑了几年,GC 日志一直是关着的。这实际上是一个很低的代价:GC 日志的开销非常小,对性能几乎没有影响,但关键时候能救命。
Java 8 的 GC 日志配置:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/var/log/app/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50mJava 9+ 的统一日志格式(推荐):
-Xlog:gc*:file=/var/log/app/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=50m我用的是 Java 11,所以下面的日志例子都是 Java 11 的统一日志格式。
读懂 G1 GC 日志
G1 是目前最常用的 GC,Java 9+ 默认使用。先看一段真实的 G1 GC 日志:
[2024-03-15T09:23:14.847+0800][1234.567s][gc,start] GC(127) Pause Young (Normal) (G1 Evacuation Pause)
[2024-03-15T09:23:14.847+0800][1234.567s][gc,task ] GC(127) Using 8 workers of 8 for evacuation
[2024-03-15T09:23:14.921+0800][1234.641s][gc,phases ] GC(127) Pre Evacuate Collection Set: 0.1ms
[2024-03-15T09:23:14.921+0800][1234.641s][gc,phases ] GC(127) Evacuate Collection Set: 62.3ms
[2024-03-15T09:23:14.921+0800][1234.641s][gc,phases ] GC(127) Post Evacuate Collection Set: 8.7ms
[2024-03-15T09:23:14.921+0800][1234.641s][gc,phases ] GC(127) Other: 3.1ms
[2024-03-15T09:23:14.921+0800][1234.641s][gc,heap ] GC(127) Eden regions: 128->0(150)
[2024-03-15T09:23:14.921+0800][1234.641s][gc,heap ] GC(127) Survivor regions: 12->15(20)
[2024-03-15T09:23:14.921+0800][1234.641s][gc,heap ] GC(127) Old regions: 201->203(512)
[2024-03-15T09:23:14.921+0800][1234.641s][gc,heap ] GC(127) Humongous regions: 3->3(3)
[2024-03-15T09:23:14.921+0800][1234.641s][gc ] GC(127) Pause Young (Normal) (G1 Evacuation Pause) 1721M->534M(2048M) 74.2ms我来逐行解读这段日志:
第一行: Pause Young (Normal) (G1 Evacuation Pause) — 这是一次 Young GC(新生代垃圾回收),类型是 Normal(不是 Mixed 或 Full),原因是 G1 Evacuation Pause(Eden 满了触发的)。
Using 8 workers of 8 for evacuation — 用了 8 个 GC 线程,跑满了。如果经常看到"Using 3 of 8",说明 GC 线程被 CPU 竞争影响了。
Eden regions: 128->0(150) — Eden 区从 128 个 Region 降到 0(全清空),新生代 Region 上限是 150。
Old regions: 201->203(512) — Old 区从 201 增加到 203,说明有一些对象从 Young 晋升到 Old。Old 最大 512 个 Region。
最后一行的核心数据: 1721M->534M(2048M) 74.2ms
1721M:GC 前堆占用534M:GC 后堆占用(2048M):堆总大小74.2ms:这次 GC 的停顿时间(Stop-The-World 时间)
这次 GC 回收了 1187MB,停顿了 74ms,比较正常。
发现问题:看什么指标
指标一:GC 停顿时间
74.2ms ← 这次 Young GC 停顿G1 GC 的目标停顿时间默认是 200ms(-XX:MaxGCPauseMillis=200)。如果大量 GC 停顿超过这个值,说明配置或内存有问题。
指标二:Old Gen 晋升趋势
Old regions: 201->203(512) ← 从 201 增加到 203如果每次 Young GC 之后,Old Gen 都在涨,而且涨得快,说明对象过早晋升(可能是 Survivor 区太小),或者有内存泄漏。
指标三:Mixed GC 和 Full GC
Pause Young (Mixed) ← Mixed GC,清理 Young + 部分 Old
Pause Full ← Full GC,停顿时间最长,要重点关注Full GC 频繁出现是需要警惕的信号。
踩坑实录一:-Xmx 和 -Xms 不一样大,导致周期性停顿
这是一个很低级但很常见的配置错误。
# 问题配置
-Xms512m -Xmx2gJVM 启动时分配 512MB,可以按需增长到 2GB。看起来很合理,但实际问题是:
每次堆扩容,JVM 需要向 OS 申请内存,这个操作会有停顿。更麻烦的是,扩容之后如果触发了 Full GC(因为 GC 尝试通过收缩堆来降低内存使用),堆又缩小了,下次又要扩,如此往复,产生周期性停顿。
GC 日志里看到的现象是:
[gc,heap] Heap Expansion: 512M->1024M ← 堆扩容,有额外开销正确做法:生产环境永远设 -Xms = -Xmx,让 JVM 启动时就分配最终大小,不再动态变化。
# 正确配置
-Xms2g -Xmx2g踩坑实录二:Humongous 对象导致 G1 频繁 GC
G1 里有一个特殊概念叫 Humongous 对象:大小超过单个 Region 50% 的对象。这类对象直接分配在 Old Gen,绕过了 Young Gen。
如果频繁创建大对象,会导致:
- Old Gen 快速填满,触发 Mixed GC 甚至 Full GC
- Humongous 分配失败,直接 Full GC
在 GC 日志里看到:
Humongous regions: 3->8(20) ← Humongous 对象在增加或者看到大量:
Pause Young (Concurrent Start) (G1 Humongous Allocation)这是触发 GC 的原因是 Humongous 分配,说明大对象太多了。
解法:
package com.example.gc;
/**
* 减少 Humongous 对象的实践
* G1 默认 Region 大小是堆大小/2048,最小 1MB,最大 32MB
* 超过 Region 大小 50% 的对象就是 Humongous 对象
*/
public class HumongousObjectExample {
// 问题:频繁创建大 byte[]
public static byte[] problematicMethod() {
// 如果 Region 大小是 4MB,超过 2MB 的数组就是 Humongous 对象
return new byte[3 * 1024 * 1024]; // 3MB,是 Humongous 对象
}
// 解法 1:减小数组大小(如果业务允许)
// 解法 2:增大 Region 大小(但这会影响 GC 效率)
// -XX:G1HeapRegionSize=8m 将 Region 大小从 4MB 调大到 8MB,
// 这样 4MB 以下的数组就不再是 Humongous 对象了
// 解法 3:复用 byte[](对象池)
}踩坑实录三:GC 日志显示"to-space exhausted",触发 Full GC
这是 G1 最常见的导致 Full GC 的原因之一:
[gc] GC(127) To-space exhausted
[gc] GC(128) Pause Full (G1 Evacuation Pause)to-space exhausted 意思是:Young GC 期间,要把存活对象复制到 Survivor 或 Old 区时,发现没有足够的空闲 Region 了,不得不升级为 Full GC。
根本原因通常是:
- Old Gen 太满了(超过 75% 就会开始 Mixed GC,但如果来不及清理...)
- 对象晋升速率太高(短命对象太多,Survivor 装不下,直接到 Old)
- Heap 整体太小
解法优先级:
- 增大堆大小(最直接)
- 减小
-XX:G1NewSizePercent(增加 Old Gen 比例) - 增大
-XX:G1HeapOccupancyPercent(更早开始 Mixed GC)
用 GCEasy 分析 GC 日志
手动读 GC 日志很累,生产中遇到问题时推荐用 GCEasy(https://gceasy.io/)。把 GC 日志文件上传,它会自动生成报告:
- GC 停顿时间分布图
- 吞吐量(应用运行时间 / 总时间)
- 关键问题诊断(比如检测到 to-space exhausted)
我自己每次做 GC 调优,都是先用 GCEasy 生成报告,找到问题区域,然后再深入分析具体的 GC 事件。
一张表:常见 GC 问题和对应的日志特征
| 问题 | GC 日志特征 | 解法方向 |
|---|---|---|
| Young GC 停顿过长 | Pause Young > 200ms | 减少存活对象,增大新生代 |
| Full GC 频繁 | Pause Full 频繁出现 | 找内存泄漏,增大堆 |
| to-space exhausted | To-space exhausted | 增大堆,优化对象生命周期 |
| Humongous 导致 GC | G1 Humongous Allocation | 避免大对象,调大 Region |
| GC 线程不足 | Using 2 workers of 8 | 检查 CPU,避免 GC 线程被压 |
| 堆扩缩频繁 | Heap Expansion/Shrinking | 设置 Xms=Xmx |
GC 调优是一个系统工程,不是靠猜、靠"感觉这个参数应该调大"。量化问题、分析数据、针对性调整、观察效果,这才是正确的流程。
