深入剖析java零拷贝

随着互联网的发展,WEB 应用已经占据了我们能看到每一个角落。其实说白了web应用分为两部分:静态内容和动态内容。而目前越来越流行前后端分离,把静态内容放到ngnix中,把动态内容还放到tomcat或者其他动态容器中。之所以大家逐渐抛弃apache和tomcat去做静态资源容器是有他的原因。其中一个奥秘就是本文要介绍的重点: 0-copy技术。

web 容器在加载静态内容过程无非就是: 响应浏览器URL --> 从磁盘读取URL映射的静态文件 --> 通过socket发送给浏览器。过程很简单,web程序在整个过程中只是起到了低效的中介而已。但是如果使用不恰当会引发多次数据无效的反复拷贝和CPU的多次低效介入。 我们就简单对比一下tomcat和ngnix在这个过程中到底有什么性能区别,才导致了我们逐渐抛弃tomcat做静态资源容器呢。

tomcat容器加载发送静态资源:

  1. 容器根据url通过系统调用read(byte)
  2. 操作系统 问磁盘索取数据,磁盘DMA把数据写入操作系统缓存
  3. 操作系统把内核态缓存写入用户态 tomcat缓存
  4. tomcat 把拿到的数据 调用 socket_send(byte) 把数据重新拷贝到内核态buffer中
  5. 操作系统 把操作系统的buffer 调用 网卡DMA写入网卡缓存中
  6. 网卡发送数据


ngnix 容器加载发送静态资源:

  1. 容器根据url通过系统调用send_file(url)系统调用
  2. 操作系统发送指令,磁盘DMA加载数据到操作系统缓存
  3. 操作系统发送数据到网卡DMA缓存
  4. 网卡发送数据

明眼人已经看出猫腻。tomcat的读取磁盘数据和发送数据都涉及到 多次相同数据在 用户态和内核态之间来回拷贝,更关键的是在来回拷贝过程中,会进行多次CPU的干预和用户态内核态的上下文切换。这样本来简简单单的数据读取发送变得极其昂贵。 于是如何减少发送数据过程中CPU的干预和减少copy次数成了人们追求的方向。
那么会有人问,是不是只有C/C++才能实现0-copy呢? JAVA是不是天生就不能呢? 可以很明确的回答这个充满着歧视的问题。 0-Copy跟语言没关系。这个是操作系统层面有没有封装对应的系统调用接口供上层应用调用而已(linux在2.1内核版本开始提供sendfile系统调用,在2.4版本引入splice更加彻底的0-copy系统调用) 。 其实不管是C/C++写出来的程序还是JAVA写出来程序,在操作系统看来是一样的,都是用户态的程序。 而JDK也在很早版本(1.4)就引入了很多NIO的内容帮助我们来调用操作系统的系统调用来完成0-copy,比如java.nio.channels.FileChannel 的 transferTo()他底层就会调用跟ngnix一样的sendfile系统调用实现0-copy。
本文就开始图文并茂的形式讲解JAVA 4种不同读取本地磁盘通过sokcet发送出去的形式。来深入剖析0-copy在性能上优势。

方法论

首先解释什么是0-拷贝:在进行文件操作的时候,尽量避免CPU将数据从一块缓存拷贝到领一块缓存的技术。来解放CPU处理更多有意义的任务。在减少CPU参与不必要的缓存拷贝路上,不同操作系统有不同的系统调用。而java通过调用系统调用实现0-copy。
在操作系统优化0-copy的路上,基本上路线是: 【减少用户态和内核态的数据拷贝和切换】 --> 【减少内核态内部缓存的拷贝】,下面我们通过四种不同的JAVA的IO操作,来解释这个路线。
我们先把四种JAVA IO操作的方法论讲解一下,然后开始用实际代码进行对比四种不同类型IO的实际性能。大家可以根据实际应用场景择优而用。

方法一、使用JVM heap 进行磁盘文件拷贝到socket发送出去的过程

伪代码如下:

byte buffer[] = new byte[file_size];
file.read(buffer);
socket.send(buffer);

内存流转图如下:

JVM heap形式拷贝文件

如上图所示: 使用java heap的形式发送磁盘文件到socket的过程如下:

  1. 在JVM heap申请一个 buffer数组
  2. java调用read函数读取文件,这个操作会进行一次 <上下文切换>(从用户态到内核态的切换)。底层会调用操作系统的 sys_read()系统调用进行磁盘操作。磁盘会通过DMA把要读取的数据 [拷贝到操作系统的缓存中]
  3. 随着sys_read()的返回, 操作系统会在进行一次 [内存拷贝], 把内核态的文件缓存拷贝到JVM的 缓存中。同时会触发一次 <上下文切换> (从内核态到用户态的切换), 从内核态切换到用户态。这一步结束,文件已经加载到JVM 的heap中了。
  4. java调用 socket.send(), 底层会调用操作系统的系统调用 socket.sys_send(), 这一操作会进行一次 <上下文切换>(从用户态到内核态的切换),并完成一次 [内存拷贝], 把JVM内部的缓存数据拷贝到 内核态的socket缓存中。
  5. 随着socket.sys_send() 的返回, 又会触发一次 <上下文切换> (从内核态切换到用户态)。然后操作系统会等到 socket send buffer到配置的缓存区大小以后,把数据发送出去。[备注1]


