一、零拷贝
零拷贝是网络编程的关键,很多性能优化都离不开零拷贝。在 Java 程序中,常用的零拷贝有 mmap(内存映射) 和 sendFile。
Java 中传统的 IO 和网络编程:
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int) file.length()];
raf.read(arr);
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);
注:DMA —— direct memory access 直接内存拷贝(不适用CPU)
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。如下图:
Linux 2.1 版本提供了 sendFile 函数,其基本原理是:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,由于和用户态完全无关,就减少了一次上下文切换。
Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,而是直接拷贝到协议栈,从而再一次减少了数据拷贝。
注:
①这里其实有一次 cpu 拷贝 kernel buffer -> socket buffer, 但是拷贝的信息很少,比如 lenght、offset,消耗很低,可以忽略
②所谓零拷贝,就是拷贝由操作系统完成,不需要 CPU 参与。这样可以避免 CPU 的上下文切换,也可以避免资源从内核空间到用户空间的拷贝,节约了时间。这也保证了内核缓冲区之间数据不会重复,在内核缓冲区中只有一份数据。零拷贝不仅仅带来更少的数据复制,还有更少的 CPU 缓存伪共享以及无 CPU 校验和计算等
mmap 和 sendFile 的区别:
①mmap 适合小数据量读写,sendFile 适合大文件传输;
②mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝;
③sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区);
二、零拷贝案例
服务端:
public class ZeroCopyServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
File file = new File("E:\\b.pdf");
SocketChannel socketChannel = serverSocketChannel.accept();
// 追加文件内容
FileChannel fileChannel = new FileOutputStream(file, true).getChannel();
while (true) {
int readCount = 0;
while (-1 != readCount) {
try {
readCount = socketChannel.read(byteBuffer);
// 注意读写转换
byteBuffer.flip();
fileChannel.write(byteBuffer);
byteBuffer.clear();
} catch (Exception ex) {
ex.printStackTrace();
break;
}
}
break;
}
fileChannel.close();
}
}
客户端:
public class ZeroCopyClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "E:\\a.pdf";
FileChannel fileChannel = new FileInputStream(filename).getChannel();
long startTime = System.currentTimeMillis();
// 在 linux 下一个 transferTo 方法就可以完成传输,但 windows 下调用一次 transferTo 只能发送 8m,就需要分段传输而且要注意传输的位置
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("发送的总字节数:" + transferCount + ",耗时:" + (System.currentTimeMillis() - startTime));
fileChannel.close();
// 保证客户端不会在文件传输后立即死掉,这会使其与服务端的连接立即断开,而此时服务端可能尚未读取完客户端发送的文件数据,断开连接后剩余的数据将不可读,从而导致传输的文件打不开
Thread.sleep(1000);
}
}
注:transferTo()方法的底层用到了零拷贝。