在现在各种RPC框架、网络编程框架中,底层大量的使用了Java NIO作为效率的保证,NIO对比IO有着无与伦比的性能优势,才保证了各种高并发场景下的系统承载能力,其中不得不提的就是“零拷贝”,“零拷贝”是决定NIO性能的关键性因素,但是其又受限于底层操作系统的支持。
“零拷贝”特性是通过程序层来调用 native方法,进而借助于操作系统底层的特殊IO实现的,就像字面意思一样,操作系统通过了一些设计上的优化,达到了减少操作数据过程中的拷贝次数、切换“上下文”的次数,从而极大的提升了数据传输效率。
通过模拟客户端向服务端发送数据来比较NIO与传统IO的速度
唯一物料:准备一个百兆大文件,文件有越大越能体现的明显
服务端
package com.leolee.zeroCopy;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @ClassName IOServer
* @Description: IO服务端接收数据,不对接受数据做任何处理,只用于模拟客户端的数据接收
* @Author LeoLee
* @Date 2020/10/13
* @Version V1.0
**/
public class IOServer {
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());
byte[] bytes = new byte[4096];
while (true) {
int readCount = dataInputStream.read(bytes, 0, bytes.length);
if (readCount == -1) {
break;
}
}
}
}
}
客户端
package com.leolee.zeroCopy;
import java.io.*;
import java.net.Socket;
/**
* @ClassName IOClient
* @Description: IO客户端读取文件并发送给服务器
* @Author LeoLee
* @Date 2020/10/13
* @Version V1.0
**/
public class IOClient {
public static void main(String[] args) throws IOException {
//建立到服务端的连接
Socket socket = new Socket("127.0.0.1", 8899);
//源文件
String file = "C:" + File.separator + "Users" + File.separator + "LeoLee" + File.separator + "Desktop" + File.separator + "sqldeveloper-4.1.5.21.78-x64.zip";
//读取目标数据
InputStream inputStream = new FileInputStream(file);
//发送数据
DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
byte[] bytes = new byte[4096];
long readcout;
long total = 0;
long startTime = System.currentTimeMillis();
while ((readcout = inputStream.read(bytes)) >= 0) {
total += readcout;
dataOutputStream.write(bytes);
}
System.out.println("发送总字节数:" + total + ",耗时:" + (System.currentTimeMillis() - startTime));
dataOutputStream.close();
inputStream.close();
socket.close();
}
}
依次运行服务端和客户端后,客户端输出如下:
服务端
package com.leolee.zeroCopy;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
/**
* @ClassName NIOServer
* @Description: NIO服务端接收数据,不对接受数据做任何处理,只用于模拟客户端的数据接收
* @Author LeoLee
* @Date 2020/10/13
* @Version V1.0
**/
public class NIOServer {
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
//该选项用来决定如果网络上仍然有数据向旧的ServerSocket传输数据,是否允许新的ServerSocket绑定到与旧的ServerSocket同样的端口上,该选项的默认值与操作系统有关,在某些操作系统中,允许重用端口,而在某些系统中不允许重用端口。
//
//当ServerSocket关闭时,如果网络上还有发送到这个serversocket上的数据,这个ServerSocket不会立即释放本地端口,而是等待一段时间,确保接收到了网络上发送过来的延迟数据,然后再释放端口。
//
//值得注意的是,public void setReuseAddress(boolean on) throws SocketException必须在ServerSocket还没有绑定到一个本地端口之前使用,否则执行该方法无效。此外,两个公用同一个端口的进程必须都调用serverSocket.setReuseAddress(true)方法,才能使得一个进程关闭ServerSocket之后,另一个进程的ServerSocket还能够立刻重用相同的端口
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress("127.0.0.1", 8899));
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
//阻塞模式
socketChannel.configureBlocking(true);
int readCount = 0;
while (-1 != readCount) {
readCount = socketChannel.read(byteBuffer);
byteBuffer.rewind();
}
}
}
}
客户端
package com.leolee.zeroCopy;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
/**
* @ClassName NIOClient
* @Description: NIO客户端读取文件并发送给服务器
* @Author LeoLee
* @Date 2020/10/13
* @Version V1.0
**/
public class NIOClient {
public static void main(String[] args) throws IOException, InterruptedException {
//建立到服务器连接
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8899));
//设置为阻塞模式:为了保证更准确的验证“零拷贝”的效率,所以设置为阻塞,一直读完所有的文件数据再传递到服务端
socketChannel.configureBlocking(true);
//源文件
String file = "C:" + File.separator + "Users" + File.separator + "LeoLee" + File.separator + "Desktop" + File.separator + "sqldeveloper-4.1.5.21.78-x64.zip";
FileChannel fileChannel = new FileInputStream(file).getChannel();
long startTime = System.currentTimeMillis();
//发送数据
//mac电脑(linux系统)中,可以直接使用long transferCount = fileChannel.transferTo(position, fileChannel.size(), socketChannel);
//并不需要如下循环,原因windows对一次传输的数据大小有限制(8388608bytes),所以不能依次传输所有数据,需要循环来传递
//参考与https://blog.csdn.net/forget_me_not1991/article/details/80722386
long position = 0;
long size = fileChannel.size();
long total = 0;
while (position < size) {
long transferCount = fileChannel.transferTo(position, fileChannel.size(), socketChannel);//这一步体现零拷贝
System.out.println("发送:" + transferCount);
if (transferCount <= 0) {
break;
}
total += transferCount;
position += transferCount;
}
System.out.println("发送总字节数:" + total + ",耗时:" + (System.currentTimeMillis() - startTime));
fileChannel.close();
}
}
依次运行服务端和客户端后,客户端输出如下:
很明显的能够看出同样的文件通过不同的方法来传递,NIO明显要快于传统IO传递数据
13224 与 771
这简直就是一个天上一个地下!!!在linux系统上可能更为明显!!!
为什么传统IO会慢???
传统IO在进行数据传递的时候,以传递文件数据场景为例:
在传统IO传递数据的过程中,一共发生了四次数据拷贝,并伴随了4次上下文切换,且socket模块发送数据是有“队列”任务的,这就是传统IO效率低下的原因。而在这个过程中,用户空间只是起到了一个数据中转的作用,这样是相当的多余的操作!是极大的性能损耗。
不完全“零拷贝”(通过sendfile()发送数据)
相对于传统IO的read() syscall和write() syscall,sendfile() syscall可以省略数据从内核空间拷贝到用户空间的过程。简单的来讲就是处于用户空间的程序通过调用native方法(syscall 系统调用)告诉内核空间,你帮我发送一个文件数据,不用告诉我文件数据是什么,你只用帮我发就行了。当内核空间发送了文件数据后遍返回结果给到用户空间。
此方式重要的操作就是读写操作之间,增加了内核空间数据的拷贝,将从磁盘读取的数据,拷贝到了socket缓冲区中。
升级版“零拷贝”
完全“零拷贝”
这个描述符就是“零拷贝”的关键所在!
起始从磁盘读数据到kemel buffer 和 socket buffer就是NIO的scatter,协议引擎通过kemel buffer 和 socket buffer获取数据,就是gather操作。
对于上面的NIO示例开说,fileChannel.transferTo 就是实质上的“零拷贝”操作,由于其实现代码在sun包下,这里就不做源码分析了,只要知道其调用了native方法,通过操作系统实现的“零拷贝”就可以了。
归根到底,我们要认识到,“零拷贝”的操作是要依靠与操作系统的能力的,现在的绝大多数操作系统都已经支持了“零拷贝”操作,这点我们不用担心。
需要代码的来这里拿嗷:demo项目地址