欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

定位JVM堆外内存问题的实用策略与技巧

最编程 2024-07-23 19:11:32
...

前言

几乎每个Java应用程序都使用一些本机(堆外)内存。只不过对于大多数应用程序而言,使用的堆外内存相对较少。 但是,有的时候会出现 Java 应用程序(进程)占用的总内存远大于其堆内存的大小。如果这不是你所期望的,并且不知道如何定位问题。那么请阅读本文,可以帮助你了解应用发生了什么事情。

本文并不会覆盖同时包含 java 和 native 代码的应用程序,因为通常而言,知道如何编写 native 代码的开发人员是懂得如何对其进行调试的。但是实际情况是:即便是纯 java 代码编写的应用程序有时也会使用大量的堆外内存,这种情况一般是比较难以理解的。

在纯 java 代码编写的应用程序中,堆外内存使用最常见的方式就是通过 java.nio.DirectByteBuffer 的对象实例来申请的。对象创建后,会通过对象内部调用来申请分配与 buffer 容量相等的堆外内存。堆外内存释放主要是通过下面两种方式:第一种是在 DirectByteBuffer 实例对象被 GC 回收之后,通过调用实例对象中与 java 机制中 finalize 类似的函数方法来进行自动释放。第二种是通过手动显示的释放。后者占的比例很小。

除此之外,在极少的情况下,本地内存可能被 JVM 内部数据过多的使用,例如类元数据等。或者操作系统可能为应用进程提供了超出其所需的内存。本文接下来将会较为详细的讨论这些情况。

1. I/O 线程使用 java.nio.HeapByteBuffers

Java NIO 使用 ByteBuffer 来读写数据。java.nio.ByteBuffer 是抽象类,其具体的实现子类有 HeapByteBufferDirectByteBuffer,其中前者只封装了一个简单的 Byte[] 数组,后者可以直接分配获取堆外内存(off-heap memory)。两个具体类实例可以分别通过 ByteBuffer.allocate()ByteBuffer.allocateDirect() 创建。每一种 buffer 实现都其优缺点,但是重要的一点是操作系统只能操作堆外内存(native memory,与上文 off-heap memory 同义)进行数据的读写。因此,如果实现 I/O 操作部分的代码(比如一些 I/O 库,Netty 等)使用到了 HeapByteBuffer,那么通过 I/O 操作具体读写的内容总是会先复制到由 JDK 在后台临时申请的 DirectByteBuffer 中,即堆外内存中。

此外,JDK中每个线程可能缓存多个DirectByteBuffer实例对象,并且在默认情况下,缓冲区的size大小并没有限制。如果 java 应用创造了很多通过HeapByteBuffer执行 I/O 操作的线程, 并且使用的 buffer 很大,那么最终 JVM 进程最终可能占用大量的 native 内存,看起来就像发生了内存泄漏一样。这些线程占用的 native memory 只会在线程终止并且GC机制将DirectByteBuffer对象实例回收之后才能得到释放。接下来进行详细的讲述。

为了更直观的说明这个问题,可以将 JVM 内存情况 dump 下来并且使用内存分析工具 JXRay来进行分析。尽管 dump 出的信息是堆内存的使用情况,而要探究的出问题的内存发生在堆外,但是 dump 出来的数据仍能给我们提供一些重要的信息:每一个 DirectByteBuffer 对象实例中都包含一个容量数据字段,该字段表明相应的实例所拥有的 native 内存的容量大小。下面是通过 JXRay 工具解析出来的一组处理 IO 操作的线程所占用的 DirectByteBuffer 缓存情况。

图片

当这些问题出现在你无法修改的代码中(例如第三方库)时,只能通过下面的方法来解决问题:

  • 降低 IO 线程数量
  • 如果 JDK 版本高于 1.8u102,可以通过 JVM 参数 -Djdk.nio.maxCachedBufferSize 来限制每个线程使用的 DirectByteBuffer 大小

关于上面的参数,Oracle 的文档解释是可以用于限制被用来当做临时缓冲区的缓冲区的大小(有点绕)。具体如下: JDK 8u102 版本引入了配置项 jdk.nio.maxCachedBufferSize,用于限制被用作 temporary buffer cache(TBC) 的内存大小。TBC是每个线程独有的缓存区,使用的是堆外内存,java NIO 的实现中便使用到了这个特性从而可以支持应用处理缓存区和堆内中数据的I/O操作。这个配置的是可以被缓存的直接缓冲区的最大容量。如果未设置该项配置,那么用于缓存的缓冲区的大小没有限制。具有某些特性I/O使用模式的应用程序可以从使用此属性中受益。特别是在启动时执行 I/O 操作使用了大量缓冲区(很多MB),但使用较小的缓冲区就可以执行其 I/O 操作的应用程序使用此属性也可能会受益。但是,该配置并不能为使用直接缓冲区执行 I/O 的应用程序带来任何好处。

