零拷贝及JavaNIO封装

/ 默认分类 / 0 条评论 / 27浏览

1.背景介绍

零拷贝技术属于操作系统层面对传统IO进行优化而产生的新技术,说白了也就是新的IO的系统调用。上篇文章中我们介绍了Java中的NIO的三大组件,下面我们将来介绍下,java nio中是如何对零拷贝系统调用进行封装的,进而提高java的IO库操作性能。

2.零拷贝底层技术分析

2.1 传统IO操作流程

介绍零拷贝技术之前,我们需要先来了解下传统的IO技术在实现数据读取和拷贝的过程到底发生了什么。

这里我们需要区分一下,之前我们介绍的都是网络IO的多路复用技术,目的是为了解决更少的线程去管理和监控更多的客户端请求或数据接收等。这里我们介绍的则是用户进程对磁盘或者网卡数据的操作(写入或读取)的优化。

先来看下传统IO的流程,如下面我画了一个时序图,在这个过程中,当用户进程发起系统调用,cpu会一直被当前用户进程使用(切换到内核态之后),可以看到cpu在进行了多次数据拷贝,最后才是read调用的返回。

所以说,传统的IO操作下,cpu会被严重占用,因此我们使用一种新的技术来帮助cpu减轻负担。

2.2 优化1-cpu减负(DMA)

DMA(Direct Memory Access)是一种允许外设硬件设备(如网卡、磁盘控制器等)直接访问系统主内存的程序实现机制,无需 CPU 参与数据搬运。CPU 只需下达指令,DMA 控制器即可完成数据在内存与设备之间的传输,传输完成后通过中断通知 CPU。这极大减轻了 CPU 的负担,提高了数据传输效率,尤其适用于大数据量、高速设备的场景。
DMA 是实现零拷贝的关键硬件基础:

这样一来,引入了DMA之后的IO流程就是下面这样的,可以看到DMA帮助CPU完成了一部分数据拷贝工作,将数据从磁盘控制器缓冲区拷贝到了内核缓冲区。

在继续介绍零拷贝的技术之前,我们再来看下,引入了DMA之后,实际进行文件数据传输时候(数据读取和写入)的整个过程是什么样的。

一般这个过程涉及两个系统调用操作
read(file, tmp_buf, len);

write(socket, tmp_buf, len);

可以看到,实际进行了2次系统调用,4次上下文切换,4次数据拷贝。

零拷贝技术的核心就是减少上述上下文切换和数据拷贝的次数。 下面我们逐渐引入优化的方案。

零拷贝技术实现的方式通常有 2 种:

mmap + write

sendfile

2.3 优化2-减少CPU进行数据拷贝(mmap)

mmap() 系统调用通过建立用户空间与内核缓冲区的直接映射关系,避免了在两者之间进行数据复制的过程。这种方法消除了用户态和内核态之间的数据拷贝开销。

当应用程序调用mmap()时,DMA(直接内存访问)会先将磁盘数据加载到内核缓冲区。此时,应用程序和内核共享该缓冲区,避免了用户空间的数据拷贝。

随后,当应用程序调用write()时,内核直接将数据从共享缓冲区复制到 socket 缓冲区(这一过程仍在内核态,由 CPU 完成)。最后,DMA 将 socket 缓冲区的数据发送到网卡。

相比传统的read()+write()方式,mmap()减少了一次数据拷贝(即用户空间缓冲区的复制),但仍然需要 CPU 参与内核缓冲区到 socket 缓冲区的复制,并且涉及 4 次上下文切换(因为仍然需要两次系统调用:mmap()和write())。

虽然mmap()优化了部分数据拷贝,但并未完全实现零拷贝(仍有一次 CPU 参与的数据复制)。更进一步的优化(如sendfile())可以完全消除 CPU 的数据搬运,实现真正的零拷贝。

2.4 优化3-减少系统调用(使用sendfile系统调用)

