引言
当内存泄漏达到一定程度,就会导致 OOM,这是常见的导致 OOM 的原因之一。除此之外,还有哪些常见的 OOM 原因呢?线上真出现了 OOM 问题,又该如何快速止血和解决呢?
本文将从监控报警、JVM 参数配置、问题排查思路等角度,系统梳理内存问题的应对策略
事前
如何感知线上出现了 OOM 或内存泄漏?
监控与报警
需要监控 JVM 以下指标:
GC(垃圾收集)瞬时和累计详情
- FullGC次数
- YoungGC次数
- FullGC耗时
- YoungGC耗时
堆内存详情
- 堆内存总和
- 堆内存老年代字节数
- 堆内存年轻代Survivor区字节数
- 堆内存年轻代Eden区字节数
- 元空间
- 元空间字节数
非堆内存
- 非堆内存最大字节数
- 非堆内存使用字节数
直接缓冲区
- DirectBuffer总大小(字节)
- DirectBuffer使用大小(字节)
JVM线程数
- 线程总数量
- 死锁线程数量
- 新建线程数量
- 阻塞线程数量
- 可运行线程数量
- 终结线程数量
- 限时等待线程数量
- 等待中线程数量
并支持分钟级别查看数据,当达到阈值时,立即报警,及时解决
时间维度记录数据的意义
通过查看图像,发现 JVM 随时间的推移,堆内存的利用率越来越高,虽然期间有多次 GC 导致堆内存短暂降低,但之后又很快来到更高的位置。这种情况,大概率就是出现了「内存泄漏」,即持引用,但不释放,GC 无法回收
同时可以作为参考依据,对 JVM 进行调优
JVM 启动参数
Heap Dump
添加参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heapdump
出现 OOM 时,自动生成 Heap Dump,并指定 dump 文件路径。这样出现了 OOM,我们才有办法以内存快照的方式,对内存分析
-Xmx 和 -Xms 设为相同值
JVM 会从 Xms(初始堆内存)开始,在运行时逐步申请内存,直到 Xmx(最大堆内存),虽然可以达到按需使用,减少内存浪费。但扩容内存时,可能会触发 Full GC,导致性能抖动
所以需要固定堆大小,即 -Xms=-Xmx
,从而:
- 避免运行时内存调整的开销
- 减少因堆大小变化导致的 GC 停顿时间
一般堆内存大小设置为物理内存的一半
限制最大直接内存(堆外内存)
设置 JVM 中直接内存(也称为“堆外内存”)的最大值。直接内存通常用于 NIO 操作、缓存或大数据处理场景,在 Netty 中被广泛使用
默认情况下,JVM 的 MaxDirectMemorySize == Xmx
,假设现在「最大堆内存」为机器物理内存的一半以上,很可能会导致 OOM(堆内存 + 堆外内存 > 物理内存)。所以我们需要指定「最大直接内存」的大小
可以根据监控,拿到该项目「直接内存占用」的历史数据,决定要设置多大。一般设置为堆内存的 1/4 ~ 1/2
限制元空间大小
元空间主要存储的内容 JIT Code Cache、类的元数据信息、方法定义等,并且位于「直接内存」,不受「堆」管理。JDK8 及以后的版本,默认最大内存空间都是无限制的,很明显不受约束的东西容易带来问题
假设某个 JSON 库有个 bug,会通过代理类无限制的创建 Class 对象,元空间将侵蚀所有物理内存空间,导致 OOM。虽然本质是 JSON 库 bug 导致的,当也因为元空间大小不受限,导致问题进一步扩散
所以可以这样设置:
-XX:MetaspaceSize=256M // 初始分配的元空间大小
-XX:MaxMetaspaceSize=512M // 元空间的最大值
一般设置这样大小足够了。具体设置多少还得看业务,可以通过监控数据,查看一般会占用多大内存,进一步调整(已经反复强调监控的必要性了)
小结
假设是 8g 的机器,使用 JDK17
java \
-Xms4g \
-Xmx4g \
-XX:MaxDirectMemorySize=1g \
-XX:+UseG1GC \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/path/to/dumps/heapdump.hprof \
-jar your-app.jar
变更回滚
很多情况的各种线上问题,都是因为代码变更导致的
对于每次变更,都尽可能保证:可观测、可灰度、可回滚。最好每次上线前,都准备好 SOP 预案
这样,一旦是因为新变更导致的线上问题,我们通过回滚,快速止血
事中
当线上事故发生,最好的方式就是「扩容+重启」。如果发现是因为代码变更导致的,就再加个「回滚」操作。达到快速止血。但也不要一次性全部都重启了,最好也是采用灰度的方式,逐步重启 JVM,并注意预热问题
先保证服务正常运行,再来分析为什么会 OOM
事后
重启只是缓兵之计。出现 OOM 后,要立即去排查 OOM 的原因,避免接下来 OOM 的发生。从以下角度排查:
- 判断是「堆内溢出」还是「堆外溢出」
- 最近有什么“变量”。比如新的促销活动、更新依赖版本、新的代码变动、修改了配置信息等等
堆内溢出
具体表现:堆内存占用率几乎达到参数设置的最大堆内存上限
堆内存申请的内存太大
机器的物理内存不够分配。linux 的 OOM Killer 将直接杀死 JVM 进程,甚至给 JVM 导出 dump 和 GC 日志的时间都没有,也就是 dump 会丢失。表现为"突然死亡"而非优雅的 OOM 错误
解决方案
堆内存最大值不要设置太大,一般设置为机器物理内存的一半。通过合理配置就可以解决
OOM Killer
OOM Killer 是 Linux 内核的一个机制,属于操作系统层面,并不是 JVM 的机制。当 os 出现内存不足(物理内存耗尽、swap 也耗尽),就会触发 OOM Killer,通过一定标准(OOM 分数、优先级、内存占用情况),将进程直接强制 kill。可以通过以下命令查看 OOM Killer 的工作情况:
sudo dmesg | grep -i "killed process"
通过查看机器物理内存利用率,比如通过 Prometheus+Grafana 或 free 命令,来判断是否是因为物理内存不足导致触发 OOM Killer
热门活动上线
请求量增加,对象创建自然越来越多
解决方案
正常的流量增长,加机器就行
内存泄漏/大对象/大量字符串
比较典型的就是一次性从数据库 SELECT 大量数据、日志 JSON.toJSONString 打印对象、集合存放大量对象
解决方案
通过 JVM 堆内存分析工具,如 MAT,对 dump 文件进行分析。也可以在开发/预发环境中,打印详细 GC 日志 -Xlog:gc*=info
,对 GC 情况进行分析
一般关注 char[] byte[],因为往往是大量 JSON 串导致的
常见内存泄漏
- 线程池中使用 ThreadLocal,但使用完没有 remove
- static 变量/集合,持有引用但不释放
- 申请堆外内存,使用了魔法类 Unsafe
- 使用 I/O 流访问文件或链接,没有及时释放
堆外溢出
具体表现:非堆内存占用率极高
堆外的内存不好排查,因为这部分不归 GC 管。这里推荐几篇堆外内存泄漏分析的文章:
- netty: https://tech.meituan.com/2018/10/18/netty-direct-memory-scree...
- glibc: https://developer.aliyun.com/article/1304510
- fastjson: https://zhuanlan.zhihu.com/p/432258798
不难看出,很多时候我们堆外溢出都是因为「第三方组件」导致的。一般堆外溢出在短期内对项目影响不大,但排查起来却相当复杂,所以可以采用以下方式尝试解决:
- 任何情况下,不要在业务中直接使用 Unsafe 类
- 升级 JDK 版本、升级 netty 版本。
将 fastjson 改成 gson 或者 jackson - 对于还在活跃的项目,推送代码重新部署进程,泄漏的内存自然就被回收了;对于不维护的老项目,可以写个
定时任务重启进程
结语
做个总结吧:
- 事前:监控报警、设置启动参数、可回滚
- 事中:扩容+回滚+重启
- 事后:使用 MAT 分析 dump 找大对象和内存泄漏,升级 JDK、netty、fastjson 依赖版本,定时重启进程
如果文章对你有帮助,欢迎点赞+收藏+关注,有问题欢迎在评论区评论哦!
公众号【牛肉烧烤屋】
参考资料
https://help.aliyun.com/zh/arms/application-monitoring/user-g...
封面:_姜歬
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。