通过实践和阅读源码,总结以下说明:

  • 进行数值设置时不能使用诸如“M”,“GB”等单位符号,只能只用单纯的数组,例如:Djdk.nio.maxCachedBufferSize = 1000000
  • 这个值设置的是每一个线程的内存使用量,所以如果计划可以使用的堆外内存的总量为R(RSS 中的堆外内存部分),并且有 T 个线程,那么每个线程可以分配的量为 R/T。
  • jdk.nio.maxCachedBufferSize 的设置并不能阻止大的 DirectByteBuffer 的分配,它只会防止buffer被缓存和重复使用。因此,如果 10 个线程分配了 1GB 的 HeapByteBuffer,然后这些线程同时开始执行一些 IO 操作,那么将会由于存在 10 个临时的直接缓冲区,RSS的内存占用量可能出现高达10GB的峰值。但是,这些 DirectByteBuffer 在 IO 操作之后被释放。相反的是,任何小于阈值的直接缓冲区都将保留在内存中,直到其所有者线程终止。

2. OutOfMemoryError: Direct Buffer Memory

JVM 内部会通过DirectByteBuffers来对分配和释放 native memory 的量进行跟踪,并对数量进行限制。如果您的应用程序失败并显示如下堆栈信息:

java.lang.OutOfMemoryError: Direct buffer memory
        at java.nio.Bits.reserveMemory(Bits.java:694)
        at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
        at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)

这表明 JVM 使用的堆外内存超出了设定的限制。需要注意的是,内部的限制和机器可用 RAM 之间无关。在机器 RAM 充足的情况下 Java 应用可能因为超过限定值而运行失败。另一方面,如果限定值设置的过高导致机器 RAM 被耗尽,那么应用程序可能会强制操作系统使用交换分区,甚至导致操作系统崩溃。

内部 JVM 配置如下:

  • 默认情况下,与 -Xmx 值相同。即在默认情况下,JVM 堆外和对内内存区域具有相同的最大使用上限,即便两个内存区域作用不同。
  • 可以通过 -XX:MaxDirectMemorySize 设置上限值。并且可以通过 "G" 或者 "g" 等数据大小单位来表示。 因此,如果需要检查一个 JVM 可以使用的最堆外内存量,首先应该查看是否配置了 -XX:MaxDirectMemorySize 项。如果没有设置,或者设置的值小于0,那么 JVM 会启用默认配置,即和堆内内存大小相同。读者可以查看以下源代码来进一步了解:
  • JDK中获取最大值并判断使用溢出的代码逻辑
  • JDK中配置定义说明

用户还可采用下面方式直接获取 JVM 可以使用的最大堆外内存:

System.out.println("Max native mem = " + sun.misc.VM.maxDirectMemory());

那么,当 JVM 使用过多的堆外内存情况出现时,应该如何查找原因,如何减少 RSS ?首先,应该找出最大(指向最大堆外堆外内存使用量)的 DirectByteBuffer 实例对象,通过这一步可以确定是哪一种类型的数据占用了大量的堆外内存。这一步可以通过 dump 堆内使用情况并且使用 JXRay 等分析工具进行分区完成。

糟糕的是如果 JVM 分配的堆内存空间很大,以至于很少发生老年代的 GC (或者 Full GC),可能会导致一些 DirectByteBuffer 对象虽然已经处于不可达的装态但是却长时间无法被 GC 掉。然后这些对象会一直持有其指向的堆外内存,直到下面的清理方法被调用:

((DirectBuffer)buf).cleaner().clean();

垃圾收集器总是在销毁对象之前调用它,但是,如上所述,它可能为时已晚。如果你对管理堆外内存的代码了如指掌,则可以显式调用上述方法。 否则,防止堆外内存过度使用的唯一方法是减小堆内存的大小从而使得GC更加频繁。

3. Linux GLIBC Allocator 的负面影响: RSS 以 64MB 为增量进行递增

这是最不容易发现但是可能导致堆外内存出问题的情况。当 RedHat 企业版 Linux 6(RedHat Enterprise Linux 6,RHEL 6)操作系统运行在 x64 位机器上,采用的内存分配器是 glibc 且版本 >= 2.10 时,JVM 实际只需要一小部分内存,但是 RSS 不断增长的问题就会显现出来。此文详细描述了该问题,读者也可以阅读下面内容进行简单了解。

