已同步更新到微信公众号,手机阅读更舒适~
零拷贝机制原理分析之前,我们先来看下传统IO在数据拷贝的基本原理,从数据拷贝(I/O拷贝)的次数以及上下文切换的次数进行对比分析。
传统IO:
1、JVM进程内发起read()系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)
2、通过DMA引擎建数据从磁盘拷贝到内核态空间的输入的socket缓冲区中(第一次拷贝)
3、将内核态空间缓冲区的数据原封不动的拷贝到用户态空间的缓存区中(第二次拷贝),同时内核态空间切换到用户态空间(第二次上下文切换),read()系统调用结束
4、JVM进程内业务逻辑代码执行
5、JVM进程内发起write()系统调用
6、操作系统由用户态空间切换到内核态空间(第三次上下文切换),将用户态空间的缓存区数据原封不动的拷贝到内核态空间输出的socket缓存区中(第三次拷贝)
7、write()系统调用返回,操作系统由内核态空间切换到用户态空间(第四次上下文切换),通过DMA引擎将数据从内核态空间的socket缓存区数据拷贝到协议引擎中(第四次拷贝)
传统IO方式,一共在用户态空间与内核态空间之间发生了4次上下文的切换,4次数据的拷贝过程,其中包括2次DMA拷贝和2次I/O拷贝(内核态与用户应用程序之间发生的拷贝)。
内核空间缓冲区的一大用处是为了减少磁盘I/O操作,因为它会从磁盘中预读更多的数据到缓冲区中。而使用BufferedInputStream的用处是减少“系统调用”。
DMA:
DMA(Direct Memory Access) —直接内存访问 :DMA是允许外设组件将I/O数据直接传送到主存储器中并且传输不需要CPU的参与,以此将CPU解放出来去完成其他的事情。
sendfile数据零拷贝:
显然,在传统IO中,用户态空间与内核态空间之间的复制是完全不必要的,因为用户态空间仅仅起到了一种数据转存媒介的作用,除此之外没有做任何事情。
Linux 提供了sendfile()用来减少我们前面提到的数据拷贝和的上下文切换次数
1、发起sendfile()系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)
2、通过DMA引擎建数据从磁盘拷贝到内核态空间的输入的socket缓冲区中(第一次拷贝)
3、将数据从内核空间拷贝到与之关联的socket缓冲区(第二次拷贝)
4、将socket缓冲区的数据拷贝到协议引擎中(第三次拷贝)
5、sendfile()系统调用结束,操作系统由用户态空间切换到内核态空间(第二次上下文切换)
根据以上过程,一共有2次的上下文切换,3次的I/O拷贝。我们看到从用户空间到内核空间并没有出现数据拷贝,从操作系统角度来看,这个就是零拷贝。内核空间出现了复制的原因: 通常的硬件在通过DMA访问时期望的是连续的内存空间。
支持scatter-gather特性的sendfile数据零拷贝
这次相比sendfile()数据零拷贝,减少了一次从内核空间到与之相关的socket缓冲区的数据拷贝。
1、发起sendfile()系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)
2、通过DMA引擎建数据从磁盘拷贝到内核态空间的输入的socket缓冲区中(第一次拷贝)
3、将描述符信息会拷贝到相应的socket缓冲区当中,该描述符包含了两方面的信息:a)kernel buffer的内存地址;b)kernel buffer的偏移量。
4、DMA gather copy根据socket缓冲区中描述符提供的位置和偏移量信息直接将内核空间缓冲区中的数据拷贝到协议引擎上(第二次拷贝),这样就避免了最后一次I/O数据拷贝。
5、sendfile()系统调用结束,操作系统由用户态空间切换到内核态空间(第二次上下文切换)
下面这个图更进一步理解:
Linux/Unix操作系统下可以通过下面命令查看是否支持scatter-gather特性。
# ethtool -k eth0 | grep scatter-gather
scatter-gather: on
tx-scatter-gather: on
tx-scatter-gather-fraglist: on
许多的web server都已经支持了零拷贝技术,比如Apache、Tomcat。
sendfile零拷贝消除了所有内核空间缓冲区与用户空间缓冲区之间的数据拷贝过程,因此sendfile零拷贝I/O的实现是完成在内核空间中完成的,这对于应用程序来说就无法对数据进行操作了。
如果需要对数据做操作,Linux提供了mmap零拷贝来实现。
mmap零拷贝:
通过上图看到,一共发生了4次的上下文切换,3次的I/O拷贝,包括2次DMA拷贝和1次的I/O拷贝,相比于传统IO减少了一次I/O拷贝。使用mmap()读取文件时,只会发生第一次从磁盘数据拷贝到OS文件系统缓冲区的操作。
1)在什么场景下使用mmap()去访问文件会更高效?
对文件执行随机访问时,如果使用read()或write(),则意味着较低的 cache 命中率。这种情况下使用mmap()通常将更高效。
多个进程同时访问同一个文件时(无论是顺序访问还是随机访问),如果使用mmap(),那么操作系统缓冲区的文件内容可以在多个进程之间共享,从操作系统角度来看,使用mmap()可以大大节省内存。
2)什么场景下没有使用mmap()的必要?
访问小文件时,直接使用read()或write()将更加高效。
单个进程对文件执行顺序访问时(sequential access),使用mmap()几乎不会带来性能上的提升。譬如说,使用read()顺序读取文件时,文件系统会使用 read-ahead 的方式提前将文件内容缓存到文件系统的缓冲区,因此使用read()将很大程度上可以命中缓存。
下面我们通过代码示例来对比下传统IO与使用了零拷贝技术的NIO之间的差异。
我们通过服务端开启socket监听,然后客户端连接的服务端进行数据的传输,数据传输文件大小为237M。
1、构建传统IO的socket服务端,监听8898端口。
public class OldIOServer {
public static void main(String[] args) throws Exception {
try (ServerSocket serverSocket = new ServerSocket(8898)) {
while (true) {
Socket socket = serverSocket.accept();
DataInputStream inputStream = new DataInputStream(socket.getInputStream());
byte[] bytes = new byte[4096];
// 从socket中读取字节数据
while (true) {
// 读取的字节数大小,-1则表示数据已被读完
int readCount = inputStream.read(bytes, 0, bytes.length);
if (-1 == readCount) {
break;
}
}
}
}
}
}
2、构建传统IO的客户端,连接服务端的8898端口,并从磁盘读取237M的数据文件向服务端socket中发起写请求。
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 8898)); // 连接服务端socket 8899端口
// 设置一个大的文件, 237M
try (FileInputStream fileInputStream = new FileInputStream(new File("/Users/david/Downloads/jdk-8u144-macosx-x64.dmg"));
// 定义一个输出流
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) {
// 读取文件数据
// 定义byte缓存
byte[] buffer = new byte[4096];
int readCount; // 每一次读取的字节数
int total = 0; // 读取的总字节数
long startTime = System.currentTimeMillis();
while ((readCount = fileInputStream.read(buffer)) > 0) {
total += readCount; //累加字节数
dataOutputStream.write(buffer); // 写入到输出流中
}
System.out.println("发送的总字节数:" + total + ", 耗时:" + (System.currentTimeMillis() - startTime));
}
}
}
运行结果:发送的总字节数:237607747, 耗时:450 (400~600毫秒之间)
接下来,我们通过使用JDK提供的NIO的方式实现数据传输与上述传统IO做对比。
1、构建基于NIO的服务端,监听8899端口。
public class NewIOServer {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); // 这里设置为阻塞模式
int readCount = socketChannel.read(byteBuffer);
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
// 这里一定要调用下rewind方法,将position重置为0开始位置
byteBuffer.rewind();
}
}
}
}
2、构建基于NIO的客户端,连接NIO的服务端8899端口,通过FileChannel.transferTo传输237M的数据文件。
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8899));
socketChannel.configureBlocking(true);
String fileName = "/Users/david/Downloads/jdk-8u144-macosx-x64.dmg";
FileInputStream fileInputStream = new FileInputStream(fileName);
FileChannel fileChannel = fileInputStream.getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目标channel
System.out.println("发送的总字节数:" + transferCount + ",耗时:" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
运行结果:发送的总字节数:237607747,耗时:161(100到300毫秒之间)
结合运行结果,基于NIO零拷贝技术要比传统IO传输效率高3倍多。所以,后续当设计大文件数据传输时可以优先采用类似NIO的方式实现。
这里我们使用了FileChannel,其中调用的transferTo()方法将数据从FileChannel传输到其他的channel中,如果操作系统底层支持的话transferTo、transferFrom会使用相关的零拷贝技术来实现数据的传输。所以,这里是否使用零拷贝必须依赖于底层的系统实现。
FileChannel.transferTo方法
public abstract long transferTo(long position,
long count,
WritableByteChannel target) throws IOException
将字节从此通道的文件传输到给定的可写入字节通道。
试图读取从此通道的文件中给定 position 处开始的 count 个字节,并将其写入目标通道。此方法的调用不一定传输所有请求的字节;是否传输取决于通道的性质和状态。如果此通道的文件从给定的 position 处开始所包含的字节数小于 count 个字节,或者如果目标通道是非阻塞的并且其输出缓冲区中的自由空间少于 count 个字节,则所传输的字节数要小于请求的字节数。
此方法不修改此通道的位置。如果给定的位置大于该文件的当前大小,则不传输任何字节。如果目标通道中有该位置,则从该位置开始写入各字节,然后将该位置增加写入的字节数。
与从此通道读取并将内容写入目标通道的简单循环语句相比,此方法可能高效得多。很多操作系统可将字节直接从文件系统缓存传输到目标通道,而无需实际复制各字节。
参数:
position - 文件中的位置,从此位置开始传输;必须为非负数
count - 要传输的最大字节数;必须为非负数
target - 目标通道
返回:
实际已传输的字节数,可能为零
FileChannel.transferFrom方法
public abstract long transferFrom(ReadableByteChannel src,
long position,
long count) throws IOException
将字节从给定的可读取字节通道传输到此通道的文件中。
试着从源通道中最多读取 count 个字节,并将其写入到此通道的文件中从给定 position 处开始的位置。此方法的调用不一定传输所有请求的字节;是否传输取决于通道的性质和状态。如果源通道的剩余空间小于 count 个字节,或者如果源通道是非阻塞的并且其输入缓冲区中直接可用的空间小于 count 个字节,则所传输的字节数要小于请求的字节数。
此方法不修改此通道的位置。如果给定的位置大于该文件的当前大小,则不传输任何字节。如果该位置在源通道中,则从该位置开始读取各字节,然后将该位置增加读取的字节数。
与从源通道读取并将内容写入此通道的简单循环语句相比,此方法可能高效得多。很多操作系统可将字节直接从源通道传输到文件系统缓存,而无需实际复制各字节。
参数:
src - 源通道
position - 文件中的位置,从此位置开始传输;必须为非负数
count - 要传输的最大字节数;必须为非负数
返回:
实际已传输的字节数,可能为零
发生相应的异常的情况:
异常抛出:
IllegalArgumentException - 如果关于参数的前提不成立
NonReadableChannelException - 如果不允许从此通道进行读取操作
NonWritableChannelException - 如果目标通道不允许进行写入操作
ClosedChannelException - 如果此通道或目标通道已关闭
AsynchronousCloseException - 如果正在进行传输时另一个线程关闭了任一通道
ClosedByInterruptException - 如果正在进行传输时另一个线程中断了当前线程,因此关闭了两个通道并将当前线程设置为中断
IOException - 如果发生其他 I/O 错误
参考资料:
http://xcorpion.tech/2016/09/10/It-s-all-about-buffers-zero-copy-mmap-and-Java-NIO/
http://www.jianshu.com/p/e76e3580e356
http://www.linuxjournal.com/node/6345
http://senlinzhan.github.io/2017/03/25/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B%E4%B8%AD%E7%9A%84zerocpoy%E6%8A%80%E6%9C%AF/
jdk官方文档