什么是零拷贝
张老师的学生无处不在,哈哈哈!@张龙
传统的IO在进行文件传输的时候,涉及多次数据从内核缓冲区到用户缓存区的双向拷贝及用户态和内核态的转换,因此效率低下;NIO的零拷贝实现了从内核缓冲区到用户缓冲区的双向0拷贝,并取消了内核缓冲区从kernel buffer到socket buffer的拷贝,同时也减少了多次用户态和内核态之间的转换,因此在涉及Socket网络传输的时候效率甚高。
传统的IO实现
我们以一个客户端发送一个文件到服务端的例子来说明。先上两段代码:
Server端
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author pulil
* @version V1.0
* @Title
* @Description
* @date 2019-07-13 下午3:03
*/
public class OldIOServer {
public static void main(String[] args) throws Exception {
//创建一个ServerSocket并监听8888端口
ServerSocket serverSocket = new ServerSocket(8888);
while(true) {
//阻塞方法,获得连接的socket对象
Socket socket = serverSocket.accept();
//通过装饰器模式获取DataInputStream
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
int totalCount = 0;
//读取数据
try {
byte[] buffer = new byte[4096];
int read = 0;
while((read = dataInputStream.read(buffer)) > 0) {
totalCount += read;
}
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("服务端接受字节数:" + totalCount);
}
}
}
Client端
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.Socket;
/**
* @author pulil
* @version V1.0
* @Title
* @Description
* @date 2019-07-13 下午4:21
*/
public class OldIOClient {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("localhost",8888);
String fileName = "本地磁盘路径/somefile.zip";//大小953.4 MB
InputStream inputStream = new FileInputStream(fileName);
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] buffer = new byte[4096];
int readCount = 0;
long total = 0;
long startTime = System.nanoTime();
while((readCount = inputStream.read(buffer)) >= 0) {
total += readCount;
dataOutputStream.write(buffer,0,readCount);
}
System.out.println("发送总字节数:" + total + ", 耗时 :" + (System.nanoTime() - startTime)/1000000);
dataOutputStream.close();
socket.close();
inputStream.close();
}
}
运行结果
//客户端运行结果
发送总字节数:953398669, 耗时 :3730
//服务器端运行结果
服务端接受字节数:953398669
本例子发送了一个953.4 MB的文件,共耗时3730ms。
传统IO执行过程
传统IO之所以慢,客户端在进行网络传输的时候需要经历上图步骤:
- JVM发出read系统调用
- 操作系统切换到内核态(限linux和unix,第一次切换),操作系统通过DMA(直接内存访问)将数据读取到内核缓冲区(第一次读取拷贝)
- 操作系统将数据拷贝到用户缓冲区(第二次拷贝),read系统调用返回;操作系统由内核态切换回用户态(第二次切换)
- JVM循环处理代码,并发送write系统调用
- 操作系统再次由用户态切换到内核态(第三次切换),并将用户缓冲区的数据拷贝到内核缓冲区中(第三次拷贝)
- 操作系统将内核缓冲区中的内容拷贝到socket buffer(第四次拷贝)中
- 协议引擎(protocol engine)从socket buffer中获取数据并将数据发送,write系统调用返回,内核态切换回用户态(第四次切换)
传统IO的流程总结
由此可见,传统的IO从硬盘上读取一个文件发送到远程服务器,需要经历
- 一次从硬件的读取拷贝
- 三次数据的拷贝(两次是内核空间和用户空间之间的拷贝,一次是内核空间之内的拷贝)
- 四次的上下文切换。
这就是造成传统IO速度慢的原因。用户空间唯一起到的作用就是中转的作用,其他事情并没有做。
NIO零拷贝的做法(Linux 2.4之前)
一种优化-sendfile()系统调用的做法
步骤
- JVM发送sendfile系统调用
- 用户态切换到内核态(第一次切换),通过DMA从硬件上加载文件到内核缓冲区(第一次读取拷贝)
- 将数据从内核缓冲区拷贝至socket buffer中(第二次拷贝),
注意是完整的数据拷贝
- 协议引擎从socket buffer中读取数据并发送,sendfile系统调用返回,内核态切换为用户态(第二次切换)
sendfile零拷贝总结,该模式下涉及了:
两次上下文的切换
一次读取拷贝
一次内核态下的拷贝
sendfile零拷贝的优化(linux2.4开始
)
可以看到,从linux2.4开始之后的sendfile做了一个很大的优化,就是使用scatter/gather,减少了一次内核状态下的拷贝,具体如下:
首先介绍一下Scatter,官方文档如下
- Scattering:在读取的时候可以不只是读取到一个buffer中,而是读取到多个buffer中
/**
* A channel that can read bytes into a sequence of buffers.
*
* <p> A <i>scattering</i> read operation reads, in a single invocation, a
* sequence of bytes into one or more of a given sequence of buffers.
* Scattering reads are often useful when implementing network protocols or
* file formats that, for example, group data into segments consisting of one
* or more fixed-length headers followed by a variable-length body. Similar
* <i>gathering</i> write operations are defined in the {@link
* GatheringByteChannel} interface. </p>
*
*
* @author Mark Reinhold
* @author JSR-51 Expert Group
* @since 1.4
*/
public interface ScatteringByteChannel extends ReadableByteChannel
Gathering
:在写的时候,可以将多个buffer中的内容合并写出去
/**
* A channel that can write bytes from a sequence of buffers.
*
* <p> A <i>gathering</i> write operation writes, in a single invocation, a
* sequence of bytes from one or more of a given sequence of buffers.
* Gathering writes are often useful when implementing network protocols or
* file formats that, for example, group data into segments consisting of one
* or more fixed-length headers followed by a variable-length body. Similar
* <i>scattering</i> read operations are defined in the {@link
* ScatteringByteChannel} interface. </p>
*
*
* @author Mark Reinhold
* @author JSR-51 Expert Group
* @since 1.4
*/
public interface GatheringByteChannel extends WritableByteChannel
升级后的sendfile零拷贝
从linux2.4版本之后,对于底层的文件描述符做了修改,这里面涉及一个gather调用,gather可以实现将多个buffer中将数据汇集到一起写入网络中。
- JVM发送sendfile系统调用
- 操作系统从用户态切换到内核态,并通过DMA copy从hard drive中将数据拷贝到kernel buffer中,在此同时,会直接将
文件描述符(并不是整个数据)
写入socket buffer中,文件描述符保存了数据在kernel buffer中的内存保存点以及长度,这样就好像数据被直接写入了socket channel中一样 - 协议引擎(protocol engine)使用
gather
从kernel buffer和socket buffer合并将数据发送出去 - 上下文从内核态切换到用户态
这样来看,整个操作过程中,只涉及必要的两次必要的上下文切换
及一次必要的数据从硬件的读取拷
贝,内核缓冲区的kernel buffer拷贝到socket buffer中仅仅拷贝了两个指针,因此真正完成了数据的零拷贝。性能当然大大的提高喽。
sendfile例子如下
Client端
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
/**
* @author pulil
* @version V1.0
* @Title
* @Description
* @date 2019-07-13 下午4:26
*/
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost",8888));
socketChannel.configureBlocking(true);
String fileName = "本地磁盘路径/somefile.zip";//953.4 MB
FileChannel fileChannel = new FileInputStream(fileName).getChannel();
long startTime = System.nanoTime();
//一行代码实现0拷贝,将文件channel中的内容直接写到SocketChannel中
long transferCount = fileChannel.transferTo(0,fileChannel.size(),socketChannel);
System.out.println("发送总字节数:" + transferCount + ", 耗时 :" + (System.nanoTime() - startTime)/1000000);
fileChannel.close();
socketChannel.close();
}
}
运行结果
//客户端输出
发送总字节数:953398669, 耗时 :786
//服务器端输出
服务端接受字节数:953398669
可以看出,发送同样的数据,耗时仅786ms,比传统的IO执行时间3730ms足足快了有4倍还多,推荐大家使用NIO进行网络传输!!!
假装自己是一个华丽的分割线
最后给大家一个Scatter和Gather的小栗子
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Arrays;
/**
* @author pulil
* @version V1.0
* @Title
* @Description
* @date 2019-07-08 下午5:38
*/
public class NioTest11 {
/**
* Buffer的Scattering和Gathering
* Scattering:在读取的时候可以不只是读取到一个buffer中,而是读取到一个buffer数组中
* Gathering:在写的时候,可以将一个buffer数组中的内容写出去
*
* 应用:比如在网络传输中,头信息是10个字节,后面的是消息体
* 拿就可以天然的将头信息和消息体分到两个buffer中,而不是读到一个buffer中再进行解析
*/
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress address = new InetSocketAddress(8888);
serverSocketChannel.socket().bind(address);
int messageLength = 2 + 3 + 4;
//构造buffer数组并初始化
ByteBuffer[] buffers = new ByteBuffer[3];
buffers[0] = ByteBuffer.allocate(2);
buffers[1] = ByteBuffer.allocate(3);
buffers[2] = ByteBuffer.allocate(4);
SocketChannel socketChannel = serverSocketChannel.accept();
while(true) {
int bytesRead = 0;
while(bytesRead < messageLength) {
//将数据读取到buffers中
long r = socketChannel.read(buffers);
bytesRead += r;
System.out.println("bytesRead:" + bytesRead);
Arrays.asList(buffers).stream().map(buffer -> "position:" + buffer.position() + ", limit: " + buffer.limit())
.forEach(System.out::println);
}
//反转
Arrays.asList(buffers).forEach(buffer-> {
buffer.flip();
});
//写入
long bytesWritten = 0;
while(bytesWritten < messageLength) {
long r = socketChannel.write(buffers);
bytesWritten += r;
}
Arrays.asList(buffers).forEach(buffer->{
buffer.clear();
});
System.out.println("bytesRead: " + bytesRead + ", butesWritten: " + bytesWritten + ", messageLength: " + messageLength);
}
}
}