上下文切换流转图如下:

JVM heap形式拷贝文件的内核态和用户态切换过程

  1. 用户态 -> 内核态: 系统调用 sys_read()。
  2. 内核态 -> 用户态: sys_read return 。
  3. 用户态 -> 内核态: socket.sys_send()。
  4. 内核态 -> 用户态: socket.sys_send() return。

内存数据拷贝流程如下:

  1. 磁盘DMA数据 -> 操作系统缓存
  2. 操作系统缓存 -> JVM用户态缓存 [CPU参与]
  3. JVM用户态缓存 -> 操作系统Socket系统缓存 [CPU参与]
  4. 操作系统Socket系统缓存 -> 网络DMA设备

通过上面完成的过程,其实我们要的只是 把磁盘上的某个文件通过网络发送出去而已。 然而,不得不发生四次数据拷贝: 其中两次还要浪费宝贵CPU介入拷贝的,还有两次是通过DMA来把硬件数据拷贝到系统缓存。 而且用户态,内核态来回切换两次,发生了4次上下文切换。这样的拷贝太浪费资源。

而且,因为我们使用的 java heap,有如下几方面弊端

  1. java heap申请过大(百兆以上)内存空间非常昂贵。 在申请大空间的时候,如果此时jvm没有足够连续的空间可以用,会触发一次JVM full gc。
  2. 即使申请成功了大空间内存,后面再JVM GC过程中,会来回多次拷贝移动这个大的内存空间(应为为了内存碎片化整理),这个过程增加了系统负担。
  3. 因为是jvm 的 heap,外部系统很难访问得到(jvm heap内部数据地址经常随着gc而改变),但是对外缓存的地址是固定的。

方法二、使用 native memory(direct memory)进行磁盘文件拷贝到socket发送出去的过程

内存数据流转图如下:

使用 Native memory(DirectByteBuffer) 实现磁盘向socket拷贝文件 使用java native memory进行文件发送的数据流转和系统上下文切换和使用 java heap的一模一样,这里就不在赘述。 但是还是存在一些性能上的差异。主要有如下差异: 如下几点待考证,也请大家指正

  1. 传统的流式IO,读取单元是 byte为单位。通俗理解: read(byte[2000]), 底层其实是一个循环读取2000次,每次一个字节的读取(虽然有字符流,但是其实底层还是一个个字节读取的,只是上面用了一定的封装而已)。
  2. NIO采用 FileChannel的形式, 底层是一个块一个块的读取。 这也就是为什么 NIO在定义byteBuffer的时候,最好是磁盘block size的整数倍。 比如是4KB的整数倍等,这样是最快的。
  3. 传统IO 读取类似于 xml的SAX解析器一样,一旦读取过,就不能再seek到之前的位置。 但是FileChannel不一样,他可以seek到之前的任意位置。
  4. 功能上 NIO有 同步IO的 FIleChannel, 还支持异步IO AsynchronousFileChannel(支持事件回调)。

但是使用 directMemory也有他的不足地方,那就是 回收对外空间非常麻烦,而且jvm对对外空间的监控和统计做的也不是很好。而且使用对外内存的时候,很容易遇到一个坑就是,假设机器内存1GB,那么如果你把JVM的 -MX900MB的话,那么只有100MB给 对外内存和系统内存使用了,如果你使用的框架底层需要使用很多对外内存的话,这个时候回报 full GC(及时JVM heap很空闲)。
关于这块我写一个专门的文章供大家参考

方法三、使用mmap 系统调用实现 发送文件到网络

伪代码:

MappedByteBuffer mappedByteBuffer = srcFileChannel.map(0, file_length);
socket.send(mappedByteBuffer, 0, file_length);

数据流转图如下:
使用MMAP(memory mapped)内存映射 实现磁盘向socket拷贝文件的 内核态和用户态切换过程

如上图,具体的流程详述如下:

  1. java 调用 mmap(), 底层进行系统调用 sys_mmap(), 触发一次 <上下文切换>(从用户态到内核态的切换)。 磁盘上的数据通过DMA [拷贝到 内核缓冲区]
  2. 随着 sys_mmap()的返回, 操作系统会把这段内核缓存区 与 java程序内存共享,这样不用把内核缓存往用户空间拷贝。 然后触发一次 <上下文切换>(从内核态到用户态的切换)
  3. java 调用socket.send(), 底层进行系统调用 socket.sys_send() 触发一次 <上下文切换>(从用户态到内核态的切换)。并完成一次 [内存拷贝],这个内存拷贝是从内核态的数据缓存拷贝到socket缓存。
  4. 随着 socket.sys_send()的返回,操作系统会等到 socket send buffer到配置的缓存区大小以后,把数据发送出去。


