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

堆还有空间,服务却停了

OutOfMemoryError 不会杀死进程

在 TT Lab 中继续学习

一句话总结

发生 OutOfMemoryError 时,需要的是那一刻的堆转储和能确保进程被终止的配置。-XX:+HeapDumpOnOutOfMemoryError 负责前者,-XX:+ExitOnOutOfMemoryError 负责后者。泄漏的元凶,要从类直方图的第一行和转储的支配树中去找。

为什么需要它

曾经有一个名为“缓存”的 static HashMap。只放入,从不清理。一天之后堆满了,Full GC 连续不断地运行,最后出现 java.lang.OutOfMemoryError: Java heap space。然而进程并没有死——异常只是在一个线程中发生,其他线程在几乎没有剩余的堆上反复 GC,处于无法响应的状态,硬撑了好几个小时。健康检查通过了(那个请求几乎不使用内存)。负载均衡器没有摘掉这个实例。重启之后恢复了,由于没有转储,原因就成了“下次再发生时再看”。

工作原理

java 命令文档把 -XX:+HeapDumpOnOutOfMemoryError 描述为“抛出 OutOfMemoryError 时,以 HPROF 格式把堆转储到当前目录,默认关闭”,并写明用 -XX:HeapDumpPath=<경로>(占位符为路径)来指定文件的位置和名称,默认名称为 java_pid<pid>.hprof。在生产环境中要始终开启这两项。转储只会在异常发生的那一刻生成,所以不开启的话,事件之后什么都不会留下。文件大小与堆的大小相近,所以要提前预留磁盘空间(在 -Xmx64m 实验中大约是 50MB,实测)。

进程不死的问题由 -XX:+ExitOnOutOfMemoryError 解决。这个选项没有收录在 21 的 java 命令文档中,但 HotSpot 是接受的,实测在第一次 OutOfMemoryError 时会打印 Terminating due to java.lang.OutOfMemoryError: Java heap space,并以退出码 3 结束。如果是 Kubernetes,容器会死掉并重启,“半死不活地硬撑”的状态就消失了。这个选项的意义,就是把健康检查抓不到的状态转变为进程终止。

对于正在运行的进程,使用 jcmd 的两个命令。GC.class_histogram 按类给出堆使用统计(影响:高——与堆大小成正比)。输出是排名、实例数、字节数和类名,泄漏几乎总是在第一行——[B(byte 数组)或 java.util.HashMap$Node 有几百万个,接下来的问题就是谁在持有它们。GC.heap_dump <파일>(占位符为文件名)会生成 HPROF 转储,文档写道,不指定 -all 时会先请求一次 Full GC——所以转储中只留下可达的对象,这就是“被持有的东西”的定义。HPROF 文件以 JAVA PROFILE 1.0.2 开头(实测)。分析工具(Eclipse MAT 等)会从这个文件计算出支配树(dominator tree)——释放某个对象能释放多少,如果在它的顶端有通过 static 字段连着的集合,那就是元凶。

即使做同样的分配,只要放开引用就不是泄漏。实验中的 Leak 用 -Dleak.retain=false 创建同样的数组,但不放进映射——分配量相同,在 -Xmx64m 下也能一直运行到结束。泄漏的定义不是“创建得多”,而是“不放手”。

在容器内,堆的默认值来自 cgroup 限制。java -XX:+PrintFlagsFinal -version 会打印实际的 MaxHeapSize,改变 -XX:MaxRAMPercentage(默认 25%),该值就会随之改变。在内存限制为 2Gi 的 Pod 中,默认最大堆是 512MB,调到 50% 则是 1GiB。如果把堆设得过于贴近限制,就会因堆外内存(元空间、线程栈、直接缓冲区)而不是 OutOfMemoryError,以 OOMKilled 的方式死掉——这是两种不同的事件,而且也不会留下转储。

在现场相遇的样子

事件之后没有转储,是最常见的情况——没有开启选项,或者开启了却没有磁盘,或者容器重启时文件消失了。转储路径必须是能保留下来的卷。第二种是没有 ExitOnOutOfMemoryError,放任“不死的僵尸”存在几个小时。第三种是看了直方图的第一行就以“byte 数组有问题”收场。[B 永远是第一名。问题在于谁在持有它,答案在转储的支配树中。最后,把启动选项放在人的记忆里。选项要写进启动脚本,并把这个脚本放在仓库里。

下一项实验要做什么

用 -Xmx64m 运行 Leak.java 制造 OutOfMemoryError,通过 HeapDumpOnOutOfMemoryError 获得转储,在运行中的进程上用 jcmd 导出直方图和转储,读懂直方图的第一行,用 retain=false 确认同样的分配并不是泄漏,抓住 ExitOnOutOfMemoryError 的退出码 3,用 MaxRAMPercentage 测量容器堆的默认值,最后编写一个包含所有这些选项的启动脚本并真正运行。