深入分析通过Socket进行数据文件传递中的传统IO的弊端以及NIO的零拷贝实现原理,及用户空间和内核空间的切换方式
传统的IO流程
在这个过程中:
- 数据从磁盘拷贝进内核空间缓冲区
- 从内核空间缓冲区拷贝到用户空间缓冲区
- 从用户空间缓冲区拷贝回内核空间缓冲区
- 在从内核空间缓冲区拷贝到socket的缓冲区
- 由Socket缓存区传递给数据发送引擎发送
第三步的必要性:
IO操作涉及到本地方法,java担心,当使用native本地方法对堆内数组进行操作时发生GC, 因为堆内内存是受JVM影响的,一旦发生了垃圾回收机制就使得全部数据都是错乱的,而堆外内存是不受JVM控制的.
就这样, 前前后后一共发生了4次数据的拷贝,用户空间模式和内核空间模式来回切换了4次, 其中用户空间参与的第二次和第三次拷贝并没有对数据进行任何改动,它仅仅是起到了中转的作用; 这恰恰是传统的IO的局限性
NIO的零拷贝
在NIO的数据传递模型中可以看到,用户明显少了用户空间缓冲区缓存数据的步骤, 减少了两次不必要的数据的拷贝,以及不必要的上下文切换, 具体如下:
- 数据从磁盘写入内核空间缓冲区
- 再从内核空间缓冲区写入到Socket缓冲区
- 由Socket缓存区传递给数据发送引擎发送
然而这个模型中仍然有问题存在,在内核空间缓冲区中仍然存在数据的拷贝
- 数据从内核空间缓冲区拷贝进了Socket缓冲区
这种现状也是有办法解决的
在2.X版本的linux中,NIO的零拷贝模型如下:
这个模型中充分利用了Scatter/Gather 分散和汇聚的特性
这张图是最完美的零拷贝模型,
- 首先文件从磁盘中加载进内核空间缓冲区
- CPU将内核空间缓冲区存储的数据的adress以及数据的大小存放进Socket
- 协议引擎根据socket提供的数据的描述,直接去内核缓冲区取出数据
第2步 一个完整的可用的buffer被分散在两个buffer中, 可以理解成是一个分散的过程 Scatter
第3步 操作系统去收集buffer,可以理解成一个Gather的过程
从而实现了真正的零拷贝
回到Java
除了上面的第一张图片以外,其他图片中数据全部在内核缓冲区,这部分空间对于人来说其实是一个黑盒,于是java提供了封装类帮我们和这块黑盒打交道
mappedByteBuffer
这是他的继承体系,和HeadByteBuffer位于同一级,我们称它为内存映射文件 他是通道的调用map()方法得来的, 这个mappedByteBuffer相对于普通的buffer而言,他并没有板板整整的维护自己的数组,相反直接关联着堆外内存,针对它的任何修改,操作系统都会自动的同步到文件中
如下修改内存buffer,却更新了文件
关于FileChannel.MapMode
文件通道的映射模型 是个枚举:
- PRIVATE
- READ_WRITE
- READ_ONLY
当我们想构建read_write类型的只能使用 RandomAccessFile类型的文件stream, 通过它的rw参数,设置为可读写的类型
关于ByteBuffer
的ByteBuffer.allocateDirect()
最常用的ByteBuffer的allocateDirect()
底层使用同样是MappedBytebuffer的实现类,DirectByteBuffer,这个对象相对于HeapByteBuffer
来说,他并没有初始化父类ByteBuffer
中的数组,但是它使用了超类BUffer
中的Long类型的adress
关键字
adress
关键字的作用是 存放了一个堆外的地址,这个地址标记着一个堆外数组的位置,使得java可以使用unsafe
类下的本地方法,操作adress
标记的堆外内存,这样就省去了在第一张图片中的还要把堆内数组拷贝到堆外再进行读写的弊端,实现了零拷贝
scattering 和 gathering在NIO编程中的体现
scattering是一个分散的过程,即把一整块数据分散在不同的buffer中,而gathering与之相反,是一个聚集的过程,只有搜集全所有的全部的buffer得到的数据才是有意义的
例子: 自定义网络协议 将请求头分装成多个缓存buffer中,实现了天然的解析