DirectBuffer堆外内存不随Full GC同步释放,因其释放依赖Cleaner异步执行Deallocator回调,而Cleaner触发需GC判定对象不可达并由低优先级CleanerThread处理,存在显著延迟。

不会同步释放。DirectBuffer 关联的堆外内存是否释放,不取决于 Full GC 是否发生,而取决于 Cleaner 是否被执行——而 Cleaner 的执行是异步、延迟且不可控的。
为什么 Full GC 不等于堆外内存释放
DirectBuffer 本身是个轻量级堆内对象(通常不到百字节),它只保存地址、容量等元数据;真正的大块内存由 Unsafe.allocateMemory 向操作系统申请,完全脱离堆内存管理范围。JVM 的 GC 只负责回收这个“壳子”,不直接调用 free()。释放动作必须经由 Cleaner 绑定的 Deallocator 回调触发,而 Cleaner 是 PhantomReference 的一种封装,其清理流程依赖两个关键环节:
- GC 必须先将 DirectBuffer 对象判定为不可达,并将其关联的 Cleaner 入队到 ReferenceQueue;
- CleanerThread(单线程、低优先级)需从队列中取出并执行 clean(),最终调用 unsafe.freeMemory。
这两个环节都存在显著延迟:GC 可能因 Minor GC 频繁而长期不扫描老年代(Cleaner 多在老年代),CleanerThread 又可能被阻塞(如 native free 调用卡在磁盘 IO 或锁竞争中),导致堆外内存“挂尸”数分钟甚至更久。
Full GC 反而可能暴露或加剧释放滞后问题
当 JVM 检测到堆外内存使用逼近 -XX:MaxDirectMemorySize 限制(例如通过 Native Memory Tracking),会主动插入 System.gc() 建议,试图加速 Cleaner 入队与执行。此时日志中出现的 Full GC 往往不是结果,而是“症状”:
- GC 日志里触发原因显示为 “Ergonomics” 或 “System.gc()”,而非正常晋升阈值;
- NMT 报告中 Internal/Other 区域持续增长,但堆内存占用平稳;
- 堆 dump 显示大量 sun.misc.Cleaner 实例处于 ReferenceQueue 中未处理状态,或 dominator tree 显示它们 retain 大量堆外 Buffer。
如何确认堆外内存是否真正释放了
不能只看 GC 日志或堆内存曲线,要交叉验证三类信号:
-
查 NMT 实时用量:运行
jcmd <pid> VM.native_memory summary scale=MB,重点关注 Internal 区域的 “Internal (reserved)” 和 “Other” 分项,若多次 Full GC 后该值未下降,说明 native 内存未释放; -
看 CleanerThread 状态:用
jstack <pid>找到 CleanerThread,若长期处于 RUNNABLE 但无 clean() 栈帧,大概率卡在 native free; - 验 Deallocator 执行痕迹:在测试环境开启 -XX:+PrintGCDetails -XX:+PrintReferenceGC,观察日志中是否有 “Cleaner” 或 “PhantomReference” 处理记录。
真正可控的释放手段
靠等待 GC 和 CleanerThread 是被动策略,高负载场景下极易失效。生产环境应主动干预:
- 显式调用 buffer.clear() + buffer = null,尽快切断强引用,缩短 Cleaner 入队等待时间;
- 对关键 DirectBuffer(如 Netty PooledByteBuf),优先复用池化对象,避免高频创建销毁;
- 必要时用
jcmd <pid> VM.run_finalization强制推进 pending reference 处理(仅限临时救急); - 设置合理 -XX:MaxDirectMemorySize 并配合 NMT 监控,早于 OOM 触发告警,而非等 Full GC 来兜底。

















