Java IO分类

传输方式数据操作两个方面分析Java IO的分类。

传输方式

字节是给计算机看的,字符才是给人看的。

字节流

InputStream
OutputStream

字符流

Reader
Writer

字节和字符的区别和理解

数据操作

  • 文件(file)
    FileInputStream、FileOutputStream、FileReader、FileWriter
  • 数组([])
    字节数组(byte[]): ByteArrayInputStream、ByteArrayOutputStream
    字符数组(char[]): CharArrayReader、CharArrayWriter
  • 管道操作
    PipedInputStream、PipedOutputStream、PipedReader、PipedWrite
  • 基本数据类型
    DataInputStream、DataOutputStream
  • 缓冲操作
    BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter
  • 打印
    PrintStream、PrintWriter
  • 对象序列化反序列化
    ObjectInputStream、ObjectOutputStream
  • 转换
    InputStreamReader、OutputStreamWriter

Java IO的设计模式 - 装饰者模式

Java IO源码 - InputStream

Java IO源码 - OutputStream

IO模型 - Unix IO模型

  • 阻塞与非阻塞
    应用程序级别的概念。
    阻塞与非阻塞是应用程序访问某一资源时候,该资源没有准备就绪时应用程序的处理方式。
    关注的是发起请求之后等待数据返回时的状态。

    被挂起无法执行其他操作的应用程序是阻塞型的;
    可以立即去进行其他作业的应用程序是非阻塞型的。

  • 同步与异步
    操作系统级别的。
    是否是同步还是异步,关注的是任务完成时消息通知的方式。

    由调用方(应用程序)主动问询的方式是同步调用;
    由被调用方(操作系统内核)主动通知调用方(应用程序)任务已完成的方式是异步调用。

Unix的5种IO模型

阻塞I/O blocking IO

应用进程被阻塞,直到数据复制到应用进程缓冲区中才返回。
应该注意到,在阻塞的过程中,其它程序还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其他程序还可以执行,因此不消耗 CPU 时间,这种模型的执行效率会比较高。

下图中,recvfrom 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中。这里把 recvfrom() 当成系统调用。

非阻塞I/O nonblocking I/O

应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。
由于 CPU 要处理更多的系统调用,因此这种模型是比较低效的。

I/O复用 I/O multiplexing

使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读,这一过程会被阻塞,当某一个套接字可读时返回。之后再使用 recvfrom 把数据从内核复制到进程中。
它可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。并且相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

信号驱动I/O signal driven I/O (SIGIO)

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。
相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

异步I/O asynchronous I/O

进行 aio_read 系统调用会立即返回,应用进程继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

I/O模型比较


前四种 I/O 模型的主要区别在于第一个阶段,而第二个阶段是一样的: 将数据从内核复制到应用进程过程中,应用进程会被阻塞。

同步 I/O: 应用进程在调用 recvfrom 操作时会阻塞。
异步 I/O: 不会阻塞。
阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O 都是同步 I/O,虽然非阻塞式 I/O 和信号驱动 I/O 在等待数据阶段不会阻塞,但是在之后的将数据从内核复制到应用进程这个操作会阻塞。

IO多路复用

IO多路复用最为重要,后面的文章Java IO - NIO将对IO多路复用,Ractor模型以及Java NIO对其的支持作详解。

IO多路复用工作模式

epoll 的描述符事件有两种触发模式: LT(level trigger)和 ET(edge trigger)。

LT模式
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。
是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
ET模式
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

应用场景 select&poll&epoll

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
selent
poll
epoll

IO基础知识与概念

什么是IO

在计算机操作系统中,所谓的I/O就是输入 input输出 output,也可以理解为读 read写 write
针对不同的对象,I/O模式可以划分为磁盘IO网络IO

I/O操作会涉及到用户空间内核空间的转换,理解以下规则:

  • 内存空间分为用户空间和内核空间,也称为用户缓冲区和内核缓冲区;
  • 用户的应用程序不能直接操作内核空间,需要将数据从内核空间拷贝到用户空间才能使用;
  • 无论是read操作,还是write操作,都只能在内核空间里执行;
  • 磁盘IO和网络IO请求加载到内存的数据都是先放在内核空间的。

在IO中(磁盘IO或者是网络IO),都是由用户去调用Read读取内核态中的数据,读取数据到用户态;而write则是将数据从用户态写到内核态中,由内核去写入文件或者是通过网络IO(网卡)发送数据。

