什么是零拷贝
维基上是这么描述零拷贝的:零拷贝描述的是CPU不执行拷贝数据从一个存储区域到另一个存储区域的任务,这通常用于通过网络传输一个文件时以减少CPU周期和内存带宽。
零拷贝给我们带来的好处
减少甚至完全避免不必要的CPU拷贝,从而让CPU解脱出来去执行其他的任务
减少内存带宽的占用
通常零拷贝技术还能够减少用户空间和操作系统内核空间之间的上下文切换
零拷贝的实现
零拷贝实际的实现并没有真正的标准,取决于操作系统如何实现这一点。零拷贝完全依赖于操作系统。操作系统支持,就有;不支持,就没有。不依赖Java本身。
Linux中的内核态与用户态
如上图所示,Unix/Linux的体系架构分为内核空间(kernal space)与用户空间(application space),内核控制着计算机的硬件资源,为上层应用程序提供运行环境。用户空间就是应用程序的活动空间,而内核为应用程序的执行提供着必要的cpu、存储、IO资源等。为了使应用程序访问使用到这些资源,内核就提供了资源访问的接口:系统调用。
传统I/O
在Java中,我们可以通过InputStream从源数据中读取数据流到一个缓冲区里,然后再将它们输入到OutputStream里。我们知道,这种IO方式传输效率是比较低的。那么,当使用上面的代码时操作系统会发生什么情况:
在这个过程中:
1、数据从磁盘拷贝进内核空间缓冲区
2、从内核空间缓冲区拷贝到用户空间缓冲区
3、从用户空间缓冲区拷贝回内核空间缓冲区
4、在从内核空间缓冲区拷贝到socket的缓冲区
5、由Socket缓存区传递给数据发送引擎发送
第三步的必要性:
IO操作涉及到本地方法,java担心,当使用native本地方法对堆内数组进行操作时发生GC, 因为堆内内存是受JVM影响的,一旦发生了垃圾回收机制就使得全部数据都是错乱的,而堆外内存是不受JVM控制的.
就这样, 前前后后一共发生了4次数据的拷贝,用户空间模式和内核空间模式来回切换了4次, 其中用户空间参与的第二次和第三次拷贝并没有对数据进行任何改动,它仅仅是起到了中转的作用; 这恰恰是传统的IO的局限性
NIO的零拷贝
在NIO的数据传递模型中可以看到,用户明显少了用户空间缓冲区缓存数据的步骤, 减少了两次不必要的数据的拷贝,以及不必要的上下文切换, 具体如下:
1、数据从磁盘写入内核空间缓冲区
2、再从内核空间缓冲区写入到Socket缓冲区
3、由Socket缓存区传递给数据发送引擎发送
然而这个模型中仍然有问题存在,在内核空间缓冲区中仍然存在数据的拷贝
数据从内核空间缓冲区拷贝进了Socket缓冲区
这种现状也是有办法解决的
在2.X版本的linux中,NIO的零拷贝模型如下:
这个模型中充分利用了Scatter/Gather 分散和汇聚的特性
这张图是最完美的零拷贝模型:
1、首先文件从磁盘中加载进内核空间缓冲区
2、CPU将内核空间缓冲区存储的数据的adress以及数据的大小存放进Socket
3、协议引擎根据socket提供的数据的描述,直接去内核缓冲区取出数据
第2步 一个完整的可用的buffer被分散在两个buffer中, 可以理解成是一个分散的过程 Scatter
第3步 操作系统去收集buffer,可以理解成一个Gather的过程
从而实现了真正的零拷贝
回到Java
除了上面的第一张图片以外,其他图片中数据全部在内核缓冲区,这部分空间对于人来说其实是一个黑盒,于是java提供了封装类帮我们和这块黑盒打交道
mappedByteBuffer
这是他的继承体系,和HeadByteBuffer位于同一级,我们称它为内存映射文件 他是通道的调用map()方法得来的, 这个mappedByteBuffer相对于普通的buffer而言,他并没有板板整整的维护自己的数组,相反直接关联着堆外内存,针对它的任何修改,操作系统都会自动的同步到文件中
如下修改内存buffer,却更新了文件
RandomAccessFile randomAccessFile = new RandomAccessFile("123.txt", "rw"); //class sun.nio.ch.FileChannelImpl
FileChannel channel = randomAccessFile.getChannel();
System.out.println(channel.getClass());
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
// todo 接下来我们直接修改内存中的内容就行了,不需要修改文件
mappedByteBuffer.put(0, (byte) 'a');
mappedByteBuffer.put(3, (byte) 'b');
randomAccessFile.close();
channel.close();
关于FileChannel.MapMode文件通道的映射模型 是个枚举:
PRIVATE
READ_WRITE
READ_ONLY
当我们想构建read_write类型的只能使用 RandomAccessFile类型的文件stream, 通过它的rw参数,设置为可读写的类型
关于ByteBuffer的ByteBuffer.allocateDirect()
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
...
}
最常用的ByteBuffer的allocateDirect()底层使用同样是MappedBytebuffer的实现类,DirectByteBuffer,这个对象相对于HeapByteBuffer来说,他并没有初始化父类ByteBuffer中的数组,但是它使用了超类BUffer中的Long类型的adress关键字
adress关键字的作用是 存放了一个堆外的地址,这个地址标记着一个堆外数组的位置,使得java可以使用unsafe类下的本地方法,操作adress标记的堆外内存,这样就省去了在第一张图片中的还要把堆内数组拷贝到堆外再进行读写的弊端,实现了零拷贝
scattering 和 gathering在NIO编程中的体现
scattering是一个分散的过程,即把一整块数据分散在不同的buffer中,而gathering与之相反,是一个聚集的过程,只有搜集全所有的全部的buffer得到的数据才是有意义的
例子: 自定义网络协议 将请求头分装成多个缓存buffer中,实现了天然的解析
ByteBuffer[] byteBuffers = new ByteBuffer[3];
byteBuffers[0] = ByteBuffer.allocate(2);
byteBuffers[1] = ByteBuffer.allocate(3);
byteBuffers[2] = ByteBuffer.allocate(4);
SocketChannel client = serverSocketChannel.accept();
long read = client.read(byteBuffers);