深入理解零拷贝
在操作系统的层面上分为两个空间:用户空间与内核空间
内核空间和用户空间一般通过系统调用进行通信。
用户空间就是用户进程所在的内存区域,系统空间就是操作系统占据的内存区域。 用户进程和系统进程的所有数据都在内存中。
传统的I/O操作
- Java虚拟机发出read()系统调用,(不是我们常使用的read()方法,这是操作系统底层的系统调用)。-> 用户空间向内核空间发出一个系统调用,发生一次上下文切换,用户空间模式切换为内核空间模式。
- 由内核空间正真的向磁盘发出读取数据的请求,然后通过DMA的方式将数据读取到内核空间缓冲区。(DMA直接内存访问)这时候出现了第一次拷贝,将磁盘上的数据拷贝到内核空间缓冲区。
- 将内核空间缓冲区的数据拷贝到用户空间的缓冲区中。(这里数据没有发生任何改变)。这里发生了一次上下文切换。
- 业务逻辑代码
- Java虚拟机发出write()系统调用,将用户空间缓冲区的数据原封不动的拷贝到内核空间缓冲区。这里发生了一次上下文切换。
- 其实这里还有一部操作将数据从内核空间缓冲区拷贝至Socket缓冲区然后再正真的写入到网络。
- 操作完成,write()方法返回。这里出现一个上下文切换。
出现了四次上下文切换和两次不必要的数据拷贝。
两次不必要的数据拷贝分别为:从内核空间将数据拷贝至用户空间,将用户空间的数据再次拷贝至内核空间。
用户空间只是一个中转的媒介,对数据没有做任何的处理。
出现了四次数据的拷贝
传统同步I/O存在的问题:上下文切换次数过多,存在没有意义的拷贝。(在这方面入手提高性能)
操作系统意义上的零拷贝
- Java虚拟机发出sendfile()系统调用。发生一次上下文切换
- 内核空间向磁盘请求数据,通过DMA的方式将数据读取到内核空间缓冲区。
- 将内核空间缓冲区的数据写入到将要发送的socket缓冲区。
- 向目标发送数据
- 操作完成,返回sendfile()系统调用。
出现了两次上下文切换。
相对于传统I/O减少了两次数据拷贝的过程。从内核空间拷贝数据至用户空间。从用户空间拷贝数据值内核空间。
从操作系统层面来看这就是零拷贝,因为不再有数据会在用户空间和内核空间之间拷贝。所有的操作都在内核空间内进行。
出现了两次数据拷贝
进一步分析 -> 可不可以从磁盘直接将数据读取到socket缓冲区? 从而再减少一次数据的拷贝。
NIO中的零拷贝
- Java虚拟机发出sendfile()系统调用。
- 内核空间向磁盘请求数据,通过DMA的方式将数据读取到内核空间缓冲区。
- 将文件描述符信息拷贝至socket buffer,文件描述符中描述了两个信息。1、内核buffer的地址。2、要读取的长度,buffer的长度。
- 协议引擎(protocol engine)分别从两处【内核缓冲区,及socket buffer】收集/读取数据,再直接将内核缓冲区中的数据发送到对应的服务器端。
- 操作完成,返回sendfile()系统调用。
出现一次数据拷贝
我们发现Netty中的零拷贝 比 操作系统意义上的零拷贝还少了一次数据的拷贝。
我们并没有将数据再拷贝至socket buffer中,而是在其中存储的文件描述符,
再然后协议引擎直接从内核缓冲区和socket buffer中收集数据,直接发送至服务器端。
但是注意->需要操作系统支持Gather。
就出现了一次数据拷贝
使用案例进行测试
案例一、使用传统I/O方式
//服务端
Server{
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8899);
while (true){
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
try {
byte[] byteArray = new byte[1024];
while (true){
int read = dataInputStream.read(byteArray, 0, byteArray.length);
if (read == -1){
break;
}
}
}catch (Exception e){
e.printStackTrace();
}
}
}
}
//客户端
Client{
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost",8899);
String fileName = "D:/Users/mjw/Downloads/eclipse.zip";
InputStream inputStream = new FileInputStream(fileName);
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[1024];
long readCount;
long total = 0;
long startTime = System.currentTimeMillis();
while ((readCount = inputStream.read(buffer)) >=0){
total += readCount;
dataOutputStream.write(buffer);
}
System.out.println("发送总字节数:" + total +", 耗时" + (System.currentTimeMillis() - startTime));
dataOutputStream.close();
socket.close();
inputStream.close();
}
}
-----------------------测试结果--------------------
耗时:15453毫秒
案例二、使用NIO方式
//服务端
Server{
public static void main(String[] args) throws Exception{
InetSocketAddress address = new InetSocketAddress(8898);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket socket = serverSocketChannel.socket();
socket.setReuseAddress(true);
socket.bind(address);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true){
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(true);
int readCount = 0;
while (-1 != readCount){
try {
readCount = socketChannel.read(byteBuffer);
}catch (Exception e){
e.printStackTrace();
}
byteBuffer.rewind();
}
}
}
}
//客户端
Client{
public static void main(String[] args) throws Exception{
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost",8898));
socketChannel.configureBlocking(true);
String fileName = "D:/Users/mjw/Downloads/eclipse.zip";
FileChannel fileChannel = new FileInputStream(fileName).getChannel();
long startTime = System.currentTimeMillis();
long transferCount = fileChannel.transferTo(0,fileChannel.size(),socketChannel); //将文件写入到socketChannel
System.out.println("发送字节数:" + transferCount +",耗时" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
---------------------测试结果---------------------
35毫秒
我们发现测试结果相差还是很明显的,拷贝的减少提高了不止一点点的性能。
我们的NIO案例中、最至关重要的一步是 ->
fileChannel.transferTo(0,fileChannel.size(),socketChannel);
它的头部注释中有这么一段话
/**
* <p> This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them. </p>
*/
此方法可能比从此通道读取数据并写入目标通道的简单循环高效得多。许多操作系统可以直接将字节从文件系统缓存传输到目标通道,而不需要实际复制它们。
原来如此,到这里我已经知道零拷贝的执行过程了,但是又有新的问题出现了。
既然我们不会再有数据拷贝至我们的用户空间,那当我们需要对数据操作的时候怎么办呢?
我们知道传统的I/O操作,我们会在用户空间对拷贝的数据进行操作。
而NIO中我们是使用直接内存映射来实现这一操作的。什么是直接内存?
在《深入理解Java虚拟机》中对于直接内存给出的解释是这样的。直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
而在NIO中,可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。因此避免了在Java堆和Native堆中来回复制数据。