Java高性能编程串讲-IO读写的那些细节

〇、前言(碎碎念)

磁盘的读写也同样是一个非常容易被忽略的点,在生产环境中也出现过由于磁盘读写代码编写不当造成的线上服务不可用,待会我会讲一下这个案例。磁盘是系统中最慢的IO之一,不当的编码对性能的影响非常大。

磁盘篇

一、JVM堆内内存和堆外内存

首先讲一下堆外内存,堆外内存是啥呢?比如我们new出来的对象,全部在JVM的堆内存中,我们写的绝大多数程序也都使用的是JVM的堆内存,当然堆内存在JVM中又被划分为很多块(Eden,S1,S2,Old等等),但这不是今天的重点,相信大家对堆内存还是非常熟悉的。那堆外内存是什么呢?就是非JVM管理的内存,是不是很神奇,Java是可以操作JVM内存以外的内存的,通过sun提供的UNSAFE类,可以通过UNSAFE.allocateDirect分配堆外内存(也可以用过ByteBuffer使用堆外内存),之所以叫UNSAFE类,是因为这部分内存需要我们自己管理,JVM不负责这块内存的管理(虽然FullGC对这块内存还是有效的),这块内存需要我们自己清理释放,使用起来没有JVM内存方便,但是堆外内存有非常多的优点是堆内存不具有的,其中非常重要的一点以及使用堆外内存的主要因素就是:堆外内存的IO速度优于堆内存的IO速度,但是缺点就是,分配堆外内存的速度是明显慢于堆内存的。

二、IO读写

传统做法

试想有这样一个应用:将本地磁盘中的一个文件通过网络发送给客户端。

习惯的想到使用InputStream和OutputStream,首先使用InputStream从本地磁盘读取文件,然后用一个buffer存在内存中,然后用OutputStream把buffer中的数据发送给客户端。

看起来是一个非常自然的过程,我们分析一下这个过程中的一些细节:数据从磁盘到我们的buffer都经历了些什么呢?

首先,我们调用InputStream的read方法的时候,read方法最终调用了JVM的native方法,而native方法中的read方法最终会调用系统调用read方法(linux自身的read方法),这个系统调用会导致系统从用户态切换到内核态,然后DMA(待会介绍DMA)把数据从磁盘搬到内核内存空间,然后CPU再将内核空间的数据拷贝到堆外用户内存,系统调用到这里结束,接下来的工作要交给JVM,所以需要从内核态切换为用户态,最后CPU还要将堆外内存拷贝进堆内存的buffer中,这时,我们才在buffer中拿到我们从磁盘读取的数据。

上面这个过程非常重要,仔细看一下,我们发现使用InputStream从磁盘中读取数据做了非常多的事情:

  • 一次磁盘读取
  • 两次内存拷贝(内核空间到堆外用户空间,堆外到JVM堆内)
  • 两次系统状态切换

系统转态切换是非常耗时的,需要保存当前转态的上下文,并载入要转换转态的上下文,如果频繁的切换系统转态带来的开销也将非常可观。

到现在数据才读到我们的堆内存中,我们接下来还要将buffer中的数据通过OutputStream发送给客户端,这时我们同样进行了两次内存拷贝(堆内到堆外,堆外到内核空间),两次系统转态切换,以及一次网络IO。所以我们一共进行了4次内存拷贝,4次系统转态切换。

怎么样,是不是贼费事,我们看看有没有改进办法,我们将数据千辛万苦读到buffer中,然后又千辛万苦从buffer中扔给网络IO,其实完全没有必要从内核中读到堆内,这一步是多余的。

mmap

为了优化这种情况,linux推出mmap系统调用代替read系统调用,该系统调用会将磁盘中的文件地址映射到内核空间中的地址,然后再将内核空间中的地址映射到用户空间中的一个地址,使得用户空间和内核空间共享一份数据,对于Java来说,JVM还会把堆外的用户空间映射到堆内存中,但是这是JVM的扩展行为,不属于mmap系统调用,所以JVM不是真正的共享,其中还是会发生拷贝。

