平时我们经常用到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 -> 磁盘
一共经过了六次拷贝,其中①②③是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 -> 磁盘
一共经过了四次拷贝,其中①②是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 -> 磁盘
这个时候我们只需要三次拷贝就可以了,其中需要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 -> 磁盘
什么?逗我呢,和mmap什么区别?
额,系统调用次数减少了算吗?
就这?
别着急吗,这是linux内核2.4之前的流程,2.4之后并且DMA支持scatter-gather能力就不是这样了(怎么看支不支持?百度下)。
2.4之后并且DMA支持scatter-gather能力的传输过程是这样的。
read:PageCache <- 磁盘
write:PageCache -> 磁盘
只需要两次拷贝,如果你要问为什么没有代码运行效果?我想说上边那个就是支持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来进行拷贝。
}
有点意思,那能不能再减少拷贝次数呢?
不能!
问题
- 关于代码演示中为什么mmap和sendfile相对于FileChannel直接内存以及sendfile相对于mmap并没有那么明显呢?
答:和DMA硬件、数据大小有关系,我测试的机器比较垃圾,不一样的机器,差距可能就显现出来了。数据大小也有关系,可以试下不同的数据大小看看效果。 - 什么情况下可以用零拷贝(mmap、sendfile)技术?
答:数据在程序中并没有使用的时候,这个时候使用1、2就没有必要。如果程序中使用了数据,那肯定是没必要用零拷贝技术的。 - 如果程序中使用数据,用哪个合适?
答:程序中使用了数据,数据那肯定要在堆中了。前三种方法都可以达到目的,不过都不可避免的需要经过三次拷贝,效率可以自己试一下,三种应该没有什么大的区别。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。