在 Linux 2.1 内核中引入的 sendfile() 系统调用,专门用于高效地传输文件数据,其函数原型如下:

#include <sys/sendfile.h>  
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);  

优化点:

  1. 减少了系统调用次数
    • 传统方式(read() + write())需要两次系统调用,导致 4 次上下文切换。
    • sendfile() 只需 1 次系统调用,减少到 2 次上下文切换。
  2. 避免用户态数据拷贝
    • read() 会将数据从 内核缓冲区 → 用户缓冲区,write() 再将其从 用户缓冲区 → socket 缓冲区,涉及 两次 CPU 拷贝。
    • sendfile() 直接在内核态 将数据从 内核缓冲区 → socket 缓冲区,省去了用户空间的拷贝,仅剩 1 次 CPU 拷贝。(这一点和mmap的零拷贝实现有相同的效果)
  3. 整体数据拷贝流程(3 次)
    • DMA 将磁盘数据拷贝到 内核缓冲区(无需 CPU)。
    • CPU 将数据从 内核缓冲区 → socket 缓冲区(唯一 CPU 参与的部分)。
    • DMA 将数据从 socket 缓冲区 → 网卡(无需 CPU)。

局限性:

所以sendFile方式的零拷贝技术最终的IO流程就是类似下面的。

下面是简单的源码分析:

源码位置:

https://github.com/torvalds/linux/blob/master/include/linux/syscalls.h

https://github.com/torvalds/linux/blob/master/fs/read_write.c


//核心实现函数:执行文件数据从in_fd到out_fd的拷贝
static ssize_t do_sendfile(int out_fd, int in_fd, loff_t *ppos,
			   size_t count, loff_t max)
{
	struct inode *in_inode, *out_inode;               //输入文件和输出文件的inode结构指针
	struct pipe_inode_info *opipe;                    //指向pipe的结构体
	loff_t pos;                                       //输入文件的位置
	loff_t out_pos;                                   //输出文件的位置
	ssize_t retval;                                   //返回值
	int fl;                                           //标志位,传递给splice时的标志

	//获取输入文件,验证合法性
	CLASS(fd, in)(in_fd);                             //宏展开:将in_fd包装为fd结构
	if (fd_empty(in))                                 //输入fd是否为空
		return -EBADF;                                //错误:bad file descriptor
	if (!(fd_file(in)->f_mode & FMODE_READ))         //文件是否可读
		return -EBADF;
	if (!ppos) {
		pos = fd_file(in)->f_pos;                    //如果没传入offset,就用当前文件位置
	} else {
		pos = *ppos;
		if (!(fd_file(in)->f_mode & FMODE_PREAD))   //如果不是pread模式但传入了offset,返回错误
			return -ESPIPE;                         //非seekable
	}
	retval = rw_verify_area(READ, fd_file(in), &pos, count); //验证读区域是否合法
	if (retval < 0)
		return retval;
	if (count > MAX_RW_COUNT)
		count = MAX_RW_COUNT;                        //限制最大读取字节数

	//获取输出文件,验证合法性
	CLASS(fd, out)(out_fd);
	if (fd_empty(out))
		return -EBADF;
	if (!(fd_file(out)->f_mode & FMODE_WRITE))       //检查是否可写
		return -EBADF;
	in_inode = file_inode(fd_file(in));              //获取输入文件inode
	out_inode = file_inode(fd_file(out));            //获取输出文件inode
	out_pos = fd_file(out)->f_pos;                   //输出文件当前位置

	if (!max)
		max = min(in_inode->i_sb->s_maxbytes, out_inode->i_sb->s_maxbytes); //限制最大文件大小

	if (unlikely(pos + count > max)) {               //判断是否超出最大值
		if (pos >= max)
			return -EOVERFLOW;
		count = max - pos;
	}

	fl = 0;
#if 0
	//是否允许非阻塞模式(默认关闭,根据man文档说明)
	if (fd_file(in)->f_flags & O_NONBLOCK)
		fl = SPLICE_F_NONBLOCK;
#endif

	opipe = get_pipe_info(fd_file(out), true);       //检查输出是否是一个pipe
	if (!opipe) {
		//普通文件或套接字,使用do_splice_direct进行零拷贝传输
		retval = rw_verify_area(WRITE, fd_file(out), &out_pos, count);
		if (retval < 0)
			return retval;
		retval = do_splice_direct(fd_file(in), &pos, fd_file(out), &out_pos,
					  count, fl);
	} else {
		//输出是pipe的情况
		if (fd_file(out)->f_flags & O_NONBLOCK)
			fl |= SPLICE_F_NONBLOCK;

		retval = splice_file_to_pipe(fd_file(in), opipe, &pos, count, fl);
	}

	if (retval > 0) {
		add_rchar(current, retval);                  //增加当前进程读字节数
		add_wchar(current, retval);                  //增加当前进程写字节数
		fsnotify_access(fd_file(in));                //通知VFS:in被访问
		fsnotify_modify(fd_file(out));               //通知VFS:out被修改
		fd_file(out)->f_pos = out_pos;               //更新输出文件位置
		if (ppos)
			*ppos = pos;                             //更新输入位置
		else
			fd_file(in)->f_pos = pos;
	}

	inc_syscr(current);                              //增加系统调用计数(读)
	inc_syscw(current);                              //增加系统调用计数(写)
	if (pos > max)
		retval = -EOVERFLOW;
	return retval;
}

