关于 JVM 应用的 OOM,首先可以阅读 Oracle 的文档:3.2 Understand the OutOfMemoryError Exception,根据我的经验,大部分都是开发人员的代码问题导致,而关于出现了 OOM 是否应该及时终止应用在 Oracle 的文档中好像也没有明确的说明,但是在 Stack Overflow 上有不少讨论,如:Can the JVM recover from an OutOfMemoryError without a restart,最高票的回答表明 OOM 发生后不是一定要及时终止应用,但是建议终止应用,因为 OOM 后应用可能存在不一致的状态。
从我的经验来看,立即触发的 OOM 异常对线上服务的影响并不大,因为不会阻塞线程,对我们线上服务有影响的主要是 java.lang.OutOfMemoryError: GC Overhead limit exceeded
,关于此异常的描述如下:
After a garbage collection, if the Java process is spending more than approximately 98% of its time doing garbage collection and if it is recovering less than 2% of the heap and has been doing so far the last 5 (compile time constant) consecutive garbage collections, then a java.lang.OutOfMemoryError is thrown.
OOM 触发前长时间的 FullGC 导致了应用无响应,从而导致下游请求超时,比如我们线上曾有服务使用 Ehcache 作为应用本地缓存,而未配置本地缓存的内存占用上限,在上线一段时间后,服务先是出现 CPU 升高,在持续很久后触发了 OOM,最后经过堆转储分析,定位原因为 Ehcache 底层用于缓存的 map 几乎占用了所有内存,而业务上的原因为缓存的键设置不合理,导致了缓存了大量利用率低的数据,从而最终触发了 java.lang.OutOfMemoryError: GC Overhead limit exceeded
。
在我们的线上 JVM 参数配置中,一直包含有这两个参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/shared/heapdump.hprof
,用于 OOM 时发起堆转储,且在此基础上我们还集成了用于监控的 Java Agent,会在 OOM 触发的堆转储文件生成后发送告警至监控系统通知开发人员分析 OOM 的原因并进行修复,其实在一段时间内,我为了保证应用的正确性还曾配置过参数 -XX:+ExitOnOutOfMemoryError
上线,但是随后不久即发现问题,即使我们的 docker container 会在 failure 时自动重启,当业务代码存在导致 OOM 的 bug 时,极有可能将线上该组业务的所有实例全部触发 OOM,然后所有实例都在重启,而重启是需要时间的,那么在这段时间内,服务就不可用了,这在互联网的业务中是不可接受的,比如我遇到的一个 tomcat 的 bug: part.getString will cause OOM without checking maxPostSize, checking maxPostSize first will avoid OOM caused by huge string,该 bug 在客户端 POST 了一个巨大的字符串时会导致服务端触发 OOM,这还不是应用开发人员的 bug,而是 tomcat 中存在的,当客户端请求足够多时,就会使整组业务服务器全部 OOM,而如果配置了 -XX:+ExitOnOutOfMemoryError
,该组应用在重启期间就不可用了,且如果客户端依然在请求的话,后端 JVM 会陷入 OOM -> 应用终止 -> 应用重启 的循环,所以后期我们又移除掉了 -XX:+ExitOnOutOfMemoryError
配置,回到开始的配置,出现 OOM 后仅发起堆转储,并通知至监控系统,由开发人员定位原因并修复后重新发布应用,在修复问题之前,虽然应用会触发 OOM,但是应用是部分可用的,等于我们在正确性与可用性之间做了 trade off,选择牺牲正确性来保证服务的可用性。
除了 JVM 的 OOM 问题,我们在部分 Linux 机器上还发现因物理内存不足而导致的 Linux oom-killer 将 JVM 进程 kill 掉然后 docker container 重启的问题,该问题起初由定时任务没有被执行的现象暴露出来,随后定位原因为 oom-killer 将 JVM 进程 kill 掉导致,以下是一段 Linux oom-killer kill 掉 JVM 进程的日志:
1 | Jul 02 15:00:57 iZm5ea5suhdnpk1633jyhkZ kernel: aliyun-service invoked oom-killer: gfp_mask=0x14200ca(GFP_HIGHUSER_MOVABLE), nodemask=(null), order=0, oom_score_adj=0 |
关于 Linux oom-killer 的介绍可以参考文章:Linux Out-Of-Memory Killer,大致是说 Linux 内核会在配置启用了 oom-killer 机器上当内存不足时选择一个最坏的进程进行 kill 以释放内存空间,在选择需要被 kill 的进程时遵循一些规则,如:
- 内核需要保证自身运行所需的内存
- 尝试回收大量的内存
- 不要 kill 掉使用少量内存的进程
- 尽可能少的 kill 进程
- 一些细粒度的算法将会提高用户想要杀死的进程的牺牲优先级
根据以上的规则,将计算出各个进程的 oom_score,然后使用该 oom_score 乘以进程占用的内存,最后具有较大值的进程将极有可能会被 oom-killer 终止。
Reference
Understand the OutOfMemoryError Exception
8u92 Update Release Notes
OUTOFMEMORYERROR RELATED JVM ARGUMENTS – HeapHero – Java & Android Heap Dump Analyzer
Chapter 13 Out Of Memory Management
How to Configure the Linux Out-of-Memory Killer
Does JVM terminate itself after OutOfMemoryError - Stack Overflow
HTTPCLIENT-2039 Do not close ConnectionManager in case of Errors - ASF JIRA
OutOfMemoryError