上下文切换流转如下:


  1. 用户态 -> 内核态: 系统调用 sys_mmap()。
  2. 内核态 -> 用户态: sys_mmap() return 。
  3. 用户态 -> 内核态: socket.sys_send()。
  4. 内核态 -> 用户态: socket.sys_send() return。

内存数据拷贝流程如下:

  1. 磁盘DMA数据 -> 操作系统缓存
  2. 操作系统缓存 -> 操作系统Socket系统缓存 [CPU参与]
  3. 操作系统Socket系统缓存 -> 网络DMA设备


理论上来说 内存映射比DirectMemory 省掉一次 CPU的拷贝,应该会更快一点。但是实际测试下来并非如此(具体测试可以看本文下面的测试结果)。至于原因这里先列出一段官方描述,大家可以先有个认识,后面会有专门一篇文章讲解 DirectMemroy和内存映射的讲解。

The mapped byte buffer returned by this method will have a position of zero and a limit and capacity of size; its mark will be undefined. The buffer and the mapping that it represents will remain valid until the buffer itself is garbage-collected.
A mapping, once established, is not dependent upon the file channel that was used to create it. Closing the channel, in particular, has no effect upon the validity of the mapping.


翻译如下

mappedByteBuffer 会返回一个从0到limit的内存映射,这种情况下 mark 没有定义,这个 mappingbuffer会一直存在,直到GC回收。映射一旦建立,就不依赖FileChannel了,即使你关闭了FileChannel也不影响mappedBuffer。
Many of the details of memory-mapped files are inherently dependent upon the underlying operating system and are therefore unspecified. The behavior of this method when the requested region is not completely contained within this channel's file is unspecified. Whether changes made to the content or size of the underlying file, by this program or another, are propagated to the buffer is unspecified. The rate at which changes to the buffer are propagated to the file is unspecified.

这段话意思不原文翻译了,大概意思是说:

当你使用mmap时,你可能会遇到一些隐藏风险。例如,当你的程序map了一个文件,但是当这个文件被另一个进程截断(truncate)时, write系统调用会因为访问非法地址而被SIGBUS信号终止。SIGBUS信号默认会杀死你的进程并产生一个coredump,如果你的服务器这样被中止了。这会导致一些风险。

For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of kilobytes of data via the usual read and write methods. From the standpoint of performance it is generally only worth mapping relatively large files into memory.

翻译过来就是

对弈大部分操作系统来说,mapped memory 在内存中 比传统读写几十千字节的数据更耗时。从性能的角度来看,通常只需要将相对较大的文件映射到内存中

所以在java生态中, 如果你想使用mappedByteBuffer的话,最好先跟传统的 读写先做一下性能对比,看是不是真的适于你的场景。

方法四、使用transferTo系统调用实现 发送文件到网络

伪代码如下


srcFileChannel.transferTo(socketChannel, 0, file_length);

数据流转图如下:

使用transferTo(linux 2.2以后的 sendfile系统调用)实现磁盘向socket拷贝文件过程

如上图,具体的流程详述如下:

  1. java 调用 transferTo(), 底层进行系统调用 sendfile(), 这个操作会进行一次 <上下文切换> (用户态->内核态), 然后磁盘通过DMA把数据拷贝到 内核态的 pageCache中。
  2. 随着系统调用sendfile()的返回, 会发生一次 [内核态的内存拷贝], 内核态的 pageCache数据拷贝到 socket 缓存中。 然后进行一次 <上下文切换> (内核态 -> 用户态)。socket 缓存回到操作系统指定的额度以后发数据发出去。


上下文切换流转如下:
transferTo 的用户态内核态切换过程


  1. 用户态 -> 内核态: 系统调用 sendfile()。
  2. 内核态 -> 用户态: sendfile() return 。

内存数据拷贝流程如下:

  1. 磁盘DMA数据 -> 操作系统缓存
  2. 操作系统缓存 -> 操作系统Socket系统缓存 [CPU参与]
  3. 操作系统Socket系统缓存 -> 网络DMA设备

注意虽然 sendfile() 系统调用 跟 使用 mmap方法的 cpu拷贝次数是一样的,但是有着如下的优点:

  1. sendfile() 比 mmap() 少两次上下问切换
  2. 调用sendfile时,如果有其它进程截断了文件,假设我们没有设置任何信号处理程序,sendfile调用仅仅返回它在被中断之前已经传输的字节数,errno会被置为success。如果我们在调用sendfile之前给文件加了锁,sendfile的行为仍然和之前相同,我们还会收到RT_SIGNAL_LEASE的信号。

同时sendfile()系统调用有如下缺点:

  1. sendfile()是能用于 磁盘文件发送socket的场景,不能用于 磁盘文件拷贝到磁盘或者其他地方的场景。 (原因是:在代表输入文件的描述符in_fd和代表输出文件的描述符out_fd之间传送文件内容(字节)。描述符out_fd必须指向一个套接字,而in_fd指向的文件必须是可以mmap的。这些局限限制了sendfile的使用,使sendfile只能将数据从文件传递到套接字上,反之则不行)