//sendfile系统调用入口(off_t版本,适用于32位系统)
SYSCALL_DEFINE4(sendfile, int, out_fd, int, in_fd, off_t __user *, offset, size_t, count)
{
	loff_t pos;                                       //内核内部使用的64位偏移量
	off_t off;                                        //用户态的32位偏移量
	ssize_t ret;

	if (offset) {
		if (unlikely(get_user(off, offset)))         //从用户空间获取offset值
			return -EFAULT;
		pos = off;
		ret = do_sendfile(out_fd, in_fd, &pos, count, MAX_NON_LFS);
		if (unlikely(put_user(pos, offset)))         //把新位置写回用户空间
			return -EFAULT;
		return ret;
	}

	return do_sendfile(out_fd, in_fd, NULL, count, 0); //无offset的调用
}

//sendfile64系统调用(支持loff_t偏移量,适用于64位大文件)
SYSCALL_DEFINE4(sendfile64, int, out_fd, int, in_fd, loff_t __user *, offset, size_t, count)
{
	loff_t pos;
	ssize_t ret;

	if (offset) {
		if (unlikely(copy_from_user(&pos, offset, sizeof(loff_t)))) //复制offset
			return -EFAULT;
		ret = do_sendfile(out_fd, in_fd, &pos, count, 0);
		if (unlikely(put_user(pos, offset)))         //写回offset
			return -EFAULT;
		return ret;
	}

	return do_sendfile(out_fd, in_fd, NULL, count, 0);
}

#ifdef CONFIG_COMPAT
//兼容系统调用(适用于32位用户程序在64位内核运行)
COMPAT_SYSCALL_DEFINE4(sendfile, int, out_fd, int, in_fd,
		compat_off_t __user *, offset, compat_size_t, count)
{
	loff_t pos;
	off_t off;
	ssize_t ret;

	if (offset) {
		if (unlikely(get_user(off, offset)))         //获取offset
			return -EFAULT;
		pos = off;
		ret = do_sendfile(out_fd, in_fd, &pos, count, MAX_NON_LFS);
		if (unlikely(put_user(pos, offset)))         //写回offset
			return -EFAULT;
		return ret;
	}

	return do_sendfile(out_fd, in_fd, NULL, count, 0);
}
#endif

2.5 优化4-终极方案(SG-DMA )

