写点什么

Java NIO 为何导致堆外内存 OOM 了?

作者:JavaEdge
  • 2022 年 2 月 01 日
  • 本文字数:2344 字

    阅读完需:约 8 分钟

Java NIO为何导致堆外内存OOM了?

Java NIO 为何导致堆外内存 OOM 了?

某天报警:某台机器部署的一个服务突然无法访问。谨记第一反应登录机器查看日志,因为服务挂掉,很可能因 OOM。这个时候在机器的日志中发现了如下的一些信息:


nio handle failed java.lang.OutOfMemoryError: Direct buffer memory at org.eclipse.jetty.io.nio.xxxx
at org.eclipse.jetty.io.nio.xxxx at org.eclipse.jetty.io.nio.xxxx
复制代码


表明确实为 OOM,问题是哪个区导致的呢?可以看到是:Direct buffer memory,还看到一大堆 jetty 相关方法调用栈,仅凭这些日志,就能分析 OOM 原因。

Direct buffer memory

堆外内存,JVM 堆内存之外的一块内存,不是由 JVM 管理,但 Java 代码却能在 JVM 堆外使用一些内存空间。这些空间就是 Direct buffer memory,即直接内存,这块内存由 os 直接管理。但称其为直接内存有些奇怪,我没更爱称其为“堆外内存”。


Jetty 作为 JVM 进程运行我们写好的系统的流程:



这次 OOM 是 Jetty 在使用堆外内存时导致。可推算得,Jetty 可能在不停使用堆外内存,然后堆外内存空间不足,没法使用更多堆外内存,就 OOM 了。


Jetty 不停使用堆外内存:


解决 OOM 的底层技术

Jetty 既然是用 Java 写的,那他是如何通过 Java 代码申请堆外内存的?然后这个堆外内存空间又如何释放呢?这涉及 Java 的 NIO 底层。


JVM 的性能优化相对还是较为容易一些的,但若是解决 OOM,除了一些弱智和简单的,如有人在代码里不停创建对象。其他很多生产的 OOM 问题,都有点技术难度,需要扎实技术。

堆外内存是如何申请的,又是如何释放的?

如在 Java 代码里要申请使用一块堆外内存空间,是使用 DirectByteBuffer 这个类,你可以通过这个类构建一个 DirectByteBuffer 的对象,这个对象本身是在 JVM 堆内存里的。


但是你在构建这个对象的同时,就会在堆外内存中划出来一块内存空间跟这个对象关联起来,我们看看下面的图,你就对他们俩的关系很清楚了。



因此在分配堆外内存时,基本就这思路。

如何释放堆外内存

当你的 DirectByteBuffer 对象无人引用,成垃圾后,就会在某次 YGC 或 Full GC 时被回收。


只要回收一个 DirectByteBuffer 对象,就会释放其关联的堆外内存:


那为何还出现堆外内存溢出?

若你创建很多 DirectByteBuffer 对象,占了大量堆外内存,然后这些 DirectByteBuffer 对象还无 GC 线程来回收,那就不会释放呀!


当堆外内存都被大量 DirectByteBuffer 对象关联使用,若你再要使用额外堆外内存,就报内存溢出!何时会出现大量 DirectByteBuffer 对象一直存活,导致大量堆外内存无法释放?


还可能是系统高并发,创建过多 DirectByteBuffer,占用大量堆外内存,此时再继续想要使用堆外内存,就会 OOM!但该系统显然不是这种情况。

真正的堆外内存溢出原因

可以用 jstat 观察线上系统运行情况,同时根据日志看看一些请求的处理耗时,分析过往 gc 日志,还看了一下系统各个接口的调用耗时后,分析思路如下。


首先看接口调用耗时,系统并发量不高,但他每个请求处理较耗时,平均每个请求需 1s。


然后 jstat 发现,随系统不停被调用,会一直创建各种对象,包括 Jetty 本身不停创建 DirectByteBuffer 对象去申请堆外内存空间,接着直到 Eden 满,就会触发 YGC:



