堆外内存

JVM 启动时分配的内存称为堆内存(Heap Memory),与之相对的是堆外内存(Off-Heap Memory)。在代码中,我们可以直接使用堆外内存,例如 Netty 框架就广泛使用了堆外内存来提升性能。但这部分内存并不归 JVM 管理,GC 算法也不会对其进行回收。因此,在使用堆外内存时需要格外小心,防止因内存无法及时释放而导致线上故障。

堆外内存的申请和释放

JDK 的 ByteBuffer 类提供了一个静态方法 allocateDirect(int capacity) 用于申请堆外内存,其底层通过 unsafe.allocateMemory(size) 实现。接下来我们深入探究在 JVM 层面是如何实现的。

可以发现,最底层是通过 malloc 方法申请的。这块内存需要进行手动释放,JVM 并不会自动回收。幸好 Unsafe 提供了另一个接口 freeMemory,可以对申请的堆外内存进行释放。

堆外内存的回收机制

如果每次申请堆外内存都需要在代码中显式地释放,对于 Java 这门语言的设计来说显然不够合理。既然 JVM 不会直接管理这些堆外内存,它们是如何被回收的呢?

DirectByteBuffer

JDK 中使用 DirectByteBuffer 对象来表示堆外内存。每个 DirectByteBuffer 对象在初始化时,都会创建一个对应的 Cleaner 对象。这个 Cleaner 对象会在合适的时候执行 unsafe.freeMemory(address),从而回收这块堆外内存。

当初始化一块堆外内存时,对象的引用关系如下:

其中 firstCleaner 类的静态变量。Cleaner 对象在初始化时会被添加到 Cleaner 链表中,与 first 形成引用关系;ReferenceQueue 则是用来保存需要回收的 Cleaner 对象。

如果该 DirectByteBuffer 对象在一次 GC 中被回收了:

此时,只有 Cleaner 对象唯一保存了堆外内存的数据(开始地址、大小和容量)。在下一次 Full GC 时,该 Cleaner 对象会被放入到 ReferenceQueue 中,并触发 clean 方法。

Cleaner 对象的 clean 方法主要有两个作用:

  1. 把自身从 Cleaner 链表中删除,从而在下次 GC 时能够被回收。
  2. 释放堆外内存。
public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

如果 JVM 一直没有执行 Full GC,无效的 Cleaner 对象就无法放入到 ReferenceQueue 中,从而导致堆外内存一直得不到释放,是否会导致内存溢出(OOM)?

其实在初始化 DirectByteBuffer 对象时,如果当前堆外内存剩余空间不足(条件苛刻),会主动调用 System.gc() 强制执行 Full GC。

不过,很多线上环境的 JVM 参数配置了 -XX:+DisableExplicitGC,这导致 System.gc() 相当于空操作,根本不会触发 Full GC。这一点在使用 Netty 框架时需要注意,评估是否会出现内存泄漏问题。

说明:本文基于 JDK 8 及之前版本分析。在 JDK 9 及之后版本中,sun.misc.Cleaner 已被 java.lang.ref.Cleaner 取代,且对 Unsafe 的访问受到模块系统限制,内部实现细节有所变化,但堆外内存回收的基本原理依然相似。