1
头图

平时我们经常用到IO流传输数据、比如操作文件经常用到FileInputStream来读取数据,然后进行Socket传输、或写到别的文件。但是有真正了解IO流内部的原理吗,这样操作数据性能是最好的吗?

一个简单的文件拷贝也能跟零拷贝扯上关系吗?带着这样的疑问,我们来看看今天的文章。

0. 准备

对IO的操作一般就是从文件与文件之间、文件与网络之间,本篇文章围绕文件 -> 文件之间的数据传输,来一探流传输之间的原理及性能问题,窥探正确的流传输姿势(文件与网络之间传输同理,本文为了方便选择了文件与文件之间传输)。

首先请我们今天的主角登场

root@iZmd9nortmn8maZ:/tmp/file# ll
-rw-r--r--   1 root root 21M Jul  1 01:09 私密文件.zip   # 没错,正是在下,听说我经常被频繁的传输?

1. FileInputStream

首先向我们走来的是FileInputStream以及FileOutputStream。
我们平时的操作是先把文件循环读取到固定大小的byte数组,写入到FileOutputStream输出流,直到读完为止。

    FileInputStream inputStream = new FileInputStream("/tmp/file/私密文件.zip");
    FileOutputStream outputStream = new FileOutputStream("/tmp/file/学习资料/私密文件.zip");

    byte[] b = new byte[1024 * 1024];

    int read;
    long start = System.currentTimeMillis();
    while ((read = inputStream.read(b)) != -1) {
        outputStream.write(b, 0, read);
    }
    System.out.println("普通文件流传输用时:" + (System.currentTimeMillis() - start));
普通文件流传输用时:33ms

深入研究read和write源码,这两个方法分别调用了native方法readBytes和writeBytes,查看navtive方法,在io_util.c中的readBytes和writeBytes,从内核读取和写入数据的时候,都会创建一个直接内存区域,用来作为堆缓冲区和内核缓冲区之间的中转。

    // 大概是这样,简化了下
    buf = malloc(len); // 创建一块内存区域
    nread = IO_Read(fd, buf, len); // 读取数据到内存区域
    (*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf); # 内存复制到java堆

也就是说数据之间的传输是这样的。

read:堆 <- 直接内存 <- PageCache <- 磁盘
write:堆 -> 直接内存 -> PageCache -> 磁盘
image.png
一共经过了六次拷贝,其中①②③是read,④⑤⑥是write。

由于我们在程序中并没有使用到该数据,所以说,这个文件在一顿copy操作之后,又原封不动的回到了磁盘,所以整个过程有很多不必要的copy。

既然这样,那我们就先把③④这个复制到堆的步骤给省略了。

2. FileChannel

FileChannel是一种通道技术,用于读取、写入、映射和操作文件,数据不会直接传输,需要结合ByteBuffer才可以,channel可以通过getChannel获取。
ByteBuffer有堆buffer和直接内存buffer,因为我们要省略③④步骤,所以在这里要使用直接内存buffer。

    FileInputStream inputStream = new FileInputStream("/tmp/file/私密文件.zip");
    FileChannel inChannel = inputStream.getChannel();

    FileOutputStream outputStream = new FileOutputStream("/tmp/file/学习资料/私密文件.zip");
    FileChannel outChannel = outputStream.getChannel();

    ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024); // 直接内存buffer

    long l = System.currentTimeMillis();
    while (inChannel.read(bb) != -1) {
        bb.flip();
        outChannel.write(bb);
        bb.clear();
    }
    System.out.println("channel直接内存buffer传输用时:" + (System.currentTimeMillis() - l));
channel直接内存buffer传输用时:18ms

此时是通过直接内存来传输数据的,所以传输过程是这样的。

read:直接内存 <- PageCache <- 磁盘
write:直接内存 -> PageCache -> 磁盘
image.png

一共经过了四次拷贝,其中①②是read,③④是write。

这个时候我们发现其实②③这一步也没必要,那能不能把②③这个步骤也省掉呢。

下面就是零拷贝的探索了。

3. mmap

