什么是I/O流

我们通常把机器或者应用程序接收外界的信息称为输入流(InputStream),从机器或者应用程 序向外输出的信息称为输出流(OutputStream),合称为输入 / 输出流(I/O Streams)。

Java 的 I/O 分为以下两类:

image-20230526110237837

不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么还要存在字符流呢?

字符到字节必须经过转码,这个过程非常耗时,如果我们不知道编码类型就很容易出现乱码问题,所以提供了直接操作字符的接口。

字节流

image-20230526110336147

字符流

image-20230526110352335

传统I/O的性能问题

1. 多次内存复制

在传统 I/O 中,我们可以通过 InputStream 从源数据中读取数据流输入到缓冲区里,通过 OutputStream 将数据输出到外部设备(包括磁盘、网络),输入操作在操作系统中的具体流程如下:

image-20230526110921118

JVM 会发出 read() 系统调用,并通过 read 系统调用向内核发起读请求;

内核向硬件发送读指令,并等待读就绪;

内核把将要读取的数据复制到指向的内核缓存中;

操作系统内核将数据复制到用户空间缓冲区,然后 read 系统调用返回。

数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生 了两次内存复制操作。

2. 阻塞

在传统 I/O 中,InputStream 的 read() 是一个 while 循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。

这种处理方式在少量连接没问题,但在发生大量连接请求时,就需要创建大量监听线程,这时如果线程没有数据就绪就会被挂起,然后进入阻塞状态。 一旦发生线程阻塞,这些线程将会不断地抢夺 CPU 资源,从而导致大量的 CPU 上下文切换,增加系统的性能开销。

如何优化 I/O 操作

NIO 的发布优化了内存复制以及阻塞导致的 严重性能问题,NIO2提出了从操作系统层面实现的异步 I/O。

1. 使用缓冲区优化读写流操作

NIO是基于块的,在 NIO 中, 最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer 是一块连续的内存块, 是 NIO 读写数据的中转地。Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。

NIO 是面向 Buffer。Buffer 可以将文 件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。

2. 使用 DirectBuffer 减少内存复制

NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存 (非堆内存)。

数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而在 Java 中,在用户空间中又存在一个拷贝,那就是从 Java 堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。

image-20230526112218554

如果单纯使用 Java 堆内存进行数据拷贝,当拷贝的数据量比较大的情况下,Java 堆的 GC 压力会比较大,而使用非堆内存可以减低 GC 的压力。

DirectBuffer 则是直接将步骤简化为数据直接保存到非堆内存,从而减少了一次数据拷贝。

由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很 高。DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被 回收时,会通过 Java Reference 机制来释放该内存块。

MappedByteBuffer 是通过本 地类调用 mmap 进行文件内存映射的,map() 系统调用方法会直接将文件从硬盘拷贝到用户 空间,只进行一次数据拷贝,从而减少了传统的 read() 方法从硬盘拷贝到内核空间这一步。

3. 避免阻塞,优化 I/O 操作

传统的 I/O 即使使用了缓冲块,依然存在阻塞问题。由于线程池线程数量有限,一旦发生大量 并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。而对 Socket 的输入流进行读取时,读取流会一直阻塞,直到发生以下三种情况的任意一种才会解 除阻塞:

1、有数据可读;

2、连接释放;

3、空指针或 I/O 异常。

NIO 发布后,通道和多路复用器这两个基本组件实现 了 NIO 的非阻塞

通道

最开始,在应用程序调用操作系统 I/O 接口时,是由 CPU 完成分配,这种方式最大的问题是 “发生大量 I/O 请求时,非常消耗 CPU“;之后,操作系统引入了 DMA(直接存储器存 储),内核空间与磁盘之间的存取完全由 DMA 负责,但这种方式依然需要向 CPU 申请权 限,且需要借助 DMA 总线来完成数据的复制操作,如果 DMA 总线过多,就会造成总线冲突。

Channel 有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。在 NIO 中,我们读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以 读、写可以同时进行。

多路复用器(Selector)

Selector 是 Java NIO 编程的基础。用于检查一个或多个 NIO Channel 的状态是否处于可 读、可写。

Selector 是基于事件驱动实现的,我们可以在 Selector 中注册 accpet、read 监听事件, Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个 Channel 就处于就绪状态,然后进行 I/O 操作。

一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。我们可以 在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O 操作时,该线程就不会一直 等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。

参考

《Java性能调优实战》