在支持 SG(scatter-gather) DMA 的系统中,sendfile() 可结合 splice() 技术,可以再次减少通过CPU把内核缓冲区的数据拷贝到socket缓冲区的过程。消除 CPU 拷贝,实现真正的 零拷贝(Zero-Copy)。

sendfile() 不再将数据复制到 socket 缓冲区,而是将内核缓冲区的描述符fd(内存地址、数据长度)传递给 socket 缓冲区。网卡的SG-DMA引擎根据描述符,直接从内核缓冲区读取数据并发送到网络。完全绕过 CPU 数据拷贝,仅需 2 次 DMA 传输(磁盘 → 内核缓冲区 → 网卡)。

所以这种最终的零拷贝技术方案真正做到了不需要CPU参与执行数据拷贝的程序。所以执行的模型可以理解为下面这样:

3.Java中的零拷贝封装

上面我们介绍了mmap和sendFile,java中的nio实际上也就是对这两个系统调用进行了封装是的nio包中可以直接使用零拷贝来提高io效率。

3.1 transferTo和transferFrom

transferTo方法用于将当前通道的数据传输到目标通道,其方法签名如下:

public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;

参数说明:

transferFrom方法用于从源通道获取数据到当前通道,其方法签名如下:

public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException;

参数说明:

这两个方法是 Java NIO 中最典型的零拷贝实现,底层在支持的操作系统上(如 Linux)调用的是 sendfile() 系统调用。

3.2 MappedByteBuffer(内存映射文件)

Java NIO也支持通过 MappedByteBuffer 实现内存映射文件,减少内核与用户空间的数据拷贝。

try (RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
     FileChannel channel = file.getChannel()) {

    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
    // 这里就可以你直接修改buffer内存数据来达到修改磁盘文件的目的了
    buffer.put(0, (byte) 'H');  // 直接修改内存映射区域
}

● 文件内容被直接映射到内存地址空间。

● 操作的是内存,但最终影响的是磁盘上的文件,并且过程中不需要进行数据拷贝。

● 适合处理超大文件或频繁访问文件的场景。

● 使用的是操作系统的 mmap() 技术。

3.3 使用示例分析

3.3.1 文件读写中使用零拷贝

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;

public class TransferToExample {
    public static void main(String[] args) throws Exception {
        //创建源文件的输入通道
        FileInputStream fis = new FileInputStream("source.txt");
        FileChannel sourceChannel = fis.getChannel();

        //创建目标文件的输出通道
        FileOutputStream fos = new FileOutputStream("target.txt");
        FileChannel targetChannel = fos.getChannel();

        //将源文件的数据从位置0开始,传输sourceChannel.size()个字节到目标通道
        long transferred = sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);

        //输出传输的字节数
        System.out.println("传输的字节数:" + transferred);

        //关闭资源
        sourceChannel.close();
        targetChannel.close();
        fis.close();
        fos.close();
    }
}






import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;

public class TransferFromExample {
    public static void main(String[] args) throws Exception {
        //创建源文件的输入通道
        FileInputStream fis = new FileInputStream("source.txt");
        FileChannel sourceChannel = fis.getChannel();

        //创建目标文件的输出通道
        FileOutputStream fos = new FileOutputStream("target.txt");
        FileChannel targetChannel = fos.getChannel();

        //将源通道的数据从位置0开始,传输sourceChannel.size()个字节到目标通道
        long transferred = targetChannel.transferFrom(sourceChannel, 0, sourceChannel.size());

        System.out.println("传输的字节数:" + transferred);

        //关闭资源
        sourceChannel.close();
        targetChannel.close();
        fis.close();
        fos.close();
    }
}










import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MappedByteBufferExample {
    public static void main(String[] args) throws Exception {
        //创建一个可读写的随机访问文件
        RandomAccessFile raf = new RandomAccessFile("mapped.txt", "rw");
        FileChannel fileChannel = raf.getChannel();

        //将文件从位置0开始,映射fileChannel.size()个字节到内存
        MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());

        //读取第一个字节
        byte firstByte = buffer.get(0);
        System.out.println("第一个字节原始值:" + (char) firstByte);

        //修改第一个字节内容(写操作会影响文件本身)
        buffer.put(0, (byte) 'H');

        //关闭资源
        fileChannel.close();
        raf.close();
    }
}

