文件传输零拷贝的Java实现

零拷贝的概念介绍

“Zero-copy” describes computer operations in which the CPU does not perform the task of copying data from one memory area to another.
零拷贝表示:在计算机操作的过程中,CPU不需要为数据在内存之间的拷贝消耗资源。而它通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间(User Space)而直接在内核空间(Kernel Space)中传输到网络的方式。

操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

用户空间:普通应用程序使用的虚拟内存空间称为用户空间,用户空间中的代码运行在较低的特权级别上,只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,也不能直接访问内核空间和硬件设备,以及其他一些具体的使用限制。

内核空间:一部分为核心软件,即是kernel,也称作内核空间,Linux 操作系统和驱动程序运行在内核空间。

DMA(Direct Memory Access):是一种不经过CPU而直接从内存存取数据的数据交换模式。在DMA模式下,CPU只须向DMA控制器下达指令,让DMA控制器来处理数据的传送,数据传送完毕再把信息反馈给CPU,这样就很大程度上减轻了CPU资源占有率,可以大大节省系统资源。

零拷贝技术

普通文件一次读写涉及到四次数据文件的拷贝、四次用户模式和内核模式之间的上下文切换。
第一次复制:DMA从磁盘读取文件内容存储在内核空间的缓冲区
第二次复制:数据从内核空间缓冲区复制到用户空间缓冲区
第三次复制:数据从用户空间缓冲区复制到socket特定缓冲区(内核缓冲区)
第四次复制:数据从内核缓冲区传递至协议引擎

在linux 2.4及以上版本的内核中(如linux 6或centos 6以上的版本),开发者修改了socket buffer descriptor,使网卡支持 gather operation,通过kernel进一步减少数据的拷贝操作。这个方法不仅减少了上下文切换,还消除了和CPU有关的数据拷贝。

使用零拷贝技术后,文件读写只需要两次拷贝。把普通文件的读写的第二次和第三次拷贝省去。
第一次复制:DMA从磁盘读取文件内容存储在内核空间的缓冲区
第二次复制:数据从内核缓冲区传递至协议引擎

Zero Copy的模式中,避免了数据在用户空间和内核空间之间的拷贝,避免消耗CPU周期和内存带宽,从而提高了系统的整体性能。Linux中的sendfile()以及Java NIO中的FileChannel.transferTo()方法都实现了零拷贝的功能,而在Netty中也通过在FileRegion中包装了NIO的FileChannel.transferTo()方法实现了零拷贝。

零拷贝的使用场景

考虑从文件读取并通过网络将数据传输到另一个程序的情况。(静态内容的Web应用程序,FTP服务器,邮件服务器等等)
Kafka、Netty等都有实际的应用场景。

【引用Netty中的零拷贝】【Netty的零拷贝体现在三个方面: 1. Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。 2. Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。 3. Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。】

零拷贝的Java代码实现,我这里选择了一个5.1G的文件,

第一种方法如下:

    // 27650
    public static void copyFileChannel(String from, String to) throws IOException {

        FileChannel fromChannel = new RandomAccessFile(from, "rw").getChannel();
        FileChannel toChannel = new RandomAccessFile(to, "rw").getChannel();

        long position = 0;
        long count = fromChannel.size();
        System.out.println(count);

        fromChannel.transferTo(position, count, toChannel);

        fromChannel.close();
        toChannel.close();
    }

居然只复制了2G, 看下transferTo 的源代码,

public long transferTo(long var1, long var3, WritableByteChannel var5) throws IOException {
        this.ensureOpen();
        if (!var5.isOpen()) {
            throw new ClosedChannelException();
        } else if (!this.readable) {
            throw new NonReadableChannelException();
        } else if (var5 instanceof FileChannelImpl && !((FileChannelImpl)var5).writable) {
            throw new NonWritableChannelException();
        } else if (var1 >= 0L && var3 >= 0L) {
            long var6 = this.size();
            if (var1 > var6) {
                return 0L;
            } else {
                int var8 = (int)Math.min(var3, 2147483647L);
                if (var6 - var1 < (long)var8) {
                    var8 = (int)(var6 - var1);
                }
 
                long var9;
                if ((var9 = this.transferToDirectly(var1, var8, var5)) >= 0L) {
                    return var9;
                } else {
                    return (var9 = this.transferToTrustedChannel(var1, (long)var8, var5)) >= 0L ? var9 : this.transferToArbitraryChannel(var1, var8, var5);
                }
            }
        } else {
            throw new IllegalArgumentException();
        }
    }

红色部分 ,会把长度设置为2147483647L也就是大概2GB的大小
所以需要对FileChannel.size()返回值进行判断,当它返回值大于0时始终要执行transferTo方法
因为transferTo单次只能处理2gb左右的长度,同时计算position偏移量

修改为另外一种方法:

public static void copyFileChannel1(String from, String to) throws IOException {

        try (FileInputStream is = new FileInputStream(from);
                FileChannel in = is.getChannel();
                FileOutputStream os = new FileOutputStream(to);
                FileChannel out = os.getChannel()) {
            long position = 0; //这里必须为long,不能为int,不然下面 position += count 会出错 !!!
            long size = in.size();
            while (0 < size) {
 
                long count = in.transferTo(position, size, out);
                System.out.println("count=" + count);
                if (count > 0) {
                    position += count;
                    size -= count;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

这个方法可以将5.1G的文件复制完全,时间大概 42728 毫秒

普通流文件复制代码如下:

public static void copyFileStream(String from, String to) throws IOException {
        FileInputStream input = new FileInputStream(from);
        FileOutputStream output = new FileOutputStream(to);

        byte[] b = new byte[1024];
        int n = 0;
        while ((n = input.read(b)) != -1) {
            output.write(b, 0, n);
        }

        input.close();
        output.close();
    }

这个方法可以将5.1G的文件复制完全,时间大概 84367 毫秒

 

posted @ 2021-01-05 16:49  南极山  阅读(1171)  评论(0)    收藏  举报