文件描述符

文件描述符(fd, File Descriptor),用于描述指向文件的引用的抽象化概念。

当应用程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
在Linxu系统中,一切皆文件,因此socket也是一个文件,也有文件句柄(或文件描述符)。

操作系统的内核态和用户态

Linux系统中分为内核态 Kernel Model用户态 User Model,CPU会在两个Model之间切换。

通俗点讲,内核空间是操作系统内核代码运行的地方,用户空间是用户程序代码运行的地方。
当应用进程在运行用户代码时就处于用户态
当应用进程执行系统调用,从而内核代码执行时就处于内核态

内核空间可以执行任意的命令,而用户空间只能执行简单的运算,不能直接调用系统资源和数据。必须通过操作系统提供接口,向系统内核发送指令。
一旦调用系统接口,应用进程就从用户态切换到内核态了,因为开始运行内核代码了。

1
2
3
4
str = "i am qige" // 用户空间,赋值运算
x = x + 2 // 用户空间,赋值运算
file.write(str) // 切换到内核空间。因为用户不能直接写文件,必须通过内核安排。
y = x + 4 // 切换回用户空间

用户态切换到内核态的3种方式

  1. 系统调用
    也称为 System Call,是说用户态进程主动要求切换到内核态的一种方式,用户态进程使用操作系统提供的服务程序完成工作。
  2. 异常
    当CPU在用户空间执行程序代码时发生了不可预期的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,切换到内核态,比如缺页异常。
  3. 外围设备的中断
    当外围设备完成用户请求的某些操作后,会向CPU发送相应的中断信号,这时CPU会暂停执行下一条即将执行的指令转而去执行与中断信号对应的处理程序,如果当前正在运行用户态下的程序指令,自然就发生由用户态到内核态的切换。
    比如硬盘数据读写完成,系统会切换到中断处理程序中执行后续操作等。

磁盘IO & 网络IO

磁盘IO

读操作
当应用程序调用read()方法时,操作系统检查内核高速缓冲区中是否存在需要的数据。
如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。
如果内核缓冲区没有需要的数据,那么通过DMA方式从磁盘中读取数据到内核缓冲区,然后由CPU控制,把内核空间的数据copy到用户空间。

这个过程会涉及到两次缓冲区copy,第一次是从磁盘到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第一次是DMA的copy,第二次是CPU的copy。

写操作
当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区),这时对用户程序来说写操作就已经完成。
至于什么时候把数据再写到磁盘(从内核缓冲区到磁盘的写操作也由DMA控制,不需要cpu参与),由操作系统决定。
除非应用程序显示地调用了sync命令,立即把数据写入磁盘。

如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作。
这时会涉及到四次缓冲区的copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第三次是从用户缓冲区到内核缓冲区,第四次是从内核缓冲区写回到磁盘。前两次是为了读,后两次是为了写。这其中有两次 CPU 拷贝,两次DMA拷贝。

磁盘IO的延时
为了读或写,磁头必须能移动到所指定的磁道上,并等待所指定的扇区的开始位置旋转到磁头下,然后再开始读或写数据。
磁盘IO的延时分成以下三部分:

  • 寻道时间:把磁头移动到指定磁道上所经历的时间;
  • 旋转延迟时间 :指定扇区移动到磁头下面所经历的时间;
  • 传输时间 :数据的传输时间(数据读出或写入的时间)。

网络IO

读操作
网络 IO 既可以从物理磁盘中读数据,也可以从Socket中读数据(从网卡中获取)。
当从物理磁盘中读数据的时候,其流程和磁盘IO的读操作一样。
当从Socket中读数据,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议栈(网卡)中读取客户端发送的数据到内核空间的Socket Buffer(这个过程也由DMA控制),然后把内核空间的数据 copy 到用户空间,供应用程序使用。

写操作
假设网络IO的数据从磁盘中获取,读写操作的流程如下:

  • 当应用程序调用 read() 方法时,通过DMA方式将数据从磁盘拷贝到内核缓冲区;
  • 由cpu控制,将内核缓冲区的数据拷贝到用户空间的缓冲区中,供应用程序使用;
  • 当应用程序调用 write() 方法时,CPU 会把用户缓冲区中的数据 copy 到内核缓冲区的 Socket Buffer 中;
  • 最后通过DMA方式将内核空间中的Socket Buffer拷贝到Socket协议栈(即网卡设备)中传输。