其他linux底层方法:sendfile() 系统调用 进一步优化

通过2.1版本以上的linux我们已经可以使用sendfile把 内核态 -> 用户态的 cpu拷贝次数降为0了, 但是内核态还是有一次 cpu拷贝发生。于是 linux2.6版本以后,借助硬件的帮助,对sendfile又做了一次改进,使得 cpu拷贝次数降为了0, 如下图所示。 linux 2.6以后优化了sendfile过程实现彻底 0拷贝

借助于硬件,当DMA把磁盘数据拷贝到pageCache中以后, 通过 sendfile()系统调用,会把pageCache的文件描述符(类似于指针),数据长度等参数传给socket缓冲区。然后 socket DMA会根据这两个参数直接去pageCache中取数据,发送到网络中去。

这种方法使得CPU彻底不用参与数据的拷贝。

其他linux底层方法:splice() 系统调用实现 磁盘文件到文件的拷贝

sendfile上面说了,是能把磁盘数据发送到网络。那么如果只是 磁盘两个文件之间的拷贝怎么办呢。在2.6.17版本之前,我们只能通过传统的读写方法。在之后的版本,引入了 splice。
但是JAVA 并没有开发对应的API去使用linux这个新功能。 在java中 你可以使用transferTo实现本地磁盘的拷贝(底层使用的mmap系统调用),也可以把磁盘文件发送到网络中去(底层使用的sendfile系统调用)。 并没有调用splice的地方。 这可能跟java是跨平台有关系吧,并非所有的操作系统都支持splice。另外splice在刚推出不仅,linux操作系统也不支持splice的调用了。可能linux觉得splice更适合给那些硬件驱动厂商实现更合适吧。


到此我们把 IO的所有java接口和底层的系统调用都讲解完毕了。我们画一个表格对比一下把。

编号 IO操作 上下文切换次数 cup参数据拷贝总次数 cup参与 内核态 <-> 用户态数据拷贝次数 cup参与 内核态 <-> 内核态数据拷贝次数
1 java heap字节数组拷贝文件方法 4 2 2 0
2 java directByteuffer 4 2 2 0
3 mapped memory方式 4 1 0 1
4 transferTo(底层调用sendfile) 2 1或者0次 0 1或者0次

对于java来说 0-copy的意思是: 内核态 <-> 用户态之间 cpu参与拷贝数据的次数是0,那么 mapped memory和 transferTo都是0拷贝的一种。
如果站在整个流程中 严格意义的0-copy的话,只有 使用了linux的 最新型sendfile才真正实现了0次数据拷贝。

用代码形式对比java四种不同类型IO操作的性能




为了省代码行数,这里做了很多偷懒操作,比如没有使用 try-catch-finally 来关闭文件流等标准代码,大家心里面直到就行。

实验准备

  • 下面所有代码中会涉及到读取一个文件 /Users/wanli.zhou/Downloads/dump.o2hprof.index, 这个文件是一个 290MB的 jvm dump的二进制文件。大家要是想运行测试代码的话,可以自行生成一个相同大小的文件就行。
  • 程序执行环境: 2.2GHZ的 i7处理器,16GB (1600MHZ)的 mac pro的电脑上。

实验一:通过读取本地磁盘文件对比三种不类型的 IO操作

源码地址

编号 IO操作 备注
1.1 inputStream.read(byte[FILE_TOTAL_SIZE]) 把数据加载到JVM的heap缓存中,一次性读取
1.2 inputStream.read(byte[200000]) 把数据加载到JVM的heap缓存中,分批次读取
1.3 FileChannel.read(byteBuffer[200000]) NIO的方式分批读取数据
2.1 FileChannel.read(directByteBuffer[FILE_TOTAL_SIZE]) 使用堆外内存一次性读取文件
2.2 FileChannel.read(directByteBuffer[200000]) 使用对外内存分批读取
3.1 FileChannel.map(mappedByteBuffer[FILE_TOTAL_SIZE]) 使用MMap方式一次净读取文件
3.2 FileChannel.map(mappedByteBuffer[200000]) 使用MMap方式分批读取文件

1. 把文件读取到 JVM 内部 heap中

1.1 对于 纯粹的 使用JVM的 byte[]一次性读取数据,
    public static void jvmBufferIO() throws IOException {
        //在JVM内部有一块缓存
        long start = System.currentTimeMillis() ;

        FileInputStream in = new FileInputStream(FILE_PATH);
        byte jvmBuffer[] = new byte[in.available()];
        in.read(jvmBuffer);
        in.close();

        long end = System.currentTimeMillis() - start;
        System.out.println("jvmBufferIO Costs " + end);
    }
1.2 对于 纯粹的 使用JVM的 byte[200000] 多次分批读取数据,
    public static void jvmBufferByShardIO() throws IOException {
        //在JVM内部有一块缓存
        long start = System.currentTimeMillis() ;
        FileInputStream in = new FileInputStream(FILE_PATH);
        int size = in.available();
        byte jvmBuffer[] = new byte[200000];
        for(int length = 0; (length = in.read(jvmBuffer)) > 0;){
            //do nothing
        }
        long end = System.currentTimeMillis() - start;
        System.out.println("jvmBufferIO Costs " + end);
    }
