Java IO、NIO原理

参考

  1. https://www.cnblogs.com/hapjin/p/5736188.html
  2. https://www.cnblogs.com/jfqiu/p/3852230.html
  3. https://blog.csdn.net/u013096088/article/details/78774627

IO原理

图片.png
  1. 用户态:对于操作系统而言,JVM只是一个用户进程(应用程序),处于用户态空间中,而处于用户态空间的进程是不能直接操作底层的硬件(磁盘/网卡)。
  2. 系统调用:区别于用户进程调用,系统调用时操作系统级别的api,比如java IO的读取数据过程(使用缓冲区),用户程序发起读操作,导致“ syscall read ”系统调用,就会把数据搬入到 一个buffer中;用户发起写操作,导致 “syscall write ”系统调用,将会把一个 buffer 中的数据 搬出去(发送到网络中 or 写入到磁盘文件)
  3. 内核态:用户态的进程要访问磁盘/网卡(也就是操作IO),必须通过系统调用,从用户态切换到内核态(中断、trap),才能完成。
  4. 局部性原理:操作系统在访问磁盘时,由于局部性原理,操作系统不会每次只读取一个字节(代价太大),而是借助硬件直接存储器存取(DMA)一次性读取一片(一个或者若干个磁盘块)数据。因此,就需要有一个“中间缓冲区”--即内核缓冲区。先把数据从磁盘读到内核缓冲区中,然后再把数据从内核缓冲区搬到用户缓冲区。
  • 从进程看,分用户态和内核态
    应用程序属于用户态,系统调用则内核态
  • 从内存看,分用户内存和内核内存
    用户内存 = 应用内存 = JVM内存 = User space
    内核内存 = 系统内存 = Kernel space
    引用一句话:

操作系统与Java基于流的I/O模型有些不匹配。操作系统要移动的是大块数据(缓冲区),这往往是在的协助下完成的。I/O类喜欢操作小块数据——单个字节、几行文本。结果,操作系统送来整缓冲区的数据,java.io的流数据类再花大量时间把它们拆成小块,往往拷贝一个小块就要往返于几层对象。操作系统喜欢整卡车地运来数据,java.io类则喜欢一铲子一铲子地加工数据。
—— 引自《JAVA NIO》

java旧的io方式,就是一个字节一个字节读取数据,这会引起频繁的用户态和系统态切换,系统开销特别大,效率极低。而基于缓冲的读写,减少这种不必要的切换,可以显著提高IO效率。NIO里ByteBuffer的设计,也是为了去贴近操作系统读取一片数据的习惯。
如下,是使用缓冲区读取数据的原理图:


图片.png

IO模型

https://www.cnblogs.com/dolphin0520/p/3916526.html
一共有5种io模型,java nio使用的时多路复用模型。

  • 阻塞io:用户进程阻塞,知道io就绪且io处理(读写)完成,阻塞才解除。
  • 非阻塞IO:用户进程不阻塞,需要不断轮询io是否就绪,很占用cpu资源,所以一般不用
  • 多路复用IO:用户进程阻塞,也是轮询io是否就绪,但是和非阻塞IO不一样的是,轮询io的是系统进程而非应用进程,实际使用了代理(select/poll/epoll),可以避免cpu空转,所以效率较高,而且1个进程可以管理多个socket,java nio使用的就是多路复用模型。
  • 信号驱动式 I/O:(有点类似回调)用户进程不阻塞,而是安装一个信号处理函数,当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。
  • 异步io:用户进程不阻塞,发起io请求(读写)后就可以做自己的事了,io就绪等待和io操作交给操作系统去完成,完成后调用用户进程处理函数,所以整个过程都是异步的。

多路复用IO和非阻塞比较

非阻塞是用户进程去轮询io是否就绪,所以用户进程是不阻塞的。
而多路复用,用户进程是阻塞的,轮询io是否就绪是系统进程(内核)在进行。
多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。

select、poll和epoll

参考:
https://www.cnblogs.com/lojunren/p/3856290.html
https://blog.csdn.net/davidsguo008/article/details/73556811

select、poll和epoll都是多路复用iO在linux系统上的实现技术,以前使用select/poll,epoll是Linux 2.6内核引入,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

select和poll
  • 场景
    有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的。
  • 实现过程
    返回的活跃连接 ==select(全部待监控的连接)
    1. 服务器进程每次都把这100万个连接的套接字告诉操作系统(从用户态复制句柄数据结构到内核态)
    2. 操作系统内核去轮询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态
    3. 用户态的应用程序轮询处理已发生的网络事件

这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

  • 缺点
  1. 采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差,时间复杂度为O(n);
  2. 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  3. 返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  4. 触发方式是水平触发(LT),应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。
  • select和poll的区别
    select单个进程能够监视的文件描述符(也叫句柄,使用__FD_SETSIZE参数设置)的数量存在最大限制,通常是1024;
    相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制。
