Skip to content
Go back

ByteBuffer——HeapByteBuffer与DirectByteBuffer的本质区别

ByteBuffer:堆内 vs 堆外——差的不只是 GC

一句话结论(30s)

HeapByteBuffer 与 DirectByteBuffer 的本质区别是数据所在位置:堆内的数据在 IO 路径上要多一次「堆内→堆外」的拷贝,因为内核的 read/write/sendfile 只能和堆外内存交互——堆内存可能被 GC 移动,物理地址不稳定。DirectByteBuffer 靠 Unsafe.allocateMemory 把数据放堆外直通内核,配合 transferTo() 可实现真正的零拷贝。核心权衡是:堆外内存绕过 GC 带来性能,但它的释放依赖 Cleaner 虚引用,GC 迟迟不触发时堆外内存会「假性泄漏」直到 OOM,这是隐蔽且难排查的代价。

核心原理(2min)

HeapByteBuffer 数据在 JVM 堆上,IO 路径是「应用 → 堆内 buffer → 临时堆外 DirectByteBuffer → 内核缓冲区 → 网卡/磁盘」,多一次堆内到堆外的拷贝;DirectByteBuffer 底层是 Unsafe.allocateMemory(size) 分配的堆外内存,路径是「应用 → 堆外 → 内核缓冲区 → 网卡/磁盘」,跳过堆内→堆外的拷贝,内核可直接读写。释放机制上,堆外内存不受 GC 直接管理——GC 管理的是堆上的 DirectByteBuffer 对象,堆外内存通过 Cleaner 虚引用链在对象被回收时顺带释放;但若堆内存充足、GC 迟迟不触发,堆外内存会一直不释放,出现「堆内存充足但堆外 OOM」。排查靠 -XX:MaxDirectMemorySize 设上限 + JMX 监控 java.nio:type=BufferPool,name=direct

底层深入(5-10min)

HeapByteBuffer:在 JVM 堆上

ByteBuffer buf = ByteBuffer.allocate(1024);  // 堆内分配

数据在 JVM 堆中,GC 管理生命周期。读写数据经过以下路径:

应用 → HeapByteBuffer(堆内) → 临时 DirectByteBuffer(堆外) → 内核缓冲区 → 网卡/磁盘

多了一次从堆内到堆外的拷贝。 因为内核的 IO 操作(read/write/sendfile)只能和堆外内存交互——JVM 堆内存可能被 GC 移动,GC 期间对象的物理地址会变化,内核无法稳定引用堆内地址。

思考:为什么内核 IO 不能直接读写堆内存?——因为堆内存的物理地址不稳定。GC 会移动对象(特别是年轻代复制、Full GC 整理),对象的物理地址随时会变,而内核 IO 是异步的,若按一个旧地址写数据,地址早就失效了。所以要么先把数据拷贝到一块「永不被移动」的堆外内存再交给内核,这就是多出来的那次拷贝。

DirectByteBuffer:在堆外

ByteBuffer buf = ByteBuffer.allocateDirect(1024);  // 堆外分配

底层是 Unsafe.allocateMemory(size) 分配的堆外内存,通过 Cleaner(虚引用)在 GC 时自动释放。

应用 → DirectByteBuffer(堆外) → 内核缓冲区 → 网卡/磁盘

跳过了堆内→堆外的拷贝。 数据始终在堆外,内核直接读写堆外内存,配合 FileChannel.transferTo()(零拷贝)实现真正的零 CPU 拷贝。

思考:DirectByteBuffer 省掉的那次拷贝,代价是什么?——代价是分配和释放都不走 GC。Unsafe.allocateMemory 是系统调用,比堆上 new 慢得多;释放又要依赖 Cleaner 虚引用,时机不可控。所以堆外内存「快」在 IO 路径、「慢」在生命周期管理,适用于长生命周期的大缓冲,不适合频繁创建的小临时缓冲。

DirectByteBuffer 的内存泄漏

堆外内存不受 GC 直接管理——GC 管理的是 DirectByteBuffer 对象(在堆上),堆外内存通过 Cleaner 虚引用链在 GC 回收 DirectByteBuffer 对象时顺带释放。

问题:如果堆内存充足、GC 迟迟不触发 → DirectByteBuffer 堆对象长期存活 → 对应的堆外内存永不释放 → 堆内存还很充足但堆外内存 OOM。

排查:-XX:MaxDirectMemorySize 设置上限防止泄漏失控;监控 jmxjava.nio:type=BufferPool,name=direct 查看堆外内存使用量。

思考:为什么堆外内存会「假性泄漏」?——因为 GC 触发与否看的是堆内存水位,而不是堆外内存。DirectByteBuffer 对象本身在堆上、很小,堆内存充足时 GC 不启动,这些堆对象一直活着,Cleaner 虚引用就一直不被处理,对应的堆外内存也就永远不释放。结果就是「堆还很空,堆外却 OOM」——这是最隐蔽的坑。

选择指南

HeapByteBufferDirectByteBuffer
分配位置JVM 堆堆外(Native)
GC 管理✅ 直接管理❌ 间接(Cleaner 虚引用)
IO 路径堆内→堆外拷贝堆外直通内核
分配开销高(系统调用)
适用场景临时小缓冲、业务逻辑大文件传输、长生命周期缓冲、零拷贝

章末提问

追问 1:HeapByteBuffer 和 DirectByteBuffer 的本质区别是什么?

回答思路:结论先行——本质区别是数据所在位置:堆内 vs 堆外。因为堆内数据在 IO 路径上要多一次「堆内→堆外」拷贝,DirectByteBuffer 底层是 Unsafe.allocateMemory 分配的堆外内存,直通内核、跳过这次拷贝,配合 transferTo() 可实现真正零拷贝。

追问 2:为什么内核 IO 操作不能直接读写 JVM 堆内存?

回答思路:结论先行——因为堆内存的物理地址不稳定,会被 GC 移动。因为 GC 回收/整理时会移动对象(年轻代复制、Full GC 压缩),对象物理地址随时变化,而内核 IO 是异步的,按旧地址读写会失效;所以必须先把数据拷到一块不会被移动的堆外内存,内核才能稳定引用,这就是多出来的那次拷贝。

追问 3:DirectByteBuffer 为什么会有内存泄漏风险?如何排查?

回答思路:结论先行——因为堆外内存依赖 Cleaner 虚引用释放,GC 迟迟不触发就会「假性泄漏」直到 OOM。因为 GC 只看堆内存水位,DirectByteBuffer 堆对象很小、堆充足时 GC 不启动,堆对象长期存活导致对应堆外内存永不释放。排查靠 -XX:MaxDirectMemorySize 设上限,再用 JMX 监控 java.nio:type=BufferPool,name=direct 观察堆外用量。


Share this post on:

Previous Post
Compact Strings——JDK 9如何将String内存占用减半?
Next Post
BigDecimal——为什么0.1+0.2不等于0.3?