文章目录

Java 堆外内存DirectBuffer的分配与回收

发布于 2026-04-14 06:18:01 · 浏览 26 次 · 评论 0 条

Java 堆外内存 DirectBuffer 的分配与回收

Java 堆外内存(Off-Heap Memory)是指直接在 JVM 堆之外分配的内存,主要由操作系统管理。使用 DirectBuffer(特别是 DirectByteBuffer)操作堆外内存,可以避免数据在 Java 堆和本地堆之间复制,从而显著提升 I/O 性能。但这也带来了内存分配与回收的特殊性,处理不当极易引发内存泄漏。


一、堆外内存的分配机制

堆外内存的分配并不像 Java 对象那样简单,它涉及 JVM 内部的权限检查和本地方法的调用。

  1. 调用分配 API
    在代码中,使用 java.nio.ByteBuffer 类的静态方法来申请堆外内存。

    输入以下代码申请 10MB 的直接内存:

    ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);

    这行代码会在 Java 堆内创建一个 DirectByteBuffer 对象,但实际的数据存储空间位于堆外。

  2. 校验内存额度
    JVM 内部维护一个 Bits 类,用于记录当前已使用的堆外内存总量。在真正分配前,系统会检查申请的大小加上已使用量是否超过限制。

    • 默认限制:默认情况下,最大堆外内存大小约等于最大堆内存(-Xmx)。部分 VM(如 JRockit)默认可能无限制,HotSpot 默认约为 64MB 或与堆一致。

    • 自定义限制添加 JVM 启动参数来显式指定最大值:

      -XX:MaxDirectMemorySize=512m
  3. 尝试分配与回收尝试
    如果校验发现内存不足,JVM 会尝试通过 System.gc() 触发一次垃圾回收,试图释放掉之前被废弃但未回收的 DirectByteBuffer 对象,并休眠 100毫秒。

    如果回收后内存仍不足,系统将抛出异常:
    java.lang.OutOfMemoryError: Direct buffer memory

  4. 执行本地分配
    如果校验通过,JVM 将调用 Unsafe 类的本地方法 allocateMemory,向操作系统申请内存。申请成功后,JVM 会将这块内存清零,并将内存地址存储在 DirectByteBuffer 对象中供后续访问。


二、堆外内存的回收机制

堆外内存不受 Java GC 的直接管控,其回收依赖于 Java 堆对象的回收机制和虚引用技术。

1. 自动回收原理

DirectByteBuffer 对象在 Java 堆中不再被引用时,它会被当作垃圾。但此时它对应的堆外内存并不会立即释放,必须通过 Cleaner 机制来处理。

下面是 DirectByteBuffer 从被废弃到堆外内存释放的完整流程:

graph TD A["DirectByteBuffer 对象被废弃"] --> B["GC 扫描并发现对象不可达"] B --> C["GC 将对象关联的 PhantomReference 插入 ReferenceQueue"] C --> D["ReferenceHandler 守护线程被唤醒"] D --> E["线程取出 Reference 并调用 Cleaner.clean"] E --> F["Cleaner 执行 Deallocator.run 方法"] F --> G["调用 Unsafe.freeMemory 释放堆外内存"] G --> H["更新 Bits 类的内存使用计数"]
  • 虚引用DirectByteBuffer 在创建时会关联一个 Cleaner 对象(继承自虚引用 PhantomReference)。虚引用不影响对象的生命周期,也无法通过它获取对象实例。
  • ReferenceHandler 线程:JVM 启动时会创建一个高优先级的 ReferenceHandler 守护线程。该线程不断监控引用队列,一旦发现 Cleaner 对象被加入队列,就执行clean 方法。
  • Deallocatorclean 方法最终调用 Deallocatorrun 逻辑,利用 Unsafe.freeMemory 释放本地内存。

2. 手动回收(不推荐)

在某些极端性能敏感场景下,若确定堆外内存不再使用,可以通过反射手动调用 Cleanerclean 方法来提前释放内存,但这破坏了 JVM 的自动管理机制,通常不建议使用。


三、System.gc() 的关键作用与陷阱

在分配堆外内存的 Bits.reserveMemory 方法中,如果内存不足,代码会显式调用 System.gc()。这是一个关键的设计细节,但也埋下了隐患。

  1. 显式 GC 的必要性
    由于堆外内存的释放依赖于 Java 堆对象的 GC(即先回收 DirectByteBuffer 对象,才能触发 Cleaner 释放堆外内存),因此在分配新内存失败时,主动建议 JVM 进行 GC 是合理的自救措施。

  2. DisableExplicitGC 的风险
    为了减少 GC 对性能的影响,很多线上环境会配置参数 -XX:+DisableExplicitGC,该参数会禁用代码中的 System.gc() 调用。

    • 后果:如果开启了此参数,当堆外内存不足时,Bits.reserveMemory 中的 System.gc() 调用将失效。JVM 不会尝试回收旧的 DirectByteBuffer,而是直接抛出 OutOfMemoryError
    • 现象:此时 Java 堆内存可能还很充裕,但物理内存已被耗尽。

    建议:如果应用大量使用了 NIO(如 Netty、RocketMQ 等),请慎用 -XX:+DisableExplicitGC,或者使用 -XX:+ExplicitGCInvokesConcurrent 将显式 GC 转换为并发 GC 以降低停顿。


四、堆外内存溢出的排查

当遇到 java.lang.OutOfMemoryError: Direct buffer memory 或物理内存异常飙升时,执行以下步骤进行排查。

  1. 区分内存区域
    首先确认是 Java 堆溢出还是堆外溢出。使用 jmap -heap <pid> 查看堆内存使用情况。如果堆内存使用率很低,但进程占用内存很高,则极有可能是堆外内存泄漏。

  2. 强制触发 GC 验证
    由于常规工具(如 VisualVM)无法直接监控堆外内存,可以利用 GC 机制来验证。

    运行以下命令:

    jmap -histo:live <pid>

    该命令会强制 JVM 执行一次 Full GC。

  3. 观察内存变化
    在执行上述命令的同时,观察操作系统的内存监控工具(如 toptop -p <pid> 的 RES 指标)。

    • 如果 GC 后,操作系统的内存占用明显下降,说明释放了大量堆外内存,这些内存对应的 DirectByteBuffer 对象此前未被回收。
    • 如果 GC 后内存占用不变,则可能存在非 DirectByteBuffer 管理的堆外内存泄漏(如通过 JNI 调用的 native 代码泄漏),或者 -XX:MaxDirectMemorySize 设置确实过小。
  4. 分析堆转储
    抓取堆转储文件(Heap Dump),分析 java.nio.DirectByteBuffer 实例的数量。如果存在大量未回收的实例,需检查业务代码中是否存在未及时释放引用的情况,或者是否由于对象生命周期过长导致无法进入 Old Gen 的 GC 范围。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文