epolld
  • 实现原理:三要素
    mmap、红黑色、链表

  • 实现过程:三部曲

    1. epoll_create()系统调用,即新建epoll描述符,epoll是通过内核与用户空间mmap(映射)同一块物理内存实现的,从而减少用户态和内核态之间的数据交换。
    2. epoll_ctrl(epoll描述符,连接)系统调用,即向epoll对象中添加、删除、修改感兴趣的连接(事件),epoll在实现上使用一颗红黑树来存储所有监听的连接,每个连接是一个事件对象epitem,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。同时,epoll中的每个epitem都会与设备(网卡)驱动程序建立回调关系,当相应的事件发生时会调用这个回调方法(ep_poll_callback),它会将发生的epitem添加到rdlist双链表中。
    3. 返回的活跃连接 ==epoll_wait( epoll描述符 )系统调用,epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

通过内存映射机制,红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。

  • 优点
  1. 使用内存映射,减少用户空间与内核空间内存拷贝;
  2. 注册操作与返回活跃连接分开,不需要每次都传入所有文件描述符;
  3. 使用事件回调机制,系统调用epoll_waitepoll,只返回就绪的事件,不需要遍历所有文件描述符,时间复杂度是O(1)。
  4. epoll的触发方式是边缘触发(ET),在并发、大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。

需要注意的是:epoll并不是在所有的应用场景都会比select和poll高很多。尤其是当活动连接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!所以,epoll特别适用于连接数量多,但活动连接较少的情况。

Reactor 反应器模型

io多路复用模型+线程池复用 = Reactor反应器模型

几种io读写方式

  1. io,面向流,没有使用Buffer(缓冲),一次就读取1个字节。频繁的调用系统函数(read/writer),用户态和内核态不断上下文切换,速度很慢。
  2. io,缓冲流,使用byte数组或缓冲流(比如BufferInputStream),实现了缓冲,一次读取多个字节到用户缓冲区,减少系统调用次数来提高性能的。
  3. nio,HeapByteBuffer,面向块(缓冲区)
  4. nio,DirectByteBuffer,使用直接内存/堆外内存
  5. nio,MappedByteBuffer,使用内存映射文件,不需要经过内核态的缓冲区 ,其实也算是直接内存的一种。

nio的byteBuffer和io的buffer

一直找不到有关这两者区别的文章,只有一句话有一些隐含的表达:

nio中Buffer的引入,使得java的IO模型更贴近操作系统底层,面向Buffer的读写操作更高效,同时也在API层面避免了单字节操作。

还有一句话,说bytebuffer的非阻塞会引起很多问题,导致数据读取不完整,不太理解

Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

个人总结,两者背后使用的都是缓冲区方式,实现原理应该是一样的,不同之处应该是API设计上,ByteBuffer是经过特别封装的缓冲区,支持指针式的读取。

MappedByteBuffer内存映射文件

图片.png

内存映射文件就是将某一段的虚拟地址和文件对象的某一部分建立起映射关系,此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这段代码内的虚拟地址时,触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,当有操作第N页数据的时候重复这样的OS页面调度程序操作。原来内存映射文件的效率比标准IO高的重要原因就是因为少了把数据拷贝到OS内核缓冲区这一步(可能还少了native堆中转这一步)。
参考:https://blog.csdn.net/fcbayernmunchen/article/details/8635427

DirectByteBuffer和HeapByteBuffer的区别

ByteBuffer分两种,直接缓冲区和非直接缓冲区。

  • heap ByteBuffer:非直接缓存区,也叫堆内缓存,可以通过ByteBuffer.wrap(byte[] array);ByteBuffer.allocate(int capacity)这两个方法来创建,该类对象分配在JVM的堆内存里面,直接由Java虚拟机负责垃圾回收
  • direct ByteBuffer:直接缓存,也叫堆外缓存,通过ByteBuffer.allocateDirect(int capacity)来创建,是借助JNI 从本机代码创建在虚拟机外内存,堆外内存通过full gc来回收内存,-XX:MaxDirectMemorySize参数可以限制直接缓存大小,含义是当Direct ByteBuffer分配的堆外内存到达指定大小后,触发Full GC。

比较:

  1. Direct Buffer分配和取消分配的时间成本高、而且使用不安全,堆外内存不好回收
  2. 速度问题,因为Direct Buffer减少了内核空间拷贝的过程,所以速度比Heap ByteBuffer明显要快。
  3. 使用场景问题
    建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。
    DirectByteBuffer主要用在设备间的i/o拷贝,比如网络编程,数据不需要再native memory和jvm memory中来回copy,实现zero copy。
    其它场景不建议使用。
    netty这些nio框架背后有使用DirectByteBuffer。

zerocopy技术

图片.png

图片.png

java.nio.channels.FileChannel类的transferTo()方法使用的就是zerocopy技术,可以直接将字节从读的通道(Readable Channel)传送到可写的通道中(Writable Channel),并不需要将字节送入用户程序空间(用户缓冲区)。

  1. IO操作需要数据频繁地在内核缓冲区和用户缓冲区之间拷贝,而zerocopy技术可以减少这种拷贝的次数
  2. 降低了上下文切换(用户态与内核态之间的切换)的次数

io是面向流,nio是面向缓冲,所以io性能差,这是错误的

实际上,io做文件读写的速度并不比nio差,因为io也可以使用buffer缓冲区来提高性能,使用buffer来读写数据的io和nio的bytebuffer多大区别。人们常说的旧io性能低下,其实指的是不使用buffer情况下,io使用一个字节一个字节的读写的方式。
因为在 JDK 1.4 中原来的 I/O 包和 NIO 已经很好地集成了。 java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如, java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在更面向流的系统中,处理速度也会更快。