Java中可以通过FileChannel#map方法调用mmap,通过使用mmap我们再看一下读取数据需要经过哪些过程:

  • 一次磁盘读取
  • 一次内存拷贝(内核空间和堆外用户空间共享了,没有拷贝,JVM从堆外拷贝到堆内)
  • 两次系统状态切换

这样,我们读取数据就节约了一次内存到内存的拷贝,但是发送给网络IO无法使用mmap,仍然需要两次内存拷贝。所以使用mmap代替read之后,我们需要3次内存拷贝,4次系统转态切换。

DMA(插个队)

我们继续之前,可以先看一下DMA。DMA(Direct Memory Access,直接内存访问)是个什么东西呢?这个东西就很牛逼了,它是专门用来给内存“搬数据”的,可以称得上是内存专用的数据搬运工。为什么需要它呢,因为CPU忙啊,如果要从磁盘中搬个几百兆的数据到内存中,如果只依靠CPU去寻址,然后去搬数据,那要搬到什么时候啊,况且CPU是个大忙人啊,不能把宝贵的CPU资源用来搬数据,那岂不是太大材小用了,所以就设计了DMA这个东西,DMA是主板上的一个专门的芯片,它也可以对内存以及各种IO设备进行寻址、读数据、写数据,并且效率比CPU还要高,有了DMA的存在,CPU接到一个搬数据的任务的时候,只需要告诉DMA(快给忙人让路,费德罗!),从哪个设备的哪个地址,搬多少数据到哪个设备的哪个地址,一共三个参数,起始地址,数据长度,目标地址,然后DMA会获取一部分的总线控制权(注意是一部分哦,不是独占总线,不然CPU又没事情做了,DMA会和CPU时分复用总线,据一些大神实验,STM32某款CPU是 CPU:DMA 为3:2的比例分配总线占用时间的,不同的设备可能设置不一样吧,但是一定是同时使用的,不是某个设备独占),DMA完成数据搬运工作之后,会给CPU发送中断,通知CPU任务已经完成了。所以前面提到的从磁盘搬数据到内核空间,没错,就是DMA干的。现在的DMA已经更加厉害了,不仅仅能从磁盘搬数据到内存,也能直接作为其他外设IO和内存交换数据的桥梁。

求教DMA与CPU的同时工作问题

sendFile

针对这么多的拷贝和系统转态切换,实在繁琐了,能不能进一步优化呢?DMA不正好是干这个的吗?核心思想就是现在不是要把磁盘的数据发送到网络IO吗,那我们直接使用DMA将磁盘的数据搬到内核空间,然后CPU将内核空间中的数据搬到网络IO的buffer中,再使用DMA把数据从buffer搬到网络IO,不就可以了吗?sendFile就这样诞生了,这样,我们只需要一次系统调用,就可以同时完成读写两个功能,这样,我们一共只需要一次内存拷贝,两次转态切换。sendFile的本质其实是直接沟通两个不同的外设IO,通过DMA将内存作为中继站。

Java中也可以使用sendFile,通过FileChannel#transferTo方法。这个方法需要传入另一个FileChannel,意味着将这两个FileChannel链接起来,数据可以在这两个FileChannel中透传。

零拷贝

好了,可以说说零拷贝了,刚刚说了,CPU反复在不同的内存空间中来回搬运数据是低效的、不必要的,所以我们希望尽可能的在数据传输过程中,不要出现内存拷贝,这就是所谓的零拷贝,sendFile和mmap都实现了零拷贝,如果直接使用C语言确实就是这样,如果使用Java语言,mmap还是会有一次内存拷贝,堆外用户空间和堆内空间的互相拷贝,而sendFile即使在Java中也是没有内存拷贝的。

三、Java中的优化