3.3.2 传统网络IO中使用零拷贝


import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class ZeroCopyFileServer {
    public static void main(String[] args) throws Exception {
        //创建 ServerSocketChannel 并绑定端口
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(9000));

        System.out.println("等待客户端连接...");

        //等待客户端连接
        SocketChannel socketChannel = serverChannel.accept();
        System.out.println("客户端已连接: " + socketChannel.getRemoteAddress());

        //打开要传输的文件
        FileChannel fileChannel = new FileInputStream("source.txt").getChannel();

        //零拷贝方式发送文件内容到 SocketChannel
        long position = 0;
        long size = fileChannel.size();
        while (position < size) {
            long transferred = fileChannel.transferTo(position, size - position, socketChannel);
            position += transferred;
        }

        System.out.println("文件传输完成,已发送 " + position + " 字节");

        //关闭通道
        fileChannel.close();
        socketChannel.close();
        serverChannel.close();
    }
}

3.3.3 JavaNIO中的Selector结合零拷贝

这个例子我是结合前面我们介绍的java NIO对linux多路复用IO模型的封装来编写的一个高性能的web服务处理程序示例,结合了零拷贝技术。

import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class ZeroCopySelectorServer {
    public static void main(String[] args) throws Exception {
        //打开 ServerSocketChannel 和 Selector
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);
        serverChannel.socket().bind(new InetSocketAddress(9000));

        Selector selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("服务器启动,监听端口 9000");

        while (true) {
            selector.select(); //阻塞直到至少有一个事件就绪
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();

            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove(); //必须移除,否则下次 select 会重复处理

                if (key.isAcceptable()) {
                    //有新连接到来
                    SocketChannel client = serverChannel.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_WRITE);

                    //附加文件通道信息,后续用于 transferTo
                    FileChannel fileChannel = new FileInputStream("source.txt").getChannel();
                    client.register(selector, SelectionKey.OP_WRITE, fileChannel);

                    System.out.println("新客户端连接:" + client.getRemoteAddress());
                }

                if (key.isWritable()) {
                    //客户端就绪,可以写数据
                    SocketChannel client = (SocketChannel) key.channel();
                    FileChannel fileChannel = (FileChannel) key.attachment();

                    if (fileChannel != null) {
                        long position = 0;
                        long size = fileChannel.size();

                        while (position < size) {
                            long sent = fileChannel.transferTo(position, size - position, client);
                            if (sent <= 0) break; //非阻塞通道可能一时不可写
                            position += sent;
                        }

                        System.out.println("完成文件传输,共发送 " + position + " 字节");

                        fileChannel.close();
                        key.cancel(); //关闭注册
                        client.close();
                    }
                }
            }
        }
    }
}

3.3 零拷贝注意事项

● 兼容性:不是所有平台都完全支持 sendfile() 或 mmap() 的零拷贝效果,某些 JVM 或操作系统可能会回退为普通拷贝。

● 资源释放:MappedByteBuffer 使用的内存不是 JVM 管理的堆内存,释放较慢,建议手动调用反射释放。

● 传输限制:transferTo() 在某些平台对单次传输字节数有限制(如最大2GB),大文件应分段处理。另外文件太大会导致完全占用了PageCache,也就是上述的内核缓冲区,这样导致其他数据无法正常使用PageCache.

4.总结

零拷贝技术核心就是减少系统调用造成的上下文切换,减少cpu执行耗时的数据拷贝任务。进而提高IO效率。测试显示,零拷贝技术比传统的IO技术在性能上提高了100%左右。

Java中很多中间件就是利用的零拷贝技术来提高效率,比如kafka。