Java NIO 03:DirectBuffer详解
DirectBuffer
之前我们用ByteBuffer.allocate()看一下源码:
1 | public static ByteBuffer allocate(int capacity) { |
HeapByteBuffer是从堆上分配的内存空间创建的Buffer,实际上JDK还提供了另外一种方式ByteBuffer.allocateDirect():
1 | public static ByteBuffer allocateDirect(int capacity) { |
DirectByteBuffer创建的buffer是从直接内存中开辟的空间分配,我们叫做堆外内存,不会被gc回收,里面用到了很多没有开源的sun的api。
new DirectByteBuffer(),这个对象本身是在堆上创建的,但是源码里的base = unsafe.allocateMemory(size);则是在堆外内存中分配的,那么java堆上的数据是如何找到堆外的数据的呢,一定是保存了一个地址,找了一下发现如下变量:
Buffer.java
1 | // Used only by direct buffers |
说放在Buffer这个类里是为了效率。
零拷贝
如果使用HeapByteBuffer在进行文件读写的时候,所有的数据都在Java堆上,然而操作系统不是直接处理堆上的数据,而是把堆上的数据拷贝到操作系统里(Java内存模型之外)某一块内存空间中,然后再把数据和IO设备进行交互。意思用HeapByteBuffer进行IO操作的时候中间多了一次数据拷贝的过程。
而使用DirectByteBuffer,因为数据本来就在堆外内存中,所以跟IO设备交互的时候没有拷贝的过程,提升了效率,这有一个专有名词,也就是零拷贝。
以下内容转自知乎:
DirectByteBuffer 自身是一个Java对象,在Java堆中;而这个对象中有个long类型字段address,记录着一块调用 malloc() 申请到的native memory。
HotSpot VM里的GC除了CMS之外都是要移动对象的,是所谓“compacting GC”。
如果要把一个Java里的 byte[] 对象的引用传给native代码,让native代码直接访问数组的内容的话,就必须要保证native代码在访问的时候这个 byte[] 对象不能被移动,也就是要被“pin”(钉)住。
可惜HotSpot VM出于一些取舍而决定不实现单个对象层面的object pinning,要pin的话就得暂时禁用GC——也就等于把整个Java堆都给pin住。HotSpot VM对JNI的Critical系API就是这样实现的。这用起来就不那么顺手。
所以 Oracle/Sun JDK / OpenJDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的I/O可能是一个很慢的操作。
于是它就先把 HeapByteBuffer 背后的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的native memory去,这个拷贝会涉及 sun.misc.Unsafe.copyMemory() 的调用,背后是类似 memcpy() 的实现。这个操作本质上是会在整个拷贝过程中暂时不允许发生GC的,虽然实现方式跟JNI的Critical系API不太一样。(具体来说是 Unsafe.copyMemory() 是HotSpot VM的一个intrinsic方法,中间没有safepoint所以GC无法发生)。
然后数据被拷贝到native memory之后就好办了,就去做真正的I/O,把 DirectByteBuffer 背后的native memory地址传给真正做I/O的函数。这边就不需要再去访问Java对象去读写要做I/O的数据了。
MappedByteBuffer
DirectBuffer是继承于MappedByteBuffer的,内存映射文件是一种允许Java直接从内存访问的特殊文件,操作系统再负责将内存的改动写入的IO设备中。
1 | /** |
上面的代码执行完成会直接修改NioTest9.txt中的内容。
FileLock文件锁
这个用的不多,共享锁是只读,都只能读,排他是只能自己读写,别人不能读也不能写。
1 | /** |
Scattering & Gathering
之前的例子中在进行读写的时候,都是用的一个Buffer对象来完成的,Buffer的Scattering(散开),可以接受传递一个Buffer的数组。比如我要把Channel中的信息读到Buffer中,那么channel里面有20个字节,传递一个Buffer数组,往里面读信息,第一个数组长度是10,第二个数组长度是5,第三个长度也是5,它会按顺序将第一个Buffer读满,再接着往第二个读,再读第三个。就是将一个Channel中的数据读取到多个Buffer中。而Gathering则是相反的,他是写操作,先将第一个Buffer写到Channel中,然后也是顺序写入后面的channel。
下面用一个网络IO的例子来说明:
1 | /** |
nc localhost 8899
telnet localhost 8899
这2个命令都可以进行刚才的程序测试。
1 | nc localhost 8899 |
回车也算一个字节,所以输入hellowor后,程序马上回写了数据。控制台输出:
1 | bytesRead: 9 |
现在程序依旧在等待输入,我们继续输入
1 | hello |
这里先输入一个hello+回车,再输入a+回车,再回车,进行了3次操作,也一共是9个字节,数据进行了回写,接下来看控制台的输出。
1 | bytesRead: 6 |
第一次输入hello+回车的时候,输入了6个字节,0、1索引的buffer读满了,2索引的buffer读取了一个位置,再敲入a+回车,2索引的Buffer还剩一个位置,此时再敲入回车,Buffer全部读满,Buffer开始进行写入操作。
总结
本文系统讲解了相关技术要点。通过学习掌握核心概念和实践方法,提升技术能力。
关键要点
- 理解核心技术原理
- 掌握实际应用方法
- 学习最佳实践和注意事项
实践建议
- 结合实际项目练习
- 深入研究官方文档
- 持续学习和实践