至少在某些版本的 Linux 中,glibc 内存分配器,通过避免竞争对具有大量并发线程的程序进行了优化,调高了程序运行速度。而提速提速是通过为每一个核来维护一个内存池达到的。这种优化方式的本质是:操作系统会为给定的进程捕获(抢占)内存,每个内存块的大小为 64MB,这样的内存块被叫做 arena,当使用pmap分析进程内存时,这些清晰可见。每一个 arena 都只能由特定的 CPU-Core 来进行访问,所以在同一时间点至多会有一个线程会进行访问。然后每个 arena 内部通过 malloc 来使用内存空间。每个 CPU-Core 至多可以分配一定数量的 arena,默认是 8 个。当线程数很多或着频繁创建和销毁线程时,可能会出现上述问题。但是在这些 arena 占用的大量内存中,应用程序真正使用的内存量可能很少。如果应用程序拥有很多的线程数量,并且执行程序的机器CPU核数也很多,那么通过分配 arena 这种方式占用的内存总量可能非常大。比如在CPU核数为16的机器上,arena 占用的内存量会达到 16 * 8 * 64 MB = 8GB。

幸运的是,可以通过MALLOC_ARENA_MAX环境变量来调整arena的最大数量。因此,Java应用程序可以使用如下脚本来防止此问题:

# Some versions of glibc use an arena memory allocator that causes
# virtual memory usage to explode. Tune the variable down to prevent
# vmem explosion.
export MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-4}

原则上,如果怀疑可能存在此问题,那么可以尝试设置上述环境变量,并且可以通过cat /proc/<JVM_PID>/environ来检查是否配置成功。但是,到此故事并没有就此结束–事实证明,有时上述调整不起作用!

因为Linux的一个BUG表明设置MALLOC_ARENA_MAX可能无法生效。不过这个问题已经在 glibc 2.12 版本进行了修复(参考Linux update release notes mentioning BZ#769594),如果使用的仍旧是未修复版本,那么需要注意这一点。

如何确定是否是因为上述原因导致的问题最直接的办法就是采用对照试验。最简单的方法是设置MALLOC_CHECK_=1。这个配置会使得进程使用其他的内存分配器。 但是此分配器的在速度可能较慢(该分配器主要用于调试)。但是,也可以使用另一个名为jemalloc的内存分配器。或者简单地切换到其他Linux发行版(例如CentOS)可能是解决此问题的最快方法。

4. Native Memory Tracking (NMT) JVM Feature

NMT 是 Hotspot JVM 的一个功能,可以用来追踪 JVM 内部内存使用情况。可以通过设置 -XX:NativeMemoryTracking=[summary | detail] 打开开关。功能打开之后其本身并不会导致 JVM 产生额外的信息,而是需要通过 jcmd 来获取 JVM 到目前为止积累的一些信息。信息中即包含一些详细的信息(比如所有的内存分配事件信息),也包含一些总述类的信息(比如内部的类、线程等各占用了多少)。DirectByteBuffer 持有的内存可以通过内部类信息(“internal” category)被追踪到。文档中有说,启用 NMT 会使 JVM 性能下降5-10%,并会导致内存消耗略微增加。但是需要注意的一点是,NMT 不跟踪非 JVM 代码的内存分配。

看起来,NMT 的主要优点是能够跟踪 JVM 内部和元数据使用的内存,否则这些内存将很难或无法区分,例如类,线程堆栈,编译器,符号等。因此,如果以前的故障排除方法不能解释内存的去向,并且知道有问题的内存分配并不是来自自定义的本机代码,则可以将其用作最后的解决方法。

5. Step-By-Step Checklist

如果 JVM 运行失败并出现java.lang.OutOfMemoryError: Direct buffer memory,则问题出在java.nio.DirectByteBuffers,这意味着使用堆外内存量要么太大太多,要么就是没有被及时释放。因此,这些实例对象将一直持有过多的堆外内存。 可以 dump JVM 堆信息,并使用工具JXRay对其进行分析(参照第二部分)。

如果 JVM 的 RSS 远远超过了最大堆内存的设置,那么很有可能也是 DirectByteBuffer 的问题。这是对象实例可能是由应用程序显示的创建(例如通过某些第三方库),也可能是由 JDK 内部使用HeapByteBuffer 的 I/O 线程自动创建和缓存。同样,dump JVM heap 信息并通过工具进行分析,如果存在引用链中将缓冲区保存在内存中并且该引用链中存在java.lang.ThreadLocal,那么可以参考第一部分进行排查,否则参考第二部分进行排查。

如果堆转储中显示并没有足够多的DirectByteBuffer对象实例,那么高 RSS 问题的原因可能是某些自定义的本地代码造成了内存泄漏,也有可能是 JVM 内部可能存在大量会过度消耗内存的类,但是这种可能性比较低,当然也有可能是操作系统存在问题。如果是操作系统的原因的话,那么在通过pmap工具输出的日志中应该会存在众多大小为 64MB 的内存块分配,如果没有,那么就需要检查自定义的本地代码,具体参照第四部分。

说明

该文为本人译文,原文链接如下Troubleshooting Problems With Native (Off-Heap) Memory in Java Applications

能力有限,如有错误请批评指正。