1.3 使用NIO的FileChannel分批读取数据

    public static void jvmBufferByShardWithNIO() throws IOException {
        //在JVM内部有一块缓存
        long start = System.currentTimeMillis() ;
        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);

        ByteBuffer byteBuffer = ByteBuffer.allocate(200000);
        for(int length = 0; (length = fileChannel.read(byteBuffer)) > 0;){
            byteBuffer.clear();
            //do nothing
        }
        long end = System.currentTimeMillis() - start;
        System.out.println("jvmBufferIO Costs " + end);
    }

2 把数据加载到native memory中

2.1 使用堆外内存一次性读取文件
    public static void directBufferIO() throws IOException {
        long start = System.currentTimeMillis();

        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);
        ByteBuffer directBuffer = ByteBuffer.allocateDirect((int)fileChannel.size());
        fileChannel.read(directBuffer);
        fileChannel.close();

        long end = System.currentTimeMillis() - start;
        System.out.println("directBufferIO costs " + end);

    }
2.2 对外内存分批次读取文件
    public static void directBufferByShardIO() throws IOException {
        long start = System.currentTimeMillis();
        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);
        int size = (int) fileChannel.size();
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(200000);
        for(int length = 0; (length = fileChannel.read(directBuffer)) > 0;){
            //do nothing
        }
        long end = System.currentTimeMillis() - start;
        System.out.println("directBufferIO costs " + end);

    }

3.1 使用mappedMemory 读取文件

    public static void mappedMemoryIO() throws IOException {
        long start = System.currentTimeMillis();

        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);
        MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
        fileChannel.close();

        long end = System.currentTimeMillis() - start;
        System.out.println("mappedMemoryIO costs " + end);

    }

4 性能测试报告

为了防止程序第一次运行过程中, 内存页(Page cache)中没有该文件的缓存,我们分别记录第一次运行和 后面几次运行的平均时间,来尽量屏蔽掉操作系统缓存对测试的影响。

编号 IO操作 第一次执行时间 后面10次平均执行时间
1.1 inputStream.read(byte[FILE_TOTAL_SIZE]) 556ms 472ms
1.2 inputStream.read(byte[200000]) 176ms 92ms
1.3 FileChannel.read(byteBuffer[200000]) 107ms 85ms
2.1 FileChannel.read(directByteBuffer[FILE_TOTAL_SIZE]) 282 285ms
2.2 FileChannel.read(directByteBuffer[200000]) 21ms 0ms(您没有看错是 0)
3.1 FileChannel.map(mappedByteBuffer[FILE_TOTAL_SIZE]) 35ms 20ms
3.2 FileChannel.map(mappedByteBuffer[200000]) 太慢了十几秒还没出结果 太慢了十几秒还没出结果

实验总结:如下

  1. 在都是用 java heap的情况下, NIO的面向块 读取的 FileChannel要比传统的 一个一个字节读取的快。
  2. native memory 比 java heap读取速度要快非常多。 一方面是申请内存要比jvm heap申请内存快很多。另一方面少了native和jvm heap之间的拷贝数据。
  3. 在mac操作系统中, mmap和 direct memory 性能基本差不多。
  4. 使用mmap的方法读取文件的话,整个文件映射比分批映射快很多。

悖论: 为什么 directByteBuffer在后面10次读取的时候耗时为0呢?

实验二:通过本地磁盘文件copy对比三种不类型的 IO操作

源码地址

编号 IO操作 备注
1.1 inputStream.read(byte[FILE_TOTAL_SIZE]); outputStream.write(byte[FILE_TOTAL_SIZE]) 把数据一次性加载到JVM的heap缓存中,然后写入新文件
1.2 inputStream.read(byte[200000]); outputStream.write(byte[200000]) 把数据分批加载到JVM的heap缓存中,分批写入新文件
1.3 FileChannel.read(byteBuffer[200000]); FileChannel.write(byteBuffer[200000]) 使用NIO的bytebuffer 分批读取写入新文件
2.1 FileChannel.read(directByteBuffer[FILE_TOTAL_SIZE]); FileChannel.write(directByteBuffer[FILE_TOTAL_SIZE]) 使用directByteBuffer 一次性写入读取写入新文件
2.2 FileChannel.read(directByteBuffer[200000]); FileChannel.write(directByteBuffer[200000]) 使用directByteBuffer 分批写入读取写入新文件
3 FileChannel.map(mappedByteBuffer); FileChannel.write(mappedByteBuffer) 使用MMap内存映射,拷贝文件
4 FileChannel.transferTo(); 使用java的 transferTo(底层还是使用操作系统的MMap跟 3 本质上是一样的,因为linux的sendFile只能把磁盘文件发送到socket,不能完成本地磁盘发磁盘。)

