TT Lab
开始
学习 学习路径 课程

堆还有空间,服务却停了

GC 停顿不在堆使用率图上

在 TT Lab 中继续学习

一句话总结

JVM 堆把短命对象(Young)和长寿对象(Old)分开管理,而 GC 日志(-Xlog:gc)会逐行记录这些回收在何时、让应用停顿了多久。服务变慢时,最先要打开的就是这份日志。

为什么需要它

“堆还有剩余,服务却停了”这个课程标题,实际上指的是两类事件。其一是本模块的事件——堆使用量曲线是 60%,响应时间却突然一次次飙升到 3 秒。原因是 GC 停顿(pause)。需要回收的东西变多时,JVM 会让所有应用线程停下来(stop-the-world)再清理。堆还有剩余,和清理速度快,是两回事。其二是下一个模块的事件——堆和 GC 都好好的,线程却全被堵住了。区分二者的第一件工具就是 GC 日志。日志中没有长时间的停顿,凶手就不是 GC。

本课程要讲 JVM,是因为韩国市场的需求。在 2026-09-11 的招聘公告统计中,Java 占全部 160 条(16.7%),而在韩国 6 家公司中为 108 条(38.0%),是海外(8.3%)的四倍多。当公告写“JVM 调优经验”“故障分析”时,它指的内容就是从这个模块开始的。

工作原理

堆大小通过 java 命令文档中的 -Xms(初始/最小)和 -Xmx(最大)来设定。文档写道,在服务器部署中把二者设为相同的值很常见——目的是避免启动之后堆不断增大所带来的 GC。如果不指定值,就会根据系统配置在运行时决定,其规则就是 -XX:MaxRAMPercentage(默认 25%)和 -XX:InitialRAMPercentage(默认 1.5625%)。也就是说,在 8GB 内存的机器上什么都不写,最大堆就是 2GB。在容器中,这个“机器内存”会按 cgroup 限制来读取(在第 4 个模块中亲自测量)。

收集器由可用的收集器文档分成三类来说明。Serial 用一个线程完成所有 GC,没有线程间通信开销,适合小数据量(文档写的是大约 100MB 以内),用 -XX:+UseSerialGC 开启。Parallel 是吞吐量(throughput)收集器,由多个线程分担 GC。G1 是把大部分工作与应用程序同时进行(mostly concurrent)的收集器,在高概率满足停顿时间目标的同时也保持吞吐量,是服务器级机器上的默认收集器。哪个更好没有标准答案,用相同的负载运行并测量停顿时间才是答案——实验中就是这样做的。

日志用统一日志 -Xlog 开启。文档的对照表写明,旧的 PrintGCDetails 对应 -Xlog:gc*。-Xlog:gc:file=gc.log 会逐行记录 GC 事件,-Xlog:gc*:file=gc.log 则会把各阶段的细节也记录到文件中。实际输出如下(JDK 21,实测)。

[0.002s][info][gc] Using G1
[0.029s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 16M->4M(32M) 1.683ms
[0.030s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 19M->4M(32M) 1.005ms

读法——方括号是 JVM 启动之后经过的时间,GC(0) 是 GC 编号,Pause Young 是年轻代停顿,16M->4M(32M) 是回收前 → 回收后(整个堆),最后是停顿时间。开启 -Xlog:gc* 后,开头会多出 Heap Max Capacity: 64M 之类的初始化信息,一次停顿会被拆成 [gc,start] 和 [gc] 两行。Full GC 会打印为 Pause Full——这个经常出现,就说明老年代一直在被填满,不是泄漏就是堆太小。

对于正在运行的进程,用 jstat 来查看。jstat -gc <pid> 1000 5 会以 1 秒的间隔打印 5 次堆统计。列名由文档规定——S0C/S1C 是 Survivor 区容量,EC/EU 是 Eden 区容量/使用量,OC/OU 是老年代容量/使用量,YGC/YGCT 是 Young GC 的次数/时间,FGC/FGCT 是 Full GC 的次数/时间,GCT 是总 GC 时间。YGC 每秒增加几十,说明分配过多;FGC 在增加,说明老年代正在被填满。

jcmd 会向同一台机器上以相同用户启动的 JVM 发送诊断命令。jcmd -l 返回 JVM 列表,jcmd <pid> GC.heap_info 返回堆摘要,jcmd <pid> VM.flags 返回实际生效的标志(包括 -XX:MaxHeapSize=)。当“配置文件里写的是 4g”与“JVM 实际使用的值”不一致时,由 VM.flags 来裁决。

在现场相遇的样子

最常见的是根本没有开启日志的服务。故障发生之后再开启是来不及的。第二种是把 -Xmx 设成机器的全部内存——JVM 在堆之外还会使用元空间、线程栈、代码缓存和直接缓冲区,所以堆一旦触及机器内存,操作系统就会杀掉进程。第三种是用平均值来看停顿时间。平均 5ms 的背后,可能每天藏着三次 800ms 的 Full GC。必须看最大值和分布。这就是实验中要从日志里提取最大停顿的原因。

下一项实验要做什么

编译 /opt/lab/fixtures/jvm/Churn.java,用 -Xlog:gc 生成日志,在 -Xlog:gc* 详细日志中统计 Young 停顿的次数,比较 -Xmx64m 与 -Xmx256m 的停顿次数,提取最大停顿时间,用 jstat -gc 观察运行中的进程,在相同负载下比较 Serial 与 G1,并用 jcmd 获取堆信息和实际生效的标志。