Java GC 调优实战:从日志解读到问题排查
Java GC 调优实战:从日志解读到问题排查
本文以 G1 GC 为主线,从日志解读、参数调优、问题诊断到面试高频题,构建一套完整的 GC 实战知识体系。
一、GC 基础速览
在深入调优之前,先快速建立几张"底图"——不需要死记硬背,有个印象就行,后面碰到具体场景时能回想起"哦,大概是这么个东西"。
1.1 堆内存分代模型
┌─────────────────────────────────────────────┐
│ Heap │
│ ┌──────────┬──────────┬──────────────────┐ │
│ │ Young │ Old │ Metaspace │ │
│ │ (Eden + │ (Old Gen)│ (元空间,存类元数据) │ │
│ │ Survivor)│ │ │ │
│ └──────────┴──────────┴──────────────────┘ │
└─────────────────────────────────────────────┘
- Young 区:所有新对象在这里分配(大部分"朝生夕死")
- Eden:对象初始分配区
- Survivor 0/1(S0/S1):经历 GC 后存活的对象,两块交替使用
- Old 区:长期存活的对象(经历多次 Young GC 后晋升至此)
- Metaspace:存类的元数据(取代 JDK 8 以前的 PermGen),默认不设上限,容易导致 OOM
1.2 几款 GC 收集器一句话对比
| 收集器 | 算法 | 特点 | 适合场景 |
|---|---|---|---|
| Serial | 标记-复制 / 标记-整理 | 单线程,暂停时 Stop-The-World | 客户端应用、小堆 |
| Parallel | 标记-复制 / 标记-整理 | 多线程并行,吞吐量优先 | 批处理、后台任务 |
| CMS | 标记-清除 | 并发回收,低延迟,但有碎片和浮动垃圾问题 | JDK 8 时代 Web 应用(已废弃) |
| G1 | 标记-复制(Region) | JDK 9+ 默认,可控暂停时间,无碎片 | 大多数服务端应用 |
| ZGC | 染色指针 + 并发整理 | 亚毫秒级暂停,支持 TB 级堆(JDK 15+ 生产可用) | 低延迟大内存场景 |
一句话选型:不知道用啥就用 G1;堆超过 32G 或者对暂停时间极度敏感就上 ZGC。
二、GC 日志完全解读
调优的起点永远是——看日志。本节从日志开启到逐字段拆解,一次性讲透。
2.1 开启 GC 日志
JDK 版本不同,开启方式差异很大,这里列出最常见的两种:
JDK 8:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log
JDK 11+(统一日志框架):
-Xlog:gc*=info:file=/var/log/app/gc.log:time,level,tags
常用 -Xlog 切面:
# 只看 GC 基本信息(生产推荐)
-Xlog:gc*=info:file=gc.log:time,level
# 加详细日志(排查问题时用)
-Xlog:gc*=debug:file=gc.log:time,level
# 看 safepoint 信息(排查长停顿时很有用)
-Xlog:gc*,safepoint=info:file=gc.log:time,level
2.2 G1 GC 日志逐字段拆解
一条典型的 G1 Young GC 日志长这样:
[2026-06-02T10:30:15.123+0800][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->256M(2048M) 12.345ms
[2026-06-02T10:30:15.123+0800][info][gc,heap] GC(42) Eden: 400M(600M)->0B(550M) Survivor: 30M->40M(50M) Heap: 512M->256M(2048M)
[2026-06-02T10:30:15.123+0800][info][gc,phases] GC(42) Evacuate Collection Set: 10.2ms
[2026-06-02T10:30:15.123+0800][info][gc,phases] GC(42) Post Evacuate Cleanup: 1.1ms
逐字段解释:
| 字段 | 含义 | 关注什么 |
|---|---|---|
GC(42) |
GC 编号,每次 GC 递增 | 配合时间戳看 GC 频率 |
Pause Young (Normal) |
Young GC,Normal 表示非并发模式 | 如果频繁出现 Full/Remark 要警惕 |
512M->256M(2048M) |
堆使用从 512M 降到 256M,总堆 2048M | 回收了多少对象 |
12.345ms |
本次暂停耗时 | 核心指标——是否超过 MaxGCPauseMillis |
Eden: 400M->0B |
Eden 区从 400M 清空 | 每次 Young GC 后 Eden 应为 0 |
Survivor: 30M->40M |
Survivor 从 30M 变 40M | 增长了说明对象存活多,可能晋升节奏加快 |
Mixed GC 日志(G1 特有,同时回收 Young + 部分 Old Region):
GC(128) Pause Young (Mixed) (G1 Evacuation Pause) 1536M->980M(2048M) 45.678ms
Mixed GC 和 Young GC 的区别:Mixed GC 会额外回收一部分 Old Region,所以暂停时间更长。出现 Mixed GC 说明 G1 在老年代回收上找到机会了,是好事。
Full GC 日志(G1 下出现这个基本意味着出事了):
GC(200) Pause Full (G1 Compaction Pause) 1950M->1900M(2048M) 2345.678ms
G1 的设计目标是避免 Full GC。如果你在 G1 下看到 Full GC,说明老年代回收速度跟不上分配速度,几乎一定需要调优。
2.3 关键指标速查
| 指标 | 怎么算 | 健康值 | 危险信号 |
|---|---|---|---|
| Young GC 频率 | 每条 GC 日志的时间间隔 | 几秒到几十秒 | 秒级连续 GC |
| Young GC 暂停 | 日志中 xxxms |
< 50ms | > 200ms |
| Mixed GC 暂停 | 同上 | < 200ms | > 500ms |
| 晋升量 | Survivor 大小变化的累计 | 看趋势 | 持续增长 |
| 吞吐率 | 应用运行时间 / (应用运行时间 + GC 时间) | > 98% | < 95% |
| Full GC 频率 | 直接数日志条目 | 0 | 任何一次都需要关注 |
三、核心调优参数
3.1 堆大小:一切的基础
# 初始堆 = 最大堆,避免堆扩容带来的 GC 开销
-Xms2048m -Xmx2048m
# 大堆场景建议
-Xms8g -Xmx8g
铁律:
-Xms和-Xmx在生产环境必须设成一样大,避免 JVM 在运行时动态调整堆大小触发不必要的 GC。
3.2 G1 核心参数
| 参数 | 含义 | 推荐值 | 为什么 |
|---|---|---|---|
-XX:+UseG1GC |
启用 G1 | JDK 9+ 默认无需设置 | — |
-XX:MaxGCPauseMillis |
单次 GC 暂停目标 | 100~200ms | 不要设太小(如 10ms),G1 无法达成时会"自暴自弃"做更多 GC |
-XX:G1HeapRegionSize |
每个 Region 大小 | 默认自动(通常 1~32M) | 堆 / 2048 ≈ 每个 Region 大小;Region 大小 = 2^n,范围 1~32M |
-XX:InitiatingHeapOccupancyPercent |
触发 Mixed GC 的老年代占比阈值 | 45(默认),大堆可降 | 降低此值 = 更早开始回收老年代 = 避免 Full GC,但会做更多 Mixed GC |
-XX:G1MixedGCCountTarget |
一轮 Mixed GC 分几次完成 | 8(默认) | 调大 = 每次 Mixed GC 做少点、快点;调小 = 猛做几次标记周期 |
-XX:G1ReservePercent |
为晋升预留的空间比例 | 10(默认) | 避免"To-space exhausted"导致 Full GC |
典型 G1 配置模板:
java -Xms4g -Xmx4g \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=40 \
-XX:G1ReservePercent=15 \
-Xlog:gc*=info:file=gc.log:time,level \
-jar app.jar
3.3 ZGC 参数要点
ZGC 的理念是"尽量别调,默认就很好",但有几个关键点:
# 启用 ZGC(JDK 15+)
-XX:+UseZGC
# ZGC 不需要设 MaxGCPauseMillis,它的目标在代码里写死了(<1ms)
# 但堆大小仍然要设
-Xms16g -Xmx16g
# ZGC 的"软性"堆占用上限(默认 80%),避免 allocation stall
-XX:SoftMaxHeapSize=12g
SoftMaxHeapSize是 ZGC 中最实用的参数:ZGC 会尽量把堆用到的内存控制在这个值以下,由此减少并发回收的压力。
选 ZGC 之前要注意:
- ZGC 从 JDK 15 开始生产可用,JDK 17 起完善
- 吞吐量略低于 G1(因为并发工作更多),低延迟服务适合,纯批处理不建议
- 需要大内存(16G+)才能体现优势,小堆没必要
四、常见问题诊断
4.1 Full GC 频繁 → 老年代填满了
症状:日志中频繁出现 Pause Full,每次几百毫秒甚至秒级,系统 RT 抖动严重。
根因分析:
分配速度 > 老年代回收速度
├── 内存泄漏:某个数据结构一直在涨,从不释放
├── 对象过早晋升:Survivor 太小,对象没熬够就被推到老年代
├── 老年代本身太小
└── G1 Mixed GC 来不及回收(IHOP 设太高)
排查步骤:
第一步:拉 Heap Dump
# 程序启动时加参数,在 OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
# 或者用 jmap 手动触发
jmap -dump:live,format=b,file=heap.hprof <pid>
第二步:MAT(Memory Analyzer Tool)分析
- 打开 heap dump → Leak Suspects Report
- 看 Dominator Tree:哪个对象持有了最多的内存
- 关注 HashMap、ArrayList、ThreadLocal 等常见泄漏点
第三步:如果不是泄漏,看晋升节奏
# jstat 看 GC 统计,关注 OU(Old Used)的增长趋势
jstat -gc <pid> 1000
- 如果 OU 持续增长且不下降 → 要么泄漏,要么老年代太小
- 如果每次 Young GC 后 Survivor 增长很多 → 对象过早晋升
4.2 GC 暂停时间过长
症状:Young GC 超过 200ms,或者 Mixed GC 超过 500ms,接口超时增加。
根因分类:
| 原因 | 表现 | 方向 |
|---|---|---|
| 堆太大 | Full GC 或 Mixed GC 时要处理大量对象 | 考虑拆服务 / 上 ZGC |
| Region 数太多 | Remembered Set 更新开销大 | 加大 RegionSize,减少 Region 数 |
| 对象复制开销大 | 每次 GC 要复制大量存活对象 | 降低晋升阈值、减少 Survivor 压力 |
| Humongous Allocation | 大对象直接进老年代,频繁触发 Mixed GC | 拆分大对象 / 加大 RegionSize |
| Allocation Stall | ZGC 下,分配速度超过回收速度 | 加大堆 / 加 SoftMaxHeapSize |
G1 排查要点:
# 用 jstat 看关键指标
jstat -gc <pid> 1000 10
关注列:
GCT:累计 GC 时间,如果增长很快说明 GC 开销大OU(Old Used):持续增长不下降 → 有泄漏或晋升过快YGC/YGCT和FGC/FGCT:对比看 Young GC 和 Full GC 的比例
4.3 OOM 排查全流程
首先要分清是哪种 OOM:
| 类型 | 错误信息 | 常见原因 |
|---|---|---|
| Heap OOM | java.lang.OutOfMemoryError: Java heap space |
堆太小 / 泄漏 |
| Metaspace OOM | OutOfMemoryError: Metaspace |
动态加载太多类(CGLIB 代理、Groovy 脚本等) |
| Direct Memory OOM | OutOfMemoryError: Direct buffer memory |
NIO 直接内存未释放 |
| 线程数 OOM | OutOfMemoryError: unable to create new native thread |
线程数超过系统限制 |
标准排查流程:
- 看日志:确认 OOM 类型和发生时间点
- 看 GC 日志:OOM 前有没有 Full GC 频繁?回收后还剩下多少?
- 看堆 Dump:MAT 分析 Dominator Tree,找"大头"
- 看监控:内存增长曲线——是突然飙升(某个大请求)还是缓慢爬坡(泄漏)?
- 看代码:结合 Dump 中的对象引用链定位代码
五、调优流程总结
GC 调优不是玄学,是工程——按照科学的闭环来:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 1.设目标 │ → │ 2.收集据 │ → │ 3.找瓶颈 │ → │ 4.调参数 │ → │ 5.验证 │
│ │ │ │ │ │ │ │ │ │
│ 延迟? │ │ GC日志 │ │ 日志解读 │ │ 逐一调整 │ │ 压测对比 │
│ 吞吐? │ │ 系统监控 │ │ 指标对比 │ │ 观察效果 │ │ 上线观察 │
│ 内存? │ │ │ │ │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
核心原则:
-
先量化,再优化:没有目标就没有终点。典型目标:
- 接口 P99 < 100ms,GC 暂停 < 50ms → 目标:Young GC < 50ms,Full GC = 0
- 夜间批处理任务,吞吐量优先 → 可以容忍偶尔 1s 的 GC 暂停
-
一次只调一个参数:同时改 3 个参数看到效果好 → 完全不知道是哪个起的作用 → 下次出问题还是懵。
-
调整参数不如改代码:如果 heap dump 显示 80% 内存被一个巨大的本地缓存占用,调什么 GC 参数都没用——加个 LRU 淘汰策略才是正解。
-
G1 默认参数通常就很好:大多数人调优的结果是——绕了一圈回到默认值。真正需要调的场景是:堆特别大(> 32G)、对象分配模式特别极端、延迟要求特别严格。
六、面试高频题:Java GC
Q1:如何判断一个对象是否可以被回收?
Java 使用可达性分析(Reachability Analysis),而不是引用计数。
核心思想:从一组称为 GC Roots 的根对象出发,沿着引用链向下搜索。如果某个对象不在任何引用链上(即从 GC Roots 不可达),就可以被回收。
GC Roots 包括:
- 虚拟机栈(栈帧中的局部变量表)引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
- Java 虚拟机内部的引用(基本数据类型对应的 Class 对象、常驻的异常对象等)
- 所有被
synchronized持有的对象 - 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调等
Q2:Java 的四种引用类型有什么区别?
| 引用类型 | 回收时机 | 使用场景 |
|---|---|---|
| 强引用 | 永不回收(只要引用存在) | 普通 Object obj = new Object() |
| 软引用(SoftReference) | 内存不足时才回收 | 图片缓存、本地数据缓存 |
| 弱引用(WeakReference) | 下一次 GC 必定回收 | ThreadLocal 的 key、WeakHashMap |
| 虚引用(PhantomReference) | 任何时候都可能被回收,无法通过它获取对象 | 管理直接内存(DirectByteBuffer)、跟踪对象回收 |
一个常被追问的点:WeakHashMap 的工作原理?
- 它的 Entry 继承自 WeakReference
- Key 被弱引用包裹,当 Key 不再被外部强引用时,下一次 GC 就会回收 Entry
- 常用于做"自动过期"的缓存
Q3:Minor GC / Major GC / Full GC 的区别?
| 类型 | 作用区域 | 触发条件 | 暂停时间 |
|---|---|---|---|
| Minor GC (Young GC) | Young 区 | Eden 满了 | 短(几十 ms) |
| Major GC (Old GC) | Old 区 | 老年代空间不足 | 长(几百 ms ~ s) |
| Full GC | 整个堆 + Metaspace | 多种原因 | 最长(秒级) |
关键区分:
- Major GC 在实践中常与 Full GC 混用,但严格说 Major GC 只看老年代
- CMS 的
system.gc()触发的是 Full GC - G1 的设计中,Mixed GC 取代了单独的老年代回收
- Full GC = Stop The World + 全堆回收,所有应用线程全部暂停
触发 Full GC 的常见原因:
System.gc()显式调用(生产环境建议-XX:+DisableExplicitGC)- 老年代空间不足(CMS/G1 并发回收失败时)
- Metaspace 不足
- 晋升失败(Promotion Failed):Young GC 后 Survivor 放不下,老年代也放不下
- 并发模式失败(Concurrent Mode Failure):CMS 回收太慢,老年代在回收完之前就用完了
Q4:CMS 和 G1 的区别?为什么 G1 取代了 CMS?
| 维度 | CMS | G1 |
|---|---|---|
| 内存布局 | 连续分代 | Region 化,不要求连续 |
| 回收算法 | 标记-清除(有碎片) | 标记-复制(无碎片) |
| 碎片处理 | 需要碎片整理(Serial Old 来做,长暂停) | 通过复制天然解决 |
| 暂停模型 | 不可控 | 可预测(MaxGCPauseMillis) |
| 大对象处理 | 直接进老年代 | Humongous Region,多个连续 Region |
| 并发标记 | 三色标记 + Incremental Update | 三色标记 + SATB(Snapshot-At-The-Beginning) |
取代的根本原因:
- 碎片问题无解:CMS 基于标记-清除,碎片化严重时被迫退化成 Serial Old 做压缩,暂停时间不可预测
- 暂停不可控:CMS 的"并发"并不意味着没有长暂停,remark 阶段和碎片整理都可能造成秒级停顿
- 不能充分利用大内存:CMS 在大堆下表现急剧恶化
- 官方废弃:JDK 9 标记废弃,JDK 14 正式移除
CMS 是"努力减少暂停",G1 是"承诺最大暂停"。一个是尽力而为,一个是 SLA。
Q5:G1 的 Region 和 Remembered Set(RSet)是什么?
Region:
- G1 把堆划分成多个大小相等(1~32MB)的 Region
- Region 可以动态切换角色:Eden / Survivor / Old / Humongous
- 参数:
-XX:G1HeapRegionSize(不设则自动,2048 个 Region 左右)
Remembered Set(RSet):
- 每个 Region 维护一个 RSet,记录哪些 Region 的哪些 Card 引用了本 Region 的对象
- 作用:避免全堆扫描——Minor GC 时只需要扫描 GC Roots + RSet 记录的引用,不用遍历整个老年代
- 代价:RSet 更新有开销,写屏障(Write Barrier)会检查每次引用赋值
- RSet 占用通常占堆的 1%~5%,如果这个比例过高,说明 Region 间引用太复杂
面试时可以这样说:RSet 相当于给每个 Region 配了一个"谁在引用我"的通讯录,GC 时不需要遍历整个老年代,直接翻通讯录就行。
Q6:ZGC 为什么能做到亚毫秒级暂停?
ZGC 的核心技术是染色指针(Colored Pointers) + 并发整理:
染色指针:在 64 位指针的高 4 位嵌入 GC 状态标记:
┌──────┬──────────────────────────────────────────────────────┐
│ 4bit │ 剩余 60 bit │
│ 染色 │ 实际地址 │
└──────┴──────────────────────────────────────────────────────┘
标记位:Finalizable / Remapped / Marked1 / Marked0
染色指针使得 ZGC 可以在不访问对象 Header 的情况下知道对象的状态。
并发整理:
- ZGC 在并发阶段移动对象(compact),不 STW
- 利用读屏障(Load Barrier) + 染色指针的 Remapped 标记
- 如果读到一个被移动但尚未更新引用的指针,读屏障会"自愈"——自动转发到新地址
为什么暂停这么短:
- STW 阶段只做根扫描,不处理整个堆
- 标记、整理、重定位全部并发
- 暂停时间与堆大小无关(堆再大,根的数量基本不变)
一句话概括:ZGC 把几乎所有 GC 工作都挪到了并发阶段,只留最核心的根扫描必须 STW,从而把暂停压缩到毫秒以下。
Q7:线上 OOM 怎么排查?
标准流程:
- 确认 OOM 类型:看错误日志是 Heap / Metaspace / Direct Memory / Thread
- 拉 Heap Dump:
-XX:+HeapDumpOnOutOfMemoryError自动 dump,或用jmap -dump:live,format=b,file=heap.hprof <pid> - MAT 分析三步走:
- Overview → Leak Suspects:自动分析疑似泄漏点
- Dominator Tree:按对象占用内存排序,关注前几名
- Histogram → Merge Shortest Paths to GC Roots:看某个类到 GC Roots 的引用链
- 结合代码定位:看引用链上哪些是"不该留着的大对象"
- 如果是 Metaspace OOM:检查是否有框架在动态生成类(CGLIB 代理过多、Groovy 脚本缓存)
- 如果是 Direct Memory OOM:检查 NIO 使用是否正确关闭 Buffer
工具链:
jps/jstat/jmap— JDK 自带,生产可用- MAT — 离线分析 Heap Dump
- Arthas — 在线诊断,支持
heapdump、vmtool、memory等命令 - GCViewer — 离线分析 GC 日志
Q8:一个对象从 new 到被回收经历了什么?
new Object()
│
▼
┌──────────────────┐
│ 1. 在 Eden 分配 │ ← TLAB(线程本地分配缓冲)优先,减少锁竞争
└──────┬───────────┘
│ Eden 满了,触发 Minor GC
▼
┌──────────────────┐
│ 2. 进入 S0/S1 │ ← 年龄 +1,在 Survivor 区来回倒腾
└──────┬───────────┘
│ 年龄达到阈值(默认 15,由 -XX:MaxTenuringThreshold 控制)
▼
┌──────────────────┐
│ 3. 晋升到老年代 │ ← "熬够了",进入老年代
└──────┬───────────┘
│ 动态年龄判断:如果 Survivor 中同龄对象总大小超过 Survivor 的一半
│ 这个年龄及以上的对象直接晋升(不等到 15 岁)
▼
┌──────────────────┐
│ 4. Mixed GC 回收 │ ← G1 在 Mixed GC 中回收部分老年代
└──────┬───────────┘
│ 或者老年代满了 → Full GC
▼
┌──────────────────┐
│ 5. 内存被回收 │ ← 不可达 → finalize()(已废弃)→ 被回收
└──────────────────┘
几个容易被追问的细节:
- TLAB:每个线程在 Eden 区有一小块私有区域,分配对象时不用加锁,提升分配效率
- 动态年龄判断:不是死等 15 岁。如果 Survivor 中某个年龄的对象总大小已经超过 Survivor 空间的一半,大于等于这个年龄的对象直接晋升
- 大对象:G1 中超过 Region 一半大小的对象视为 Humongous,直接在 Old 区连续 Region 分配,不经过 Young → Old 的晋升流程
- finalize() 已从 JDK 18 标记废弃,不要依赖它做资源释放,用 try-with-resources 或 Cleaner
总结
GC 调优的本质不是"调参数",而是理解你的对象:
- 你的应用每秒分配多少对象?(看 GC 频率)
- 这些对象活多久?(看晋升速率)
- 什么对象占内存最多?(看 Heap Dump)
- 你能接受多长的 GC 暂停?(看业务需求)
回答了这四个问题,GC 调优的方向就自然浮现了。
最后一条建议:升级 JDK 版本是最好的 GC 优化。从 JDK 8 升到 JDK 17,从 CMS/Parallel 切换到 G1,然后再考虑升到 JDK 21 的 ZGC——比你调三个月的参数效果还好。

浙公网安备 33010602011771号