1.1 使用 JVM heap的 byte[] 读取数据然后写文件

    public static void regularCopy() throws IOException {
        long start = System.currentTimeMillis();
        //Open
        FileInputStream fileInputStream = new FileInputStream(FILE_PATH);

        //Read
        byte[] buffer = new byte[fileInputStream.available()];
        fileInputStream.read(buffer);

        //Write
        FileOutputStream fileOutputStream = new FileOutputStream(DEST_FILE_DIRECTORY + "/" + "regularCopy.file");
        fileOutputStream.write(buffer);

        long end = System.currentTimeMillis() - start;
        fileOutputStream.close();
        fileInputStream.close();
        System.out.println("regularCopy cost " + end);
    }

1.2 使用 JVM heap的 byte[] 分批读取数据然后写文件

public static void regularCopyByShard() throws IOException {
        long start = System.currentTimeMillis();
        //Open
        FileInputStream fileInputStream = new FileInputStream(FILE_PATH);
        int size = fileInputStream.available();

        FileOutputStream fileOutputStream = new FileOutputStream(DEST_FILE_DIRECTORY + "/" + "regularCopy.file");
        //Read
        byte[] buffer = new byte[200000];
        for(int length = 0 ; (length =  fileInputStream.read(buffer)) > 0; ){
            //Write
            fileOutputStream.write(buffer);
        }

        long end = System.currentTimeMillis() - start;
        fileOutputStream.close();
        fileInputStream.close();
        System.out.println("regularCopy cost " + end);
    }

1.3 使用 JVM heap的 byteBuffer 分批读取数据然后写文件

    public static void regularCopyByShardNIO() throws IOException {

        long start = System.currentTimeMillis() ;
        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);
        FileChannel destFileChannel = FileChannel.open(Paths.get(DEST_FILE_DIRECTORY).resolve("mapperMemoryCopy.file"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        int size = (int) fileChannel.size();

        ByteBuffer byteBuffer = ByteBuffer.allocate(200000);
        for(int length = 0; (length = fileChannel.read(byteBuffer)) > 0;){
            byteBuffer.flip();
            destFileChannel.write(byteBuffer);
        }
        long end = System.currentTimeMillis() - start;
        fileChannel.close();
        destFileChannel.close();
        System.out.println("regularCopy NIO cost " + end);
    }

2.1 使用 direct byte(native memory) buffer一次性复制文件

    public static void directMappedCopy() throws IOException {
        long start = System.currentTimeMillis();
        // Open
        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));
        ByteBuffer directByteBuffer = ByteBuffer.allocateDirect((int) fileInChannel.size());

        //direct memory
        fileInChannel.read(directByteBuffer);
        directByteBuffer.flip();
        //write
        FileChannel destFileChannel = FileChannel.open(Paths.get(DEST_FILE_DIRECTORY).resolve("mapperMemoryCopy.file"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        destFileChannel.write(directByteBuffer);

        fileInChannel.close();
        destFileChannel.close();

        long end = System.currentTimeMillis() - start;

        System.out.println("directMappedCopy cost " + end);
    }

2.2 使用 direct byte(native memory) buffer分批复制文件

public static void directMappedByShardCopy() throws IOException {
        long start = System.currentTimeMillis();
        // Open
        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));
        FileChannel destFileChannel = FileChannel.open(Paths.get(DEST_FILE_DIRECTORY).resolve("mapperMemoryCopy.file"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);

        ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(200000);
        int size = (int) fileInChannel.size();
        for(int length = 0; (length = fileInChannel.read(directByteBuffer)) > 0;){
            //direct memory
            directByteBuffer.flip();
            //write
            destFileChannel.write(directByteBuffer);
        }

        fileInChannel.close();
        destFileChannel.close();

        long end = System.currentTimeMillis() - start;

        System.out.println("directMappedCopy cost " + end);
    }

