在介绍零拷贝之前,先看看传统的IO是如何将一个文件读取进内存中的。
传统IO(一)
public class Test01TraditionIO {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("/EthanHe/20210514NIO/1.txt");
byte[] buff = new byte[1024];
int length = 0;
while ((length = fileInputStream.read(buff)) != -1) {
String s = new String(buff, 0, length);
System.out.println(s);
}
fileInputStream.close();
}
}
以上是一段很经典的将文件1.txt(1.txt文件只有"测试数据"这几个中文,占用12个byte)通过字节流方式读入内存代码,且占用的内存是JVM堆空间内存。接下来将这段程序放进Linux系统中跑起来并使用strace查看IO磁盘进行了哪些系统调用。
系统调用的过程存储在了nio01log文件中,再通过vim查看该文件:
图中红色框的系统调用就是传统IO过程发生的系统调用,我们逐一解释这个过程
open:打开位于/EthanHe/20210514NIO目录下的1.txt文件,并设置为可读。后面的数字5表示1.txt的文件描述符(fd)。
fstat:获取fd为5的文件状态
read:将fd=5的文件读入内存中(JVM堆空间),后面的1024就是Java代码中定义的byte数组大小,接着数字12表示读取了12个byte
mprotect:设置内存空间权限,后面的PROT_READ、PROT_WRITE表示该内存的操作权限时可读可写,数字0表示设置该内存的权限成功
write:接着是连续的两个write,因为上面的Java代码拿到文件内容后是控制台输出,通过write系统调用给显示器控制器进行字符的输出显示。
跟着继续调用了read系统调用,发现此时读到的数据为0表示已经读完此fd了。对应到上面的Java代码则会跳出while循环。
close(5):关闭fd=5的文件,释放内存空间。
我们再把read系统调用的过程细化一下:
- JVM进行read系统调用读取1.txt文件时会先发生一次CPU上下文切换,从用户态进入内核态
- 用内核的中断处理程序去驱动磁盘控制器,磁盘控制器通过DMA将1.txt文件读入内核空间中
- 接着将内核空间中的数据再拷贝至用户空间中JVM内存。
- 再次发生一次上下文切换,从内核态切回用户态,程序的执行流程交由JVM处理
整个过程上下文切换了2次,数据拷贝了2次(其实是3次,下文解释)
传统IO(二)
public class Test02TraditionIOWithChannel {
public static void main(String[] args) throws Exception {
FileChannel fileChannel = FileChannel.open(Paths.get("/EthanHe/20210514NIO/1.txt"));
// 分配用户态缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
while ((fileChannel.read(buffer)) != -1) {
// 切换成读模式
buffer.flip();
byte[] dst = new byte[buffer.limit()];
buffer.get(dst);
String s = new String(dst, 0, dst.length);
System.out.println(s);
// 切换成写模式继续向buffer中读取数据
buffer.flip();
}
fileChannel.close();
}
}
上面这段代码采用Java NIO的API读取本地文件,同样在Linux系统运行并查看系统调用
图中箭头所示是NIO读取1.txt文件(fd=5)的过程。可以看到跟第一段代码的系统调用没有差别。因此同样需要2次上下文切换,2次拷贝。
采用本地内存(DirectByteBuffer)
public class Test03ZeroCopy {
public static void main(String[] args) throws Exception {
FileChannel fileChannel = FileChannel.open(Paths.get("/EthanHe/20210514NIO/1.txt"));
// 分配Native直接内存(存在于用户态中)
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while ((fileChannel.read(buffer)) != -1) {
buffer.flip();
byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
String s = new String(bytes, 0, bytes.length);
System.out.println(s);
buffer.flip();
}
fileChannel.close();
}
}
同样用strace查看系统调用
从系统调用的输出结果来看和前两种传统IO没有任何区别,我们继续把上面两种传统的IO 过程再细化一下就明白了采用直接内存的优势了。
可以看到,其实前面两种传统的IO在JVM的实现中会先将内核态的数据拷贝至JVM的堆外内存,然后再拷贝进JVM的堆内存。因此前面两种传统IO实际是经过了2次上下文切换,3次数据拷贝。
而采用了直接内存后,就减少了一次从直接内存拷贝至堆内存的过程。通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作
从图中可以看出采用了NIO的直接内存后,上下文切换进行了2次,数据拷贝了2次。
零拷贝
public class Test04ZeroCopy {
public static void main(String[] args) throws Exception {
FileChannel fileChannel = FileChannel.open(Paths.get("/EthanHe/20210514NIO/1.txt"));
// 获取内存映射空间(存在于内核态中)
MappedByteBuffer byteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
byte[] bytes = new byte[byteBuffer.limit()];
byteBuffer.get(bytes);
String s = new String(bytes, 0, bytes.length);
System.out.println(s);
fileChannel.close();
}
}
还是直接查看这段程序的系统调用
可以看到这次采用的系统调用不是read读取数据了,而是采用mmap系统调用。mmap 主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,达到内核缓冲区和应用缓冲区共享的目的。
通过mmap系统调用,此时上下文切换2次,数据拷贝1次。减少了数据拷贝过程中性能消耗。看似很完美,但其实调用mmap创建内存映射的性能损耗也非常的大,Java源码注释中也建议只有大文件传输才适用,否则还是用回read和write系统调用合适。
总结
本文对比了Java几种IO本地文件的方式和对应OS提供的系统调用(read、write、mmap)。当然这些IO过程不局限在本地文件的IO,进行网络IO(Socket通讯)同样也适用,比如传输大文件时就可以采用创建映射文件的方式节省数据的拷贝,Kafka传输速度快也是因为有零拷贝技术的支持。
本文已经作者授权,转发自:mp.weixin.qq.com/s/aPeWh5Kbd…