JVM 内存模型与 GC 日志怎么看

线上服务突然变慢,接口 P99 从 50ms 飙到几秒,CPU 却不高。老手第一反应是:看看是不是 GC 出问题了。但很多人打开 GC 日志一看,满屏的 [GC (Allocation Failure)]Pause Young、一堆数字,完全不知道哪个是关键。

这篇不堆理论,目标很明确:讲清楚 JVM 运行时内存分哪几块、对象在里面怎么流动,然后手把手教你读一段真实的 GC 日志,知道该盯哪几个数字判断健康与否。

JVM 运行时内存分区

先建立一张地图。JVM 把内存分成几块,和 GC 最相关的是堆(Heap):

  • 堆(Heap):对象实例都分配在这里,是 GC 的主战场。堆又分成:
    • 新生代(Young):新创建的对象先进这里。内部再分 Eden 和两个 Survivor(S0/S1)。绝大多数对象"朝生夕死",在新生代就被回收了。
    • 老年代(Old):在新生代熬过多次 GC 还活着的对象,会被"晋升"到老年代。长期存活的对象(如缓存、单例)住这里。
  • 元空间(Metaspace):存类的元信息(JDK 8 后从永久代 PermGen 挪到了本地内存)。
  • 虚拟机栈 / 本地方法栈 / 程序计数器:线程私有,存方法调用的局部变量、操作数等,和 GC 关系不大。

对象的一生大致是:在 Eden 出生 → Young GC 时把存活对象挪到 Survivor → 熬过若干次 Young GC → 晋升到 Old → Old 满了触发 Full GC

两种 GC:Young GC 和 Full GC

理解日志前,先分清两类回收:

  • Young GC(Minor GC):只回收新生代。触发频繁(几秒到几十秒一次很正常),但速度快,停顿通常在毫秒级。这是健康的。
  • Full GC:回收整个堆(含老年代)甚至 Metaspace。停顿长(可能几百毫秒到数秒),而且会 Stop The World——所有业务线程暂停。Full GC 频繁,才是要命的信号。

排查性能问题时,第一件事就是看 Full GC 的频率和耗时。

打开 GC 日志

JDK 9+ 用统一的 -Xlog 参数(JDK 8 的写法在文末补充):

# JDK 9 及以后
java -Xlog:gc*:file=gc.log:time,uptime,level,tags \
     -Xms512m -Xmx512m -jar app.jar
# JDK 8
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps \
     -Xloggc:gc.log -jar app.jar

-Xms-Xmx 建议设成相等,避免堆动态扩缩容带来的额外停顿和干扰,也让日志更好读。

逐行读一段真实日志(G1 GC,JDK 11)

下面是 G1 收集器的一段典型输出,我们逐段拆:

[2026-07-09T10:15:32.101+0800][3.204s] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
[2026-07-09T10:15:32.101+0800][3.204s] GC(12)   Eden regions: 24->0(24)
[2026-07-09T10:15:32.101+0800][3.204s] GC(12)   Survivor regions: 3->3(4)
[2026-07-09T10:15:32.101+0800][3.204s] GC(12)   Old regions: 10->12
[2026-07-09T10:15:32.101+0800][3.204s] GC(12)   Humongous regions: 0->0
[2026-07-09T10:15:32.102+0800][3.205s] GC(12) Pause Young (Normal) 195M->98M(512M) 8.451ms

关键信息这样读:

  • GC(12):第 12 次 GC 事件,编号方便你追踪同一次 GC 的多行输出。
  • Pause Young (Normal):这是一次普通的新生代回收——好现象。如果看到 Pause Full,才要警惕。
  • Eden regions: 24->0(24):Eden 从占用 24 个 region 清成 0,说明新对象都被处理了(存活的挪走、死的回收)。
  • Old regions: 10->12:老年代从 10 涨到 12 个 region,说明有 2 个 region 的对象晋升到了老年代。持续快速上涨要留意,是 Full GC 的前兆。
  • 195M->98M(512M):这是最该盯的一行。GC 前堆用了 195M,GC 后降到 98M,堆总大小 512M。回收掉了约 97M。
  • 8.451ms:本次停顿 8.4 毫秒。Young GC 在毫秒级是正常的。

判断健康的几个信号

把上面的数字串起来,健康的 GC 应该满足:

  1. GC 后堆占用能明显回落。像 195M->98M,回收后降回低位,说明内存能有效释放。如果是 480M->470M——回收了个寂寞,内存基本没降,老年代快满了,离 Full GC 不远。
  2. 老年代增长平缓Old regions 每次 Young GC 只涨一点点是正常的;如果每次都猛涨,说明对象晋升太快,可能是新生代太小或有大对象。
  3. Full GC 稀少。理想情况几小时甚至几天一次。如果日志里 Pause Full 几分钟就来一次,几乎可以断定有内存问题。
  4. 单次停顿时间可控。Young GC 毫秒级、Full GC 别超过几百毫秒。

一个典型的内存泄漏信号长这样——每次 Full GC 后堆占用不降反升:

Pause Full ... 500M->480M(512M) 820ms   # 回收后还剩 480M
Pause Full ... 505M->495M(512M) 910ms   # 越回收剩得越多,停顿越长
Pause Full ... 510M->505M(512M) 1050ms  # 马上就要 OOM 了

这种情况别再纠结 GC 参数,直接上 jmap -dump:format=b,file=heap.bin <pid> 导出堆快照,用 MAT(Memory Analyzer)分析是谁在占内存不放。

快速上手的排查命令

不想翻日志时,几个命令能快速看现状:

# 实时看各代内存和 GC 次数/耗时,每 1 秒刷一次
jstat -gcutil <pid> 1000
# 输出里 YGC/YGCT 是 Young GC 次数/总耗时,FGC/FGCT 是 Full GC 次数/总耗时
# 重点看 FGC 是不是在快速增长,O(老年代使用率%)是不是一直贴近 100

# 看堆里对象数量和大小排名,快速定位可疑的大户
jmap -histo:live <pid> | head -20

jstat -gcutil 里如果 FGC 每隔几秒就 +1、O 长期在 95% 以上,基本实锤是老年代回收不动了。

小结

  • 堆分新生代(Eden + 2 Survivor)老年代;对象在 Eden 出生,熬过多次 Young GC 后晋升老年代。
  • Young GC 频繁但快是正常的;Full GC 频繁才是危险信号,它会 Stop The World。
  • 读日志最该盯 GC前->GC后(总量) 这个数:回收后能明显回落 = 健康,回收后基本不降 = 快满/泄漏
  • 老年代持续快速上涨、Full GC 后内存不减反增,是内存泄漏的典型特征,用 jmap dump + MAT 定位元凶。
  • 一句话记忆:GC 日志只看三样——Full GC 频不频、回收后堆降不降、单次停顿长不长。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