但往往在进行 GC 的一瞬间,可能有的请求还没处理完,此时就有不少 DirectByteBuffer 对象处于存活状态,还没被回收,当然之前不少 DirectByteBuffer 对象对应的请求可能处理完毕了,他们就可以被回收了。


此时肯定会有一些 DirectByteBuffer 对象以及一些其他的对象是处于存活状态的,就需转入 Survivor 区。记得该系统上线时,内存分配极不合理,就给了年轻代一两百 M,老年代却给七八百 M,导致年轻代中的 Survivor 只有 10M。因此往往在 YGC 后,一些存活下的对象(包括了一些 DirectByteBuffer)会超过 10M,没法放入 Survivor,直接进入 Old:



于是反复的执行这样的过程,导致一些 DirectByteBuffer 对象慢慢进入 Old,Old 的 DirectByteBuffer 对象越来越多,而且这些 DirectByteBuffer 都关联很多堆外内存:



这些老年代里的 DirectByteBuffer 其实很多都是可以回收的状态了,但是因为老年代一直没塞满,所以没触发 full gc,也就自然不会回收老年代里的这些 DirectByteBuffer 了!当然老年代里这些没有被回收的 DirectByteBuffer 就一直关联占据了大量的堆外内存空间了!


直到最后,当你要继续使用堆外内存时,所有堆外内存都被老年代里大量的 DirectByteBuffer 给占用了,虽然他们可以被回收,但是无奈因为始终没有触发老年代的 full gc,所以堆外内存也始终无法被回收掉。最后导致 OOM!

这 Java NIO 怎么看起来这么沙雕?

Java NIO 没考虑过会发生这种事吗?


考虑了!他知道可能很多 DirectByteBuffer 对象也许没人用了,但因未触发 gc 就导致他们一直占据堆外内存。Java NIO 做了如下处理,每次分配新的堆外内存时,都调用 System.gc(),提醒 JVM 主动执行以下 GC,去回收掉一些垃圾没人引用的 DirectByteBuffer 对象,释放堆外内存空间。


只要能触发 GC 去回收掉一些没人引用的 DirectByteBuffer,就会释放一些堆外内存,自然就可以分配更多对象到堆外内存。但因为我们又在 JVM 设置了:


-XX:+DisableExplicitGC
复制代码


导致这 System.gc()不生效,因此导致 OOM。

终极优化

项目有如下问题:


  • 内存设置不合理,导致 DirectByteBuffer 对象一直慢慢进入老年代,堆外内存一直无法释放

  • 设置了-XX:+DisableExplicitGC,导致 Java NIO 无法主动提醒去回收掉一些垃圾 DIrectByteBuffer 对象,也导致了无法释放堆外内存


对此就该:


  • 合理分配内存,给年轻代更多内存,让 Survivor 区域有更大的空间

  • 放开-XX:+DisableExplicitGC 这个限制,让 System.gc()生效


优化后,DirectByteBuffer 一般就不会不断进入老年代了。只要他停留在年轻代,随着 young gc 就会正常回收释放堆外内存了。


只要放开-XX:+DisableExplicitGC 限制,Java NIO 发现堆外内存不足了,自然会通过 System.gc()提醒 JVM 去主动垃圾回收,回收掉一些 DirectByteBuffer,进而释放堆外内存。

发布于: 刚刚阅读数: 2
用户头像

JavaEdge

关注

正在征服世界的 Javaer。 2019.09.25 加入

曾就职于百度、携程、华为等大厂,阿里云开发者社区专家博主、腾讯云+社区2019、2020年度最佳作者、慕课网认证作者、CSDN博客专家,简书优秀创作者兼《程序员》专题管理员,牛客网著有《Java源码面试解析指南》。

评论

发布
暂无评论
Java NIO为何导致堆外内存OOM了?