头图

引言

当内存泄漏达到一定程度,就会导致 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 管。这里推荐几篇堆外内存泄漏分析的文章:

不难看出,很多时候我们堆外溢出都是因为「第三方组件」导致的。一般堆外溢出在短期内对项目影响不大,但排查起来却相当复杂,所以可以采用以下方式尝试解决:

  • 任何情况下,不要在业务中直接使用 Unsafe 类
  • 升级 JDK 版本、升级 netty 版本。将 fastjson 改成 gson 或者 jackson
  • 对于还在活跃的项目,推送代码重新部署进程,泄漏的内存自然就被回收了;对于不维护的老项目,可以写个定时任务重启进程

结语

做个总结吧:

  • 事前:监控报警、设置启动参数、可回滚
  • 事中:扩容+回滚+重启
  • 事后:使用 MAT 分析 dump 找大对象和内存泄漏,升级 JDK、netty、fastjson 依赖版本,定时重启进程

如果文章对你有帮助,欢迎点赞+收藏+关注,有问题欢迎在评论区评论哦!

公众号【牛肉烧烤屋】

参考资料

https://help.aliyun.com/zh/arms/application-monitoring/user-g...

封面:_姜歬


牛肉烧烤屋
1 声望0 粉丝