Java已经对InputStream和OutputStream这些传统IO进行了优化,对于File的读写底层都已经改用了FileChannel,但是我们还是推荐自己使用FileChannel,那样会更加明白一些。

FileChannel还提供了write和read方法,这两个方法底层做了优化,有人测试,这两个方法已经能够满足绝大多数的使用需求了,只有对性能要求极其苛刻的情况下,或者在一些特殊场景下,才用去考虑是否使用map和transferTo方法。

四、总结

其实还有一些问题,如果磁盘文件非常大,内存装不下,传统io、mmap、sendfile在Java中会分别怎么表现呢?

  • 如果使用传统IO读一个内存装不下的文件,会抛出OOM异常(因为你要先new一个buffer啊)。
  • mmap本质是对地址的映射,当文件过大时,mmap只会将文件的一部分数据映射到内存,当要访问的数据超出范围时,mmap会根据一些算法对内存中的数据和磁盘进行置换,有点类似换页算法。所以不会有OOM异常,但是如果访问文件跨度跳跃很大很频繁的话,mmap的性能会明显下降。
  • sendFile的话,由于它是直接沟通两个IO,如果内存不够,会分批搬运数据,所以也不会有OOM异常。

sendFile的缺点也非常明显,那就是数据只能透传,不能由程序介入访问数据,因为整个数据的搬运过程全部是在内核态下完成的,如果是透传数据,例如客户端需要下载一个服务器上的文件,那么可以使用sendFile,但是如果程序要对数据进行访问之后再给客户端,sendFile是做不到的。

大家有兴趣可以研究一下市面上流行的消息中间件框架,看看各种不同的框架是如何设计存储结构以及对应使用什么方法读写磁盘的,相信你会有很大的收获。

五、用例分析

之前出现一个事情,机器表现如下:

  • 一些普通的接口调用时长突然变长,一个很简单的接口要数秒甚至数十秒的时间才能返回。
  • CPU比平时稍高,但是不易察觉。
  • 内存占用稳定,没有变化。
  • ps看了服务进程还在。
  • 业务没有抛出任何异常

到机器上打印一下日志,发现出现了大量的FullGC,由于机器上部署了不止一个服务,所以也没有马上确定内存是否超出JVM的堆内存,但是无疑是由于FullGC引起的服务假死。

看服务log,发现在出现这个现象的时间点上,有用户调用了一些批量下载任务,这个任务会批量返回一批PDF,这些PDF在另外的服务器上,本服务器会先去上游服务上下载这些PDF,然后再打包,最后返回给用户。

由于用户连续点击了几次大规模的下载任务,后端不断下载PDF后,堆内存逐渐消耗殆尽,并触发FullGC,但是FullGC之后又腾出一些空间,然后又下载了数个PDF,然后没过多久又触发了FullGC。

后来同事优化了,原因是同事一直使用的是InputStream将数据读到了堆内存中,用堆内存进行压缩操作,这次的任务量特别的多,超出了堆内存的限制,所以引起了程序的假死。

今天分析之后呢,相信你一定知道了,这个下载任务其实是从一个网络IO到另一个网络IO的数据透传,完全可以使用sendFile,这样,不用担心堆内存不足的问题,也不会引起任何GC,并且效率要高得多。

当然,中间有打包操作,可以借助Java对命令的操作,调用shell命令进行数据打包,中间存一下磁盘,整个过程还是只使用了sendFile以及内核内存空间。

这里JVM参数其实设计的也不合理(使用的是什么收集器我忘了,大概是CMS吧),JVM默认的是在98%的时间内没有释放2%的内存,才会报OOM异常,这里我们应该设置一个FullGC频率参数,超过一定频率就抛出OOM异常,让请求返回。

[参考](里面有帮助理解的好看的图片,建议大家看看):

java NIO 缓存区之内核空间、用户空间和虚拟地址

sendfile:Linux中的”零拷贝”