网络IO 的写操作也有四次缓冲区的copy,第一次是从磁盘缓冲区到内核缓冲区(由DMA控制),第二次是内核缓冲区到用户缓冲区(CPU控制),第三次是用户缓冲区到内核缓冲区的 Socket Buffer(由CPU控制),第四次是从内核缓冲区的 Socket Buffer 到网卡设备(由DMA控制)。四次缓冲区的copy工作两次由CPU控制,两次由DMA控制。

网络IO的延时
网络IO主要延时是由:服务器响应延时+带宽限制+网络延时+跳转路由延时+本地接收延时决定。一般为几十到几千毫秒,受环境影响较大。
所以,一般来说,网络IO延时要大于磁盘IO延时(不过同数据中心的交互除外,会比磁盘 IO 更快)。

零拷贝

mmap

mmap的核心思想是:应用程序这边由于在用户态无法直接操作寄存器的物理地址,于是通过mmap方法进行内存映射,将物理地址映射到用户态的虚拟地址上,然后应用程序通过读写自己手边的虚拟地址,就可以实现对物理地址的读取/写入。

sendFile

sendfile系统调用函数,可以直接把内核缓冲区的数据直接拷贝到socket缓冲区中,不再拷贝到用户态。

sendfile要求输入的fd必须是文件句柄,不能是socket,输出的fd必须是socket,也就是说,数据的来源必须是从本地的磁盘,而不能是从网络中。

1
2
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)

in_fd 必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。
由此可见,sendfile 几乎是专门为在网络上传输文件而设计的。

数据传输方式 PIO & DMA

DMA(直接存储器访问)和 PIO(程控输入/输出)。
DMA和PIO分别是在电子设备中传输信息的两种方式;在计算机和其他类似设备中更为著名。
PIO是一种较老的方法,由于某些优点,在大多数应用中已被DMA取代。

缓冲IO & 直接IO

缓冲IO

机械硬盘的读写原理与特点是:一个机械硬盘中装有多个盘片,每个盘片上有多个同心圆(磁道),每个同心圆又由多个弧(扇区)组成,每个弧上都记录了等量的数据(比方说512byte)。
如果发起一个随机读写请求,磁头需要先找到对应的磁道,然后等待对应的扇区旋转到磁头正下方才能开始读取数据(民用机械硬盘的转速一般在5400或者7200RPM,工业界倒是经常使用10000RPM的机械硬盘。但是它们的寻道时间大概都在几ms到十几ms左右)。
机械硬盘的顺序读写很快(一般在100-200MB/s),但是随机读写很慢(寻道时间在十几ms,导致随机读写的iops只有几十)。

假定我们不做任何额外的优化处理,在用户发起读数据请求的时候,直接调用硬盘驱动读取磁盘数据并返回。
设想一个场景:循环调用read方法读取文件,但是每次只读取较少的数据(比方说每次只读一个byte)。那么每次read请求都对应于一次对磁盘的随机读写(两次读请求之前需要重新寻道),也就是说read操作的tps只有几十。
也就是说此时磁盘占用率为100%,但是只能提供不到100byte/s的数据读取率,这显然是不可接受的。

Linux对此有个很简单的优化,就是在内核中维护一块缓冲区(buffer cache),在用户第一次调用read读取数据的时候,无论用户想要读取的数据有多小,都会一次性从磁盘中加载一段数据放到缓冲区中,根据局部性原理,这样用户下一次调用read方法的时候可以直接从缓冲区中返回数据,不用再次访问磁盘了。
write方法也是同理,用户写入的数据不是直接落盘,而是先写到kernel中的缓冲区里,按照一定的策略批量刷盘。当然也可以调用flush方法强制将缓存区的数据落盘。
这个优化极大的提高了顺序读写的效率。由于直接读写的是kernel中的缓冲区而不是磁盘,这种IO被称为缓冲IO。

直接IO

一般来说,缓冲IO已经足够应付日常需求了。但是像数据库这种极度依赖IO的应用程序,为了追求极致的性能,往往更加愿意自己直接操作磁盘。
直接IO可以直接将数据从磁盘复制到用户空间,或者将数据从用户空间写到磁盘,减少了kernel中的缓冲区这一环节,这是直接IO可以提高性能的原理。
但是如果用得不好就悲剧了,所以直接IO只在少数场景下使用。