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/YGCTFGC/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 线程数超过系统限制

标准排查流程

  1. 看日志:确认 OOM 类型和发生时间点
  2. 看 GC 日志:OOM 前有没有 Full GC 频繁?回收后还剩下多少?
  3. 看堆 Dump:MAT 分析 Dominator Tree,找"大头"
  4. 看监控:内存增长曲线——是突然飙升(某个大请求)还是缓慢爬坡(泄漏)?
  5. 看代码:结合 Dump 中的对象引用链定位代码

五、调优流程总结

GC 调优不是玄学,是工程——按照科学的闭环来:

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│ 1.设目标  │ → │ 2.收集据  │ → │ 3.找瓶颈  │ → │ 4.调参数  │ → │ 5.验证    │
│          │    │          │    │          │    │          │    │          │
│ 延迟?    │    │ GC日志   │    │ 日志解读  │    │ 逐一调整  │    │ 压测对比  │
│ 吞吐?    │    │ 系统监控  │    │ 指标对比  │    │ 观察效果  │    │ 上线观察  │
│ 内存?    │    │          │    │          │    │          │    │          │
└──────────┘    └──────────┘    └──────────┘    └──────────┘    └──────────┘

核心原则

  1. 先量化,再优化:没有目标就没有终点。典型目标:

    • 接口 P99 < 100ms,GC 暂停 < 50ms → 目标:Young GC < 50ms,Full GC = 0
    • 夜间批处理任务,吞吐量优先 → 可以容忍偶尔 1s 的 GC 暂停
  2. 一次只调一个参数:同时改 3 个参数看到效果好 → 完全不知道是哪个起的作用 → 下次出问题还是懵。

  3. 调整参数不如改代码:如果 heap dump 显示 80% 内存被一个巨大的本地缓存占用,调什么 GC 参数都没用——加个 LRU 淘汰策略才是正解。

  4. 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 的常见原因

  1. System.gc() 显式调用(生产环境建议 -XX:+DisableExplicitGC
  2. 老年代空间不足(CMS/G1 并发回收失败时)
  3. Metaspace 不足
  4. 晋升失败(Promotion Failed):Young GC 后 Survivor 放不下,老年代也放不下
  5. 并发模式失败(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)

取代的根本原因

  1. 碎片问题无解:CMS 基于标记-清除,碎片化严重时被迫退化成 Serial Old 做压缩,暂停时间不可预测
  2. 暂停不可控:CMS 的"并发"并不意味着没有长暂停,remark 阶段和碎片整理都可能造成秒级停顿
  3. 不能充分利用大内存:CMS 在大堆下表现急剧恶化
  4. 官方废弃: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 怎么排查?

标准流程:

  1. 确认 OOM 类型:看错误日志是 Heap / Metaspace / Direct Memory / Thread
  2. 拉 Heap Dump-XX:+HeapDumpOnOutOfMemoryError 自动 dump,或用 jmap -dump:live,format=b,file=heap.hprof <pid>
  3. MAT 分析三步走
    • Overview → Leak Suspects:自动分析疑似泄漏点
    • Dominator Tree:按对象占用内存排序,关注前几名
    • Histogram → Merge Shortest Paths to GC Roots:看某个类到 GC Roots 的引用链
  4. 结合代码定位:看引用链上哪些是"不该留着的大对象"
  5. 如果是 Metaspace OOM:检查是否有框架在动态生成类(CGLIB 代理过多、Groovy 脚本缓存)
  6. 如果是 Direct Memory OOM:检查 NIO 使用是否正确关闭 Buffer

工具链

  • jps / jstat / jmap — JDK 自带,生产可用
  • MAT — 离线分析 Heap Dump
  • Arthas — 在线诊断,支持 heapdumpvmtoolmemory 等命令
  • 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 调优的本质不是"调参数",而是理解你的对象

  1. 你的应用每秒分配多少对象?(看 GC 频率)
  2. 这些对象活多久?(看晋升速率)
  3. 什么对象占内存最多?(看 Heap Dump)
  4. 你能接受多长的 GC 暂停?(看业务需求)

回答了这四个问题,GC 调优的方向就自然浮现了。

最后一条建议:升级 JDK 版本是最好的 GC 优化。从 JDK 8 升到 JDK 17,从 CMS/Parallel 切换到 G1,然后再考虑升到 JDK 21 的 ZGC——比你调三个月的参数效果还好。

posted @ 2026-06-02 11:24  松鼠航  阅读(0)  评论(0)    收藏  举报