上文我们介绍了下FileChannel的基本API使用。本文中,我们就一起看下FileChannel中的高阶API。
说是高阶,还真的就是,这些知识点大量利用了操作系统的对文件传输映射的高级玩法,极大的提高了我们操作文件的效率。我们熟知的kafka、rocketMQ等也是用了这些高阶API,才有如此的高效率。
我们提出一个需求,描述如下:提供一个对外的socket服务,该服务就是获取指定文件目录下的文件,并写出到socket中,最终展现在client端。
按照此需求,常规方式,我们使用如下代码来完成:
File file = new File("D:\\test.txt"); Long size = file.length(); byte[] arr = new byte[size.intValue()]; try { // 1.将test.txt文件内容读取到arr中 FileInputStream fileInputStream = new FileInputStream(file); fileInputStream.read(arr); // 2.提供对外服务 Socket socket = new ServerSocket(9999).accept(); // 3.传输到客户端 socket.getOutputStream().write(arr); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }
以上是一个最简单版本的实现。
那么从操作系统的角度,以上传输经历了哪些过程呢?
这中间的过程我们可以分为以下几步: fileInputStream.read方法对应于: 1)第一次复制:read方法调用,用户态切换到内核态。数据从硬盘拷贝到内核缓冲区,基于DMA自动操作,不需要CPU支持 2)第二次复制:从内核缓冲区拷贝到用户缓冲区(也就是byte[] arr中)。read方法返回,用内核态到用户态的转换。 socket.getOutputStream().write(arr)对应于: 3)第三次复制:从用户缓冲区拷贝数据到socket的内核缓冲区。write方法调用,用户态切换到内核态。 4)数据从socket内核缓冲区,使用DMA拷贝到网络协议引擎。write方法返回,内核态切换到用户态。 从上面的过程我们可以发现,数据发生了四次拷贝,四次上下文切换。 那么还有没有优化方式呢?答案是肯定的,我们接着往下看。File file = new File("D:\\test.txt"); Long size = file.length(); byte[] arr = new byte[size.intValue()]; try { // 1.将test.txt文件内容读取到arr中 RandomAccessFile raFile = new RandomAccessFile(file, "rwd"); FileChannel channel = raFile.getChannel(); MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, size); // 2.提供对外服务 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind(new InetSocketAddress(9999)); serverSocketChannel.configureBlocking(false); while(true){ SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel != null){ // 3.传输到客户端 socketChannel.write(mappedByteBuffer); } } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }我们直接将file的内容映射到mappedByteBuffer,然后直接将mappedByteBuffer的内容传递出去。 那么从操作系统的角度,以上传输经历了哪些过程呢?
参考1中的四个步骤,少了一次内存拷贝,就是将文件从内核缓冲区拷贝到用户进程缓冲区这一步;但是上下文切换并没有减少。
File file = new File("D:\\test.txt"); Long size = file.length(); try { // 1.将test.txt文件内容读取到arr中 RandomAccessFile raFile = new RandomAccessFile(file, "rwd"); FileChannel channel = raFile.getChannel(); // 2.提供对外服务 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind(new InetSocketAddress(9999)); serverSocketChannel.configureBlocking(false); while(true){ SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel != null){ // 3.使用transferTo方法将文件数据传输到客户端 channel.transferTo(0, size, socketChannel); } } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }同2中的代码,只是在最后一步将文件内容传输到socket时,使用了不一样的方法,本例中使用了FileChannel.transferTo方法来传递数据。 那么从操作系统的角度,以上传输经历了哪些过程呢?
参照1中的4个过程,少了用户空间的参与,那么就不存在用户态与内核态的切换。 所以,总结下来,就是减少了两次上下文切换,同时,减少了一次数据拷贝。 注意:剩下的是哪两次上下文切换呢?用户进程调用transferTo方法,用户态切换到内核态;调用方法返回,内核态切换到用户态。
参照1中的4个操作过程,同样少了用户空间的参与,也不存在用户态与内核态的切换。
所以总结下来,就是两次数据拷贝,两次上下文切换(相比较3就是减少了内核文件缓冲区到内核socket缓冲区的拷贝)
下面我们通过一个图表来展示下以上四种传输方式的异同
传输方式 | 上下文切换次数 | 数据拷贝次数 |
传统IO方式 | 4 | 4 |
mmap方式 | 4 | 3 |
sendFile(Linux2.1) | 2 | 3 |
sendFile(Linux2.4) | 2 | 2 |
首先我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据, sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。 而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。 再稍微讲讲 mmap 和 sendFile 的区别。
linux下的mmap和零拷贝技术 - 简书
mmap与sendfile() - 简书