mmap是一种虚拟映射技术,调用该方法会在内存中创建一个对该文件的映射,数据并没有被拷贝,操作数据时面向的是PageCache。

    FileInputStream fileInputStream = new FileInputStream("/tmp/file/私密文件.zip");
    FileChannel channel = fileInputStream.getChannel();
    
    FileOutputStream outputStream = new FileOutputStream("/tmp/file/学习资料/私密文件.zip");
    FileChannel channel1 = outputStream.getChannel();

    MappedByteBuffer map = channel.map(READ_ONLY, 0, channel.size()); // 单次最大可传输2G,超出可分批

    long start = System.currentTimeMillis();
    channel1.write(map);
    System.out.println("mmap用时:" + (System.currentTimeMillis() - start));
mmap用时:16ms

传输速度更快了,此时的传输过程是这样的。

read:PageCache <- 磁盘
write:PageCache -> PageCache -> 磁盘
image.png

这个时候我们只需要三次拷贝就可以了,其中需要CPU参与的拷贝仅需要一次。

经过一系列的拷贝缩减,我们的想法也越来越大胆了,能不能再减少拷贝次数?
嗯,想法确实大胆,不过也不是不可以。

4. sendfile

sendfile是一个在linux内核2.1才出现的一个函数,专门用来文件之间的传输。这种传输只存在于内核之间,所以不需要用户态内核态之间的切换。减少了不必要的上下文切换的时间。
废话不多说,上代码。

    FileInputStream inputStream = new FileInputStream("/tmp/file/私密文件.zip");
    FileChannel inChannel = fileInputStream.getChannel();

    FileOutputStream outputStream = new FileOutputStream("/tmp/file/学习资料/私密文件.zip");
    FileChannel outChannel = copyStream.getChannel();

    long start = System.currentTimeMillis();
    
    inChannel.transferTo(0 , inChannel.size(), outChannel); // 单次最大传输2G,超出可分批
    
    System.out.println("sendfile用时:" + (System.currentTimeMillis() - start));
sendfile用时:14ms

是不是更快了,此时传输过程是这样的。

read:PageCache <- 磁盘
write:PageCache -> PageCache -> 磁盘
image.png

什么?逗我呢,和mmap什么区别?
额,系统调用次数减少了算吗?

就这?
别着急吗,这是linux内核2.4之前的流程,2.4之后并且DMA支持scatter-gather能力就不是这样了(怎么看支不支持?百度下)。

2.4之后并且DMA支持scatter-gather能力的传输过程是这样的。

read:PageCache <- 磁盘
write:PageCache -> 磁盘
image.png

只需要两次拷贝,如果你要问为什么没有代码运行效果?我想说上边那个就是支持scatter-gather DMA的运行效果,那为什么没有不支持scatter-gather DMA的运行效果呢?测试的机器环境就是支持,不怪我。

那java中是使用mmap还是sendfile?支不支持我还要自己判断?
大胆使用sendfile就好了,关于其他的事?代码为你做好了。

// sendfile 
public long transferTo(long position, long count, WritableByteChannel target) throws IOException
{
    ...

    // Attempt a direct transfer, if the kernel supports it
    if ((n = transferToDirectly(position, icount, target)) >= 0) // 先使用sendfile
        return n;

    // Attempt a mapped transfer, but only to trusted channel types
    if ((n = transferToTrustedChannel(position, icount, target)) >= 0) // 不支持sendfile则使用mmap
        return n;

    // Slow path for untrusted targets
    return transferToArbitraryChannel(position, icount, target); // mmap仅支持目标channel为file和socket的,不支持的类型则用ByteBuffer来进行拷贝。
}

有点意思,那能不能再减少拷贝次数呢?
不能!

问题

  1. 关于代码演示中为什么mmap和sendfile相对于FileChannel直接内存以及sendfile相对于mmap并没有那么明显呢?
    答:和DMA硬件、数据大小有关系,我测试的机器比较垃圾,不一样的机器,差距可能就显现出来了。数据大小也有关系,可以试下不同的数据大小看看效果。
  2. 什么情况下可以用零拷贝(mmap、sendfile)技术?
    答:数据在程序中并没有使用的时候,这个时候使用1、2就没有必要。如果程序中使用了数据,那肯定是没必要用零拷贝技术的。
  3. 如果程序中使用数据,用哪个合适?
    答:程序中使用了数据,数据那肯定要在堆中了。前三种方法都可以达到目的,不过都不可避免的需要经过三次拷贝,效率可以自己试一下,三种应该没有什么大的区别。


_btl
7 声望2 粉丝

码农