Nio 堆内存,堆外内存,零拷贝

一、操作系统相关基础

  • 在传统的文件 IO 操作中,我们都是调用操作系统提供的底层标准 IO 系统调用函数 read()、write() ,此时调用此函数的进程(在 JAVA 中即 java 进程)由当前的用户态切换到内核态,然后 OS 的内核代码负责将相应的文件数据读取到内核的 IO 缓冲区,然 后再把数据从内核 IO 缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次 IO 操作。

至于为什么要多此一举搞一个内核 IO 缓冲区把原本只需一次拷贝数据 的事情搞成需要 2 次数据拷贝呢? 我想学过操作系统或者计算机系统结构的人都知道,这么做是为了减少磁盘的 IO 操作,为了提高性能而考虑的,因为我们的程序访问一般都带有局部性,也就是所 谓的局部性原理,在这里主要是指的空间局部性,即我们访问了文件的某一段数据,那么接下去很可能还会访问接下去的一段数据,由于磁盘 IO 操作的速度比直接 访问内存慢了好几个数量级,所以 OS 根据局部性原理会在一次 read() 系统调用过程中预读更多的文件数据缓存在内核 IO 缓冲区中,当继续访问的文件数据在缓冲区中时便直接拷贝数据到进程私有空间,避免了再次的低 效率磁盘 IO 操作。

  • BufferedInputStream 减少系统调用

JAVA 虚拟机内部便会调用 OS 底层的 read()系统调用完成操作,如上所述,在第二次调用 in.read() 的时候可能就是从内核缓冲区直接返回数据了(可能还有经过 native 堆做一次中转,因为这些函数都被声明为 native,即本地平台相关,所以可能在 C 代码中有做一次中转,如 win32 中是通过 C 代码从 OS 读取数据,然后再传给 JVM 内存)。既然如此,JAVA 的 IO 包中为啥还要提供一个 BufferedInputStream 类来作为缓冲区呢。关键在于四个字,“系统调用”!当读取 OS 内核缓冲区数据的时候,便发起了一次系统调用操作(通过 native 的 C 函数调用),而系统 调用的代价相对来说是比较高的,涉及到进程用户态和内核态的上下文切换等一系列操作,所以我们经常采用如下的包装:

FileInputStream in = new FileInputStream("D:\\java.txt"); 
BufferedInputStream buf_in = new BufferedInputStream(in);  
buf_in.read(); 

这样一来,我们每一次 buf_in.read() 时候,**BufferedInputStream 会根据情况自动为我们预读更多的字节数据到它自己维护的一个内部字节数组缓冲区中,这样我们便可以减少系统调用次数,从而达到其缓冲区的目的。所以要明确 的一点是 BufferedInputStream 的作用不是减少 磁盘 IO 操作次数(这个 OS 已经帮我们做了),而是通过减少系统调用次数来提高性能的。** 同理 BufferedOuputStream , BufferedReader/Writer 也是一样的。在 C 语言的函数库中也有类似的实现,如 fread(),这个函数就是 c 语言中的缓冲 IO,作用与 BufferedInputStream() 相同.

二、与传统 I/O 流相比,NIO 的 HeapByteBuffer 有什么优势?

  • 开始讲 NIO 之前,了解为什么会有 NIO,相比传统流 I/O 的优势在哪,它可以用来做什么等等的问题,还是很有必要的。

传统流 I/O 是基于字节的,所有 I/O 都被视为单个字节的移动;而 NIO 是基于块的,大家可能猜到了,NIO 的性能肯定优于流 I/O。没错!其性能的提高 要得益于其使用的结构更接近操作系统执行 I/O 的方式:通道和缓冲器。我们可以把它想象成一个煤矿,通道是一个包含煤层(数据)的矿藏,而缓冲器则是派送 到矿藏的卡车。卡车载满煤炭而归,我们再从卡车上获得煤炭。也就是说,我们并没有直接和通道交互;我们只是和缓冲器交互,并把缓冲器派送到通道。通道要么 从缓冲器获得数据,要么向缓冲器发送数据。(这段比喻出自 Java 编程思想)

三、内存映射文件 MappedByteBuffer(和 DirectByteBuffer 不同,少了将数据拷贝到 OS 内核缓冲区这一步)

  • 内存映射文件和之前说的 标准 IO 操作最大的不同之处就在于它虽然最终也是要从磁盘读取数据,但是它并不需要将数据读取到 OS 内核缓冲区,而是直接将进程的用户私有地址空间中的一 部分区域与文件对象建立起映射关系,就好像直接从内存中读、写文件一样,速度当然快了。为了说清楚这个,我们以 Linux 操作系统为例子,看下图:

图片.png
null

image.png

此图为 Linux 2.X 中的进程虚拟存储器,即进程的虚拟地址空间,如果你的机子是 32 位,那么就有 2^32 = 4G 的虚拟地址空间,我们可以看到图中有一块区域: “Memory mapped region for shared libraries” ,这段区域就是在内存映射文件的时候将某一段的虚拟地址和文件对象的某一部分建立起映射关系,此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这 段代码内的虚拟地址时,触发了缺页异常,这时候 OS 根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,当有操作第 N 页数据的时候重复这样的 OS 页面调度程序操作。注意啦,原来内存映射文件的效率比标准 IO 高的重要原因就是因为少了把数据拷贝到 OS 内核缓冲区这一步

  • java 中提供了 3 种内存映射模式,即:只读 (readonly)、读写 (read_write)、专用 (private) ,

对于 只读模式来说,如果程序试图进行写操作,则会抛出 ReadOnlyBufferException 异 常;第二种的读写模式表明了通过内存映射文件的方式写或修改文件内容的话是会立刻反映到磁盘文件中去的,别的进程如果共享了同一个映射文件,那么也会立即 看到变化!而不是像标准 IO 那样每个进程有各自的内核缓冲区,比如 JAVA 代码中,没有执行
IO 输出流的 flush()或者 close() 操作,那么对文件的修改不会更新到磁盘去,除非进程运行结束;最后一种专用模式采用的是 OS 的“写时拷贝”原则,即在没有发生写操作的情况下,多个进程之 间都是共享文件的同一块物理内存(进程各自的虚拟地址指向同一片物理地址),一旦某个进程进行写操作,那么将会把受影响的文件数据单独拷贝一份到进程的私 有缓冲区中,不会反映到物理文件中去。

四、DirectBuffer 相比 HeapBuffer 优势?(比 HeapBuffer 少了一次内存拷贝),注意下方参考中的,知乎专栏。

  • 一个 Java 进程相对于操作系统而言,肯定是一个用户进程。所以 Java 就有了这 3G 的使用权。JVM 想使用这些内存的时候,就要使用一个叫 malloc 的方法去问操作系统要(其实中间还隔了一个 C runtime,我们不去管这个细节,只把 malloc 往下都看成是操作系统的功能,并不会带来太大的问题)。
    图片.png

null

DirectBuffer 的 GC 优点

  • 直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存!
  • DirectBuffer 当然还有一个直观的优点,不被 GC 管理,所以发生 GC 的时候,整理内存的压力就会小。当然,我后面也会讲,它并不是完全不被 GC 管理,它还是能被回收的,但是在 GC 平常整理内存的时候确实是不会去管它的。

五、“零拷贝”(FileChannel 的 transferTo 和 transferFrom)

Java NIO 中提供的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个 Channel 中的数据拷贝到 FileChannel。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。

参考

http://www.aichengxu.com/java/888073.htm
https://zhuanlan.zhihu.com/p/27625923

作者:远 o_O
链接:https://www.jianshu.com/p/c81f8a93d42f
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

  
    展开阅读全文