3 使用mapped memory 内存映射拷贝文件

    public static void mapperMemoryCopy() throws IOException {
        long start = System.currentTimeMillis();
        // Open
        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));

        //Mapped memory
        MappedByteBuffer mapedBuffer = fileInChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileInChannel.size());

        //        Path dstPath = Files.createFile(Paths.get(DEST_FILE_DIRECTORY).resolve("mapperMemoryCopy.file"));
        FileChannel destFileChannel = FileChannel.open(Paths.get(DEST_FILE_DIRECTORY).resolve("mapperMemoryCopy.file"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        destFileChannel.write(mapedBuffer);

        fileInChannel.close();
        destFileChannel.close();

        long end = System.currentTimeMillis() - start;

        System.out.println("mapperMemoryCopy cost " + end);
    }

4 使用 transfer to 进行文件拷贝

其实 transferTo底层源码会做判断,如果是本地磁盘文件拷贝的话,会使用MappedMemory技术做拷贝,所以性能上跟直接使用mappedByteBuffer一样。

    public static void transferToCopy() throws IOException {
        long start = System.currentTimeMillis();

        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));
        FileChannel destFileChannel = FileChannel.open(Paths.get(DEST_FILE_DIRECTORY + "/" + "mapperMemoryCopy.file"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
        fileInChannel.transferTo(0, fileInChannel.size(), destFileChannel);

        long end = System.currentTimeMillis() - start;
        System.out.println("transferToCopy cost " + end);
    }

5 性能测试结果

编号 IO操作 第一次执行时间 后面10次平均执行时间
1.1 inputStream.read(byte[FILE_TOTAL_SIZE]); outputStream.write(byte[FILE_TOTAL_SIZE]) 1216ms 941ms
1.2 inputStream.read(byte[200000]); outputStream.write(byte[200000]) 392ms 336ms
1.3 FileChannel.read(byteBuffer[200000]); FileChannel.write(byteBuffer[200000]) 26ms 0ms(对,没有写错,是0)
2.1 FileChannel.read(directByteBuffer[FILE_TOTAL_SIZE]); FileChannel.write(directByteBuffer[FILE_TOTAL_SIZE]) 549ms 495ms
2.2 FileChannel.read(directByteBuffer[200000]); FileChannel.write(directByteBuffer[200000]) 23ms 0ms(对,没有写错,是0)
3 FileChannel.map(mappedByteBuffer); FileChannel.write(mappedByteBuffer) 290ms 262ms
4 FileChannel.transferTo(); 290ms 253ms

其实 transferTo底层源码会做判断,如果是本地磁盘文件拷贝的话,会使用MappedMemory技术做拷贝,所以性能上跟直接使用mappedByteBuffer一样。

结论:

  1. 同样适用jvm heap的情况下, 使用nio方式比bio方式拷贝文件快很多。
  2. 在mac系统下, direct memory方式拷贝文件比 mmap方式拷贝文件快。 而且 direct memory情况下,分批读写比整个文件一次性读写快很多。
  3. java的 transferTo在 磁盘对磁盘文件拷贝的时候,底层源码使用的就是mmap方式(可以jdk源码,transferTo底层会判断,如果磁盘对网络的拷贝那么调用sendfile系统调用,否则直接走mmap)

悖论: 为什么 jvm 的heap和 native memory的 directByteBuffer 耗时一样?

实验三: 本地文件通过socket发送测试

server端代码, Client端代码

编号 IO操作 备注
1.1 inputStream.readTotal(byte[FILE_TOTAL_SIZE]); socket.writeTotal(byte[FILE_TOTAL_SIZE]) 使用 JVM heap的 byte[] 一次性读取数据然后一次性发送socket
1.2 inputStream.readShard(byte[10000]); socket.writeShard(byte[10000]) 使用 JVM heap的 byte[] 分批读取数据然后分批发送socket
1.3 FileChannel.read(byteBuffer[200000]); socket.write(byteBuffer[200000]) 使用 JVM heap的 byteuffer 分批读取数据然后分批发送socket
2.1 FileChannel.readTotal(directByteBuffer[FILE_TOTAL_SIZE]); socket.writeTotal(directByteBuffer[FILE_TOTAL_SIZE]) 使用 direct byte buffer 一次性读取文件发送socket
2.2 FileChannel.readShard(directByteBuffer[200000]); socket.writeShard(directByteBuffer[200000]) 使用 direct byte buffer 分批次性读取文件发送socket
3 FileChannel.map(mappedByteBuffer); socket.write(mappedByteBuffer) 使用 mapped memory 内存映射后发送 socket
4 FileChannel.transferTo(); 使用 sendFile系统调用发送文件

1.1 使用 JVM heap的 byte[] 一次性读取数据然后一次性发送socket


    public static void traditionalCopyToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);
        long start = System.currentTimeMillis();

        //Opend file
        FileInputStream fileInputStream = new FileInputStream(FILE_PATH);

        //copy file to jvm
        byte[] jvmBuffer = new byte[fileInputStream.available()];
        fileInputStream.read(jvmBuffer);

        //send to socket
        ByteBuffer byteBuffer = ByteBuffer.wrap(jvmBuffer);
        byteBuffer.flip();
        socketChannel.write(byteBuffer);

        fileInputStream.close();
        socketChannel.close();
        long end = System.currentTimeMillis() - start;
        System.out.println("traditionalCopyToSocket " + end);
    }

1.2 使用 JVM heap的 byte[] 分批读取数据然后分批发送socket

public static void traditionalCopyByShardToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);
        long start = System.currentTimeMillis();

        //Opend file
        FileInputStream fileInputStream = new FileInputStream(FILE_PATH);

        int available = fileInputStream.available();

        //copy file to jvm
        byte[] jvmBuffer = new byte[200000];
        for(int length = 0; (length = fileInputStream.read(jvmBuffer)) > 0;){
            //send to socket
            ByteBuffer byteBuffer = ByteBuffer.wrap(jvmBuffer);
            byteBuffer.flip();
            socketChannel.write(byteBuffer);
        }

        fileInputStream.close();
        socketChannel.close();
        long end = System.currentTimeMillis() - start;
        System.out.println("traditionalCopyByShardToSocket " + end);
    }

1.3 使用 JVM heap的 byteuffer 分批读取数据然后分批发送socket

