传统Java BIO API为何无需使用Direct Buffer?
BIO并非没有解决GC内存移动对系统调用的影响,只是它把堆外内存的申请、拷贝、释放逻辑完全封装在JDK底层native实现里,对上层开发者完全透明,不需要像NIO那样暴露DirectBuffer相关API给用户自行管理。
本质前提:所有OS原生IO系统调用都要求内存地址固定
不管是BIO还是NIO,只要调用操作系统内核提供的read/write类系统调用,传入的用户态内存地址必须在整个系统调用执行期间保持固定——内核态不会感知JVM的GC行为,如果GC在IO过程中移动了目标内存块的位置,内核访问的就是非法地址,必然出现数据错误甚至崩溃。
这个底层约束对所有IO模型是通用的,不存在BIO可以绕开GC内存移动影响的说法。
BIO的底层实现逻辑:临时堆外内存的透明使用
我们平时写BIO代码时,都是直接把堆上的byte[]传给InputStream.read()/OutputStream.write()方法,看起来全程没有接触堆外内存,实际JDK在native层做了隐式处理:
- 执行write类操作时:JVM会先临时申请一块固定地址的堆外内存(和NIO Direct Buffer申请的是同类型的不受GC管控的native内存),把传入的堆上byte数组的数据拷贝到这块临时堆外内存,再把堆外内存的地址传给内核write调用,等内核完成数据拷贝(从用户态堆外内存到内核套接字缓冲区)返回后,立刻释放这块临时堆外内存。
- 执行read类操作时:JVM同样先申请临时堆外内存,把地址传给内核read调用,等内核把数据从内核缓冲区拷贝到临时堆外内存返回后,再把堆外内存里的数据拷贝回传入的堆上byte数组,之后立刻释放临时堆外内存。
这套逻辑对上层开发者完全不可见,所以会给人一种“BIO不需要Direct Buffer”的错觉,本质上BIO每次IO调用都在用一次性的“临时Direct Buffer”,只是生命周期和单次系统调用完全绑定,不需要用户操心。
为什么NIO要显式暴露Direct Buffer
NIO引入Direct Buffer的核心原因不是“只有NIO会遇到GC内存移动问题”,而是NIO的非阻塞、多路复用模型和BIO的阻塞模型有本质差异:
- BIO是阻塞模型,单次read/write调用必须等IO操作完全完成才会返回,临时堆外内存的生命周期极短,JDK自行申请释放的开销很低,完全没必要暴露给用户。
- NIO的非阻塞模型下,缓冲区往往需要跨多次IO调用、跨IO事件复用:比如做散射读/聚合写、或者半包数据要留到下一次可读事件再处理,如果还沿用BIO每次临时申请堆外内存、做两次内存拷贝的逻辑,会产生大量没必要的性能开销。因此NIO才把堆外内存的管控权开放给开发者,允许用户申请长生命周期的Direct Buffer自行管理,减少重复内存申请释放、重复拷贝的成本,本质是用灵活性换性能。
补充一个常见误区:BIO的隐式堆外内存拷贝是它性能弱于NIO的原因之一——高并发场景下,每次IO都做两次堆内<->堆外拷贝、反复申请释放临时内存的开销会被放大,而合理使用池化Direct Buffer的NIO程序可以大幅减少这部分损耗。
内容的提问来源于stack exchange,提问作者Jiankuan Xing

