理解Java中的零拷贝技术原理:MappedByteBuffer
零拷贝技术主要包括mmap和sendfile,在RocketMQ、Kafka这类高性能消息队列中间件中有应用,在Netty这种高性能网络通信框架中也有应用。在Java里mmap和sendfile分别对应MappedByteBuffer和FileChannel.transferTo(),两者都是Java的nio包提供的能力。
MappedByteBuffer与mmap
理解mmap内存文件映射需要理解虚拟内存或者说内存虚拟化,实际可以认为是零拷贝技术的一个基石。
使用虚拟化技术,可以做到让多个虚拟地址映射到同一片物理地址,这样硬件设备驱动就可以做到通过DMA对一片同时对内核和应用都可见的内存区域进行读写了。这样的意义在于,由于这样的内存区域对内核和应用都可见,应用程序才能做到直接操作内核内存去完成一些以往需要到自己应用程序的用户态内存进行中转的读写逻辑。
Java里的mmap内存文件映射能力是通过MappedByteBuffer = FileChannel.map()
这样一个操作提供的,下面来看一下示例代码:
/**
* mmap内存映射在Java中的使用 FileChannel.map() -> MappedByteBuffer
*/
@Slf4j
public class MmapTest {
private static String filepath = "D:\\Media\\test.txt";
private static File f = new File(filepath);
// 使用java io包中的缓冲输入流BufferedInputStream
public static void readFile() {
try {
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(f));
byte[] buffer = new byte[1024];
int len = 0;
while ((len = bis.read(buffer)) != -1) { // 从bis读到byte[] buffer里,读了len个字节
log.info("从BufferedInputStream读了{}", new String(buffer, 0, len, "UTF-8"));
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 使用内存映射
public static void mmapReadFile() {
f = new File(filepath);
int bufferSize = (int) f.length();
byte[] buffer = new byte[bufferSize];
FileChannel fileChannel;
try {
// fileChannel = new RandomAccessFile(f, "rw").getChannel();
fileChannel = FileChannel.open(Paths.get(filepath), StandardOpenOption.READ, StandardOpenOption.WRITE);
/*建立内存映射,用户态虚拟内存与文件读取到的os文件系统内核态内存映射到相同的物理内存地址
这样应用程序读写用户态内存相当于就是读写内核态内存,从而读写文件
内核态内存与磁盘文件之间由os的文件系统管理,读的时候在内核内存就直接读、不在就缺页中断,内核置换页;
写的话直接写到内核态内存里,由os负责或手工flush到磁盘。*/
MappedByteBuffer mappedButeBuffer = fileChannel.map(MapMode.READ_WRITE, 0, f.length());
// 使用内存映射从内核态内存直接读取到byte[] buffer里,因为是内存映射、所以不会发生从内核态复制到用户态内存的过程。
ByteBuffer byteBuffer = mappedButeBuffer.get(buffer);
log.info("从MappedByteBuffer读了{}", new String(buffer, 0, bufferSize, "UTF-8"));
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
readFile();
mmapReadFile();
}
}
上面的代码很简单,比较使用InputStream
读文件内容以及使用MappedByteBuffer
按内存映射的方式读取文件内容。
相比传统的InputStream方式,使用FileChannel.map()
建立内存文件映射,用户态虚拟内存与文件通过DMA存入的os文件系统的内核态内存、映射到相同的物理内存地址。 这样应用程序读写用户态内存相当于就是读写内核态内存。这也就是相当于读写文件:原因在于内核态内存与磁盘文件之间由os的文件系统管理,读的时候在内核内存就直接读、不在就缺页中断,内核置换页; 写的话直接写到内核态内存里,由os负责或手工flush到磁盘。
内存文件映射建立后得到MappedByteBuffer
,之后代码里使用MappedByteBuffer.get(byte[])
将文件内容从内核态内存直接读取到byte[]里,因为是虚拟内存映射、所以不会发生从内核态复制到用户态内存的过程。
FileChannle.transferTo()与sendfile
transferTo好比将两个流的channel直接进行连接,而不是像传统的方式那样从一个读出来再写到另一个去,直接走内核态的copy,不用经过用户态。底层实际是将文件通过DMA读取到os文件系统的内核态内存之后、不复制到用户态内存而是直接transfer到内核态的Socket缓冲区、再通过DMA写到网卡通过网络发送出去。
对应到操作系统层面底层使用的是sendfile内核调用,从Linux2.1开始提供。Linux2.4之后支持所谓“scatter-gather”特性,甚至内核态的copy都不用,target内核态缓冲已经记录了src内核态缓冲的地址,相当于使用DMA直接从src往device(控制台、文件、网路等等)进行输出。也就是说上面提到的从文件系统内核态内存到Socket缓冲区内核态内存这步也省了,直接从文件系统内核态内存通过DMA就写到了网卡。
底层系统的sendfile API:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
说明:
第 1 个参数 out_fd,在 2.6 内核里,必须指向一个 socket 。
第 2 个参数 in_fd,是一个要拷贝文件的文件fd。
第 3 个参数 offset, 是一个偏移量,它在不断的 sendfile 中,这个偏移量会随着偏移增加,直到文件发送完为止,当然在程序中需要用如 while() 这样的语句来控制。
第 4 个参数 count,表示要传送的字节数(在以下示例中,是 1G 文件的大小,即 buf.st_size)
需要注意的是in_fd必须是一个文件,而out_fd可以是文件和网络socket等可写的句柄、但底层从2.6内核开始必须是socket了。从这里可以基本可以看出sendfile的使用场景跟它的名字一样,发送文件到网络。
在Java里,sendfile技术对应的是FileChannel.transferTo()
方法。下面看一下例子程序:
@Slf4j
public class TestServer {
private ServerSocket ss;
public TestServer(int port) throws Exception {
ss = new ServerSocket(port);
}
public void doAccept() throws Exception {
log.info("TestServer start ...");
while (true) {
Socket client = ss.accept();
log.info("recv a connection " + client);
new Worker(client).start();
}
}
class Worker extends Thread {
Socket client;
byte[] buffer = new byte[1024];
Worker(Socket socket) {
client = socket;
}
@Override
public void run() {
try {
BufferedInputStream bis = new BufferedInputStream(client.getInputStream());
BufferedOutputStream bos = new BufferedOutputStream(client.getOutputStream());
int len = 0;
while ((len = bis.read(buffer)) != -1) {
log.info(new String(buffer, 0, len, "UTF-8"));
bos.write(buffer, 0, len);
}
client.shutdownInput();
bos.flush();
client.shutdownOutput();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != client)
try {
client.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
try {
TestServer server = new TestServer(6687);
server.doAccept();
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们要模拟一个客户端使用FileChannel.transferTo发送文件到服务端,上面是一个简单的SockerServer服务端程序,做的事情也很简单,把收到文件后把文件内容再返回给客户端,下面再看下客户端:
/**
* sendfile在Java中的应用
*/
@Slf4j
public class SendFileTest {
private static File file = new File("D:\\Media\\test.txt");
public static void sendStream() {
Socket socket = null;
try {
socket = new Socket("127.0.0.1", 6687);
FileInputStream fis = new FileInputStream(file);
OutputStream os = socket.getOutputStream();
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
int len = 0;
while ((len = fis.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
os.flush();
socket.shutdownOutput();
log.info("发送完毕flush and shutdownOutput");
byte[] readBuf = new byte[1024];
is.read(readBuf);
log.info("OutputStream发送文件收到回复{}", new String(readBuf, "UTF-8"));
socket.shutdownInput();
log.info("读取回复完毕,shutdownInput");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (socket != null)
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void sendfile() {
try {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 6687));
FileInputStream fis = new FileInputStream(file);
FileChannel fileChannel = fis.getChannel();
// 从FileChannel直接transfer到SocketChannel,直接在内核态完成数据copy
fileChannel.transferTo(0, file.length(), socketChannel);
socketChannel.shutdownOutput();
ByteBuffer buffer = ByteBuffer.allocate(1024);
fileChannel.read(buffer);
log.info("sendfile发送文件收到回复{}", new String(buffer.array(), "UTF-8"));
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
sendStream();
sendfile();
}
}
客户端先后用了传统的socket OutputStream方法和FileChannel.transferTo方法发送文件,并显示服务端的返回。
参考
理论:
sendfile“零拷贝”、mmap内存映射、DMA - 简书 (jianshu.com)
什么是零拷贝?mmap与sendFile的区别是什么?_The Mamba Mentality的博客-CSDN博客_mmap和sendfile
linux零拷贝原理,RocketMQ&Kafka使用对比 - 云+社区 - 腾讯云 (tencent.com)
浅析Linux中的零拷贝技术 - 简书 (jianshu.com)
代码:
java 零拷贝-- MMAP,sendFile,Channel - 简书 (jianshu.com)
☕【Java深层系列】「并发编程系列」深入分析和研究MappedByteBuffer的实现原理和开发指南 - InfoQ 写作平台