public static void traditionalCopyByShardToSocketNIO(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);
        long start = System.currentTimeMillis();

        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH), StandardOpenOption.READ);

        ByteBuffer byteBuffer = ByteBuffer.allocate(200000);
        for(int length = 0; (length = fileChannel.read(byteBuffer)) > 0;){
            byteBuffer.flip();
            socketChannel.write(byteBuffer);
        }

        fileChannel.close();
        socketChannel.close();
        long end = System.currentTimeMillis() - start;
        System.out.println("traditionalCopyByShardToSocketNIO " + end);
    }

2.1 使用 direct byte buffer 一次性读取文件发送socket

    public static void directMappedCopyToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);

        long start = System.currentTimeMillis();
        // Open
        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));
        ByteBuffer directByteBuffer = ByteBuffer.allocateDirect((int) fileInChannel.size());

        directByteBuffer.flip();
        socketChannel.write(directByteBuffer);

        fileInChannel.close();
        socketChannel.close();

        long end = System.currentTimeMillis() - start;
        System.out.println("directMappedCopyToSocket cost " + end);
    }

2.2 使用 direct byte buffer 分批次性读取文件发送socket

    public static void directMappedCopyByShardToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);

        long start = System.currentTimeMillis();
        // Open
        FileChannel fileInChannel = FileChannel.open(Paths.get(FILE_PATH));
        int size = (int) fileInChannel.size();
        ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(200000);
        for(int length = 0 ; (length = fileInChannel.read(directByteBuffer)) > 0;){
            directByteBuffer.flip();
            socketChannel.write(directByteBuffer);
        }

        fileInChannel.close();
        socketChannel.close();

        long end = System.currentTimeMillis() - start;
        System.out.println("directMappedCopyByShardToSocket cost " + end);
    }

3 使用 mapped memory 内存映射后发送 socket

    public static void mappedMemoryCopyToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);

        long start = System.currentTimeMillis();

        //Open file
        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH));

        //mapped memory system call
        MappedByteBuffer mapedBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
        mapedBuffer.flip();
        //write to socket
        socketChannel.write(mapedBuffer);

        socketChannel.close();
        fileChannel.close();
        long end = System.currentTimeMillis() - start;
        System.out.println("mappedMemoryCopyToSocket " + end);

    }

4 使用 sendFile系统调用发送文件

    public static void transferToCopyToSocket(ServerSocketChannel serverSocketChannel) throws IOException {
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(true);

        long start = System.currentTimeMillis();

        //open file
        FileChannel fileChannel = FileChannel.open(Paths.get(FILE_PATH));

        fileChannel.transferTo(0, fileChannel.size(), socketChannel);

        long end = System.currentTimeMillis() - start;
        fileChannel.close();
        socketChannel.close();
        System.out.println("transferToCopyToSocket " + end);

    }

5 性能测试结果

编号 IO操作 第一次执行时间 后面10次平均执行时间
1.1 inputStream.readTotal(byte[FILE_TOTAL_SIZE]); socket.writeTotal(byte[FILE_TOTAL_SIZE]) 566ms 566ms
1.2.1 inputStream.readShard(byte[10000]); socket.writeShard(byte[10000]) 215ms 200ms
1.2.2 inputStream.readShard(byte[200000]); socket.writeShard(byte[200000]) 165ms 116ms
1.3 FileChannel.read(byteBuffer[200000]); socket.write(byteBuffer[200000]) 13ms 0ms
2.1 FileChannel.readTotal(directByteBuffer[FILE_TOTAL_SIZE]); socket.writeTotal(directByteBuffer[FILE_TOTAL_SIZE]) 215ms 200ms
2.2 FileChannel.readShard(directByteBuffer[200000]); socket.writeShard(directByteBuffer[200000]) 16ms 0ms(对, 是0)
3 FileChannel.map(mappedByteBuffer); socket.write(mappedByteBuffer) 16ms 0ms
4 FileChannel.transferTo(); 16ms 10ms

结论:

  1. jvm heap情况下 NIO比 BIO发送文件快非常多。
  2. 磁盘发网络情况下, mmap方式和 sendfile方式 耗时一样。

悖论:

  1. 磁盘->网络的文件拷贝, 跟 磁盘->磁盘的文件拷贝 同样接口耗时不一样。 其中 mmaped方法最明显。

备注

备注1 如何修改linux操作系统的 socket的 buffer大小


# 我们可以通过下面这个来查看系统的 socket 接受缓冲区大小
sysctl -a | grep rmem
# 我们可以通过下面这个来查看系统的 socket 发送缓冲区大小
sysctl -a | grep wmem

如何更改缓存区大小

# linux 2.4 以后
http://www.man7.org/linux/man-pages/man7/tcp.7.html

  tcp_rmem (since Linux 2.4)
              This is a vector of 3 integers: [min, default, max].  These
              parameters are used by TCP to regulate receive buffer sizes.
              TCP dynamically adjusts the size of the receive buffer from
              the defaults listed below, in the range of these values,
              depending on memory available in the system.


cat /proc/sys/net/ipv4/tcp_rmem

4096 87380 4194304
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
慷慨打赏