4. 启动过程剖析

GC 日志这东西,说实话,很多同学一开始都觉得头疼。满屏的英文缩写、数字、时间戳,看着就像天书。但我可以负责任地告诉你——GC 日志是 JVM 性能调优的「黑匣子」。飞机失事要找黑匣子,JVM 出问题,第一件事就是翻 GC 日志。

我个人习惯是,线上环境必须开启 GC 日志。哪怕你暂时不看,也得开着。为什么?因为问题往往发生在你睡觉的时候,等你第二天上班,现场早就没了。日志就是你的时间机器。

启动参数怎么配?

先别急着分析,你得先把日志打开。我常用的三板斧是这样的:

-Xloggc:/path/to/gc-%t.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M

这里我解释一下每个参数的作用:

参数 作用 我的建议
-Xloggc 指定 GC 日志输出路径 用 %t 加时间戳,避免覆盖
-XX:+PrintGCDetails 输出详细的 GC 信息 必开,否则日志太简略
-XX:+PrintGCDateStamps 打印人类可读的时间戳 比相对时间好用太多
-XX:+UseGCLogFileRotation 启用日志轮转 防止日志撑爆磁盘
-XX:NumberOfGCLogFiles 保留的日志文件数 10 个够用,多了占空间
-XX:GCLogFileSize 单个日志文件大小 10M 一个,方便传输和分析
注意:JDK 9 之后,这些参数被统一成了 -Xlog:gc* 的格式。如果你用的是 JDK 11+,记得用新语法。我曾经在升级 JDK 时忘了改参数,结果日志没打出来,排查问题多花了半天。

GC 日志长什么样?

好,参数配好了,我们来抓一条真实的 GC 日志看看。这是我从一个线上服务截取的:

2024-03-15T10:32:18.456+0800: 2.345: [GC (Allocation Failure) [PSYoungGen: 51200K->8192K(59392K)] 102400K->60192K(196608K), 0.0123456 secs] [Times: user=0.02 sys=0.01, real=0.01 secs]

嗯,乍一看确实有点乱。但别怕,我们把它拆开来看,其实就几个部分:

  1. 时间戳:2024-03-15T10:32:18.456+0800 —— 这是 PrintGCDateStamps 带来的好处,精确到毫秒。
  2. JVM 启动后时间:2.345 —— 表示 JVM 启动后 2.345 秒发生了这次 GC。
  3. GC 类型和原因:GC (Allocation Failure) —— 这次是 Minor GC,原因是分配失败(说白了就是 Eden 区满了)。
  4. 年轻代信息:[PSYoungGen: 51200K->8192K(59392K)] —— 年轻代从 50MB 回收到了 8MB,总容量约 58MB。
  5. 堆整体信息:102400K->60192K(196608K) —— 整个堆从 100MB 降到了 58.8MB,总容量 192MB。
  6. 耗时:0.0123456 secs —— 12 毫秒,很快。
  7. CPU 时间:[Times: user=0.02 sys=0.01, real=0.01 secs] —— 用户态 20ms,内核态 10ms,实际耗时 10ms。

核心要点:看 GC 日志,我一般先看三个数字——回收前、回收后、总容量。回收前如果接近总容量,说明内存快满了;回收后如果还是很大,说明有对象没释放掉,可能是内存泄漏。

Full GC 日志长什么样?

Minor GC 看完了,咱们再看看 Full GC。这个才是真正让人头疼的:

2024-03-15T10:35:42.123+0800: 186.234: [Full GC (Metadata GC Threshold) [PSYoungGen: 8192K->0K(59392K)] [ParOldGen: 131072K->139264K(139264K)] 139264K->139264K(196608K), [Metaspace: 99876K->99876K(1048576K)], 0.5678901 secs] [Times: user=0.89 sys=0.12, real=0.57 secs]

这个日志比刚才那个长了不少。为什么?因为 Full GC 涉及了所有区域。我来拆解一下:

  • 触发原因:Metadata GC Threshold —— 元空间(Metaspace)达到了阈值。我在项目中遇到过好几次,都是因为动态生成类太多导致的。
  • 年轻代:PSYoungGen: 8192K->0K(59392K) —— 回收后变 0 了,说明年轻代被清空了。
  • 老年代:ParOldGen: 131072K->139264K(139264K) —— 等等,回收后反而变大了?从 128MB 涨到了 136MB?
  • 堆整体:139264K->139264K(196608K) —— 回收前后一模一样,136MB 纹丝不动。
  • 耗时:0.5678901 secs —— 568 毫秒,这已经算比较慢了。

注意这个细节:老年代回收后反而变大,说明年轻代的对象被晋升到了老年代。如果这种情况频繁发生,你的 Survivor 区可能太小了,或者晋升阈值设置不合理。我曾经在一个高并发服务上遇到过,Survivor 区只有 2MB,结果对象刚创建就被晋升到老年代,Full GC 频繁触发,服务响应时间直接飙到 5 秒以上。

如何快速定位问题?

日志读懂了,接下来就是怎么用。我总结了一个「三步排查法」:

  1. 看频率:Minor GC 多久一次?Full GC 多久一次?如果 Full GC 每小时超过 1 次,就要警惕了。
  2. 看耗时:单次 GC 耗时多少?Minor GC 超过 100ms 算慢,Full GC 超过 1s 算严重。
  3. 看趋势:老年代占用是平稳的,还是持续上涨的?持续上涨基本就是内存泄漏。

举个例子,我去年排查过一个线上问题。GC 日志显示:

2024-06-01T00:00:00.000+0800: 12345.678: [Full GC 1024M->1024M(2048M), 2.345 secs]
2024-06-01T00:05:00.000+0800: 12350.678: [Full GC 1024M->1024M(2048M), 2.456 secs]
2024-06-01T00:10:00.000+0800: 12355.678: [Full GC 1024M->1024M(2048M), 2.567 secs]

看到了吗?每 5 分钟一次 Full GC,每次耗时 2 秒多,而且回收前后堆大小完全不变。这说明什么?堆里的对象全是存活的,一个都回收不掉。这就是典型的内存泄漏。后来通过 heap dump 分析,发现是一个缓存 Map 没有设置过期策略,越积越多。

我的小技巧:如果你不想手动翻日志,可以用 grep 配合 awk 快速统计。比如:grep "Full GC" gc.log | wc -l 可以统计 Full GC 次数。更专业的可以用 GCeasy、GCViewer 这些工具,能自动生成图表。

一张图看懂 GC 日志分析流程

说了这么多,我画了一张流程图,把整个分析思路串起来:

GC 日志分析流程 获取 GC 日志 解析日志结构 判断 GC 类型 Minor GC Full GC Minor GC 分析 看频率、看耗时、看晋升 Full GC 分析 看趋势、看老年代、看元空间 得出结论 / 调优 核心原则:先看频率,再看耗时,最后看趋势。不要只看一次 GC,要看整体模式。 一次 Full GC 不可怕,可怕的是每 5 分钟来一次。

常见问题速查表

最后,我整理了一个速查表。你在分析日志时,可以直接对照:

日志现象 可能原因 我的建议
Minor GC 频繁(每秒多次) Eden 区太小 增大年轻代大小,或调整 -XX:NewRatio
Minor GC 耗时超过 100ms GC 线程竞争或对象太多 检查 -XX:ParallelGCThreads 设置
Full GC 频繁(每小时多次) 老年代增长过快 检查是否有大对象直接进入老年代
Full GC 后老年代占用不变 内存泄漏 立即 dump 堆,分析引用链
元空间 Full GC 动态类加载过多 增大 -XX:MaxMetaspaceSize,或检查类加载器泄漏
GC 日志中出现 System.gc() 代码中显式调用了 System.gc() 加上 -XX:+DisableExplicitGC 禁用

最后说一句:GC 日志分析不是一次性工作。我建议你养成习惯,每次上线新版本后,都盯一下 GC 日志看 15 分钟。很多问题在灰度阶段就能发现,等全量上线再查就晚了。