Java IO 和 NIO
同步和异步、阻塞和非阻塞
- 同步 (synchronous) 是一种可靠的运行机制,当我们进行同步操作时,后续操作是等待当前调用返回,才会进行下一步操作。
- 异步 (asynchronous) 相反于同步操作,执行异步操作时,其他操作不需要等待当前调用返回,通常依靠事件、回调机制来实现任务间的次序关系。
- 阻塞 (blocking) 操作被执行时,当前线程会处于阻塞状态,无法执行其他任务,只有当条件就绪,阻塞操作完成,线程才会继续往下运行。典型操作比如,ServerSocket 新连接建立完毕后、数据写入后、读取完成后,线程才会继续往下执行。
- 非阻塞 (non-blocking) 是不需要等待条件就绪,只要执行操作,就会立即返回结果。比如 IO 操作还未结束,就可以先读取部分数据,直接返回,相应的 IO 操作继续在后台进行。
不能一概认为同步、阻塞就是低效的。不同的场景有不同的功能需求。
Java 提供了哪些 IO 方式
Java IO 方式很多,基于不同的 IO 抽象模型和实现方式,可以简单区分。
- 传统 IO
指的是 java.io 包和 java.net 包下的部分网络 API。它们基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象类、RandomAccessFile 类、输入输出流、Socket、ServerSocket、HttpURLConnection 等类。这些类的交互特点是同步、阻塞的方式。也就是说,在读取输入输出流时,在读、写操作完成前,线程会一直阻塞在那里,它们之间的调用是可靠的线性关系。
- NIO
Java 1.4 中引入了 NIO 框架 (java.nio) ,提供了 Selector、Channel、Buffer 等新的抽象,可以用来构建多路复用、同步、非阻塞的 IO 程序,同时也提供了更接近操作系统底层的高性能数据操作方式 DMA。
- AIO
在 Java 1.7 中引入了异步、非阻塞 IO 。也有人把它叫做 NIO2。异步 IO 基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
基础 API 功能与设计,InputStream/OutputStream 和 Reader/Writer 等模式的设计和实现原理
- 首先,基础 IO API 的设计,不仅仅是指对文件的 IO,线程间的通信 pipe,网络编程中 Socket 通信,都是典型的 IO 操作目标。
- 输入输出流 (InputStream/OutputStream) 是用于读取或者写入字节的,例如操作图片文件。
- 而 Reader/Writer 则是用于操作字符,在输出输出流的基础上增加了字符编码、解码的功能,适用于类似从文件中读取或者写入字符串。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader/Writer 相当于构建了应用逻辑和原始数据之间的桥梁。
- BufferedOutputStream 等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高 IO 的处理效率,这种设计利用了缓冲区,即将批量的操作一次处理,但是使用时要记得 flush。
- 很多 IO 工具类实现了 Closeable 接口,因为需要进行资源的释放。比如,使用 FileInputStream,它会获取相应的文件描述符 (FileDescripter) 。需要利用 try-with-resource、try-finally 等机制保证 FileInputStream 被明确关闭,进而释放相应的文件描述符,否则文件资源将得不到释放。在必要时,还应增加 Cleaner、Finalize 机制作为资源释放的最后把关。
NIO 的基础组成
-
Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。
-
Channel,类似在 Linux 之类的操作系统上看到的文件描述符,是 NIO 被用来支持批量式 IO 操作的一种抽象。
File 或者 Socket,通常被认为式比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化。比如 DMA 等。不同层次的抽象是相互关联的,我们可以利用 Channel 获取 Socket,反之亦然。 -
Selector,是 NIO 用来构建多路复用的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,并把它们归类到就绪集合中,进而实现了单线程对多 Channel 的高效管理。
Selector 同样是基于操作系统底层机制,不同模式、不同版本都存在区别,例如,在最新的代码库里,相关实现如下:
Linux 上的实现基于 epoll (http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java) -
Charset,提供了 Unicode 字符串定义,NIO 也提供了相应的编解码器。
NIO 解决的问题
- 传统 IO 在特定情境的困境
NIO 被用来构建多路复用程序,如果问为什么需要 NIO,那应该就是为了解决传统 IO 在遇到瓶颈、不能满足我们需求时引入的多路复用方案的实现方式了。
在传统 Socket IO 中,服务器对每个客户端连接都启动一个单独的线程来对应,而 Java 语言目前的线程实现是比较重量级的,启动或者销毁一个线程有明显的开销。例如在 32 位的 JVM 中,启动一个线程的堆栈开销是 320K,在 64 位 JVM 中这个数字是 1024K,如果是少量线程还能正常运行,若是遇到百万线程级别的场景,1024K * 1M = 1TB,这个内存开销就不能接受了,这还不算线程内部的运行程序的内存开销。
同时,线程上下文切换在这种极多线程的情况下,也会极大拖累程序的运行,成为系统吸能的瓶颈。
如果引入线程池机制,通过一个固定大小的线程池,来负责管理工作线程,避免频繁的创建、销毁线程的开销,这也是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解:
如果连接不多,只有最多几百个连接的普通应用,这种工作方式在大部分情况下可以工作得很好。但是,如果并发数量急剧上升,线程上下文切换的开销就很大。如果有些线程长时间占用着线程池中的资源,其他线程就得不到操作。
- NIO 提供的多路复用思路
-
首先创建一个 Selector 作为调度员。
-
然后创建 ServerSocketChannel,配置为非阻塞模式(在阻塞模式下,注册操作是不被允许的。配置阻塞操作后,Channel 的操作会变为非阻塞。另外也可从此推出,FileChannel 不可以配置非阻塞模式,不可以注册)并向 Selector 注册,通过指定 Selector.OP_ACCEPT,告诉调度员关注新的连接请求。
-
Selector 阻塞在 select 操作,当有关注的操作发生时,就会被唤醒。
-
唤醒后,通过 SelectionKey 获取对象进行相应的输入输出操作。
可以看到,在传统 IO 操作中,程序都是同步阻塞模式,需要以多线程多任务的方式处理。而 NIO 则是利用了一个线程对应一个 Selector 的轮询机制来高效定位就绪的 Channel,来决定做什么,仅仅 select 操作是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题。
NIO 程序设计
相比于传统 IO,Java 提供的 NIO 在使用时要考虑的因素很多,编写出来的程序也比较复杂。不是 NIO 就一定比 IO 好,在选择技术方案时还是要根据需求具体决定。
- 使用 Selector
可以把对各个事件的处理操作封装成 component 来对接 Selector
如果所有监听事件都注册到同一个 Selector 中时,每个处理操作会遍历到不需要监听的对象,浪费性能和时间。可以开多几个 Selector 配合 component 处理特定的连接集合。
- 读取数据
NIO 中的读取的数据是没有分界的,我们不知道一次读取了多少数据,所以需要构建一个 Reader 来解析这些数据块。
我们不知道每个数据块中包含多少条数据,可以是不足一条,可以一条多,可以更多条。如果数据块不足一条,那么我们需要缓存下来,等待后续的数据块拼接出一个完整的数据。这就要求给每个连接分配一个 Reader,并分配缓冲区。
实现后的程序大概是这样
- 缓冲区设计
从第二点可以知道我们的 Reader 必须一个缓冲区,然而如何设计一个缓冲区又是需要考虑的问题。
假设简单地按照所有可能到来的消息的最大值,给每个 Reader 分配一个最大值大小的缓冲区,那在连接少量时,还能正常工作。但是,连接少量时,我们也可以选择传统 IO 这种更简单的实现方式。假设这个最大值是 1M,那百万连接就需要 1TB 的内存作为缓冲区,这还是没考虑如果有些连接传送数据块的最大值是 16MB 、 128 MB 甚至更大的情况。
所以,我们应该设计一个动态缓冲区。最简单的实现方式就像数组的动态扩容,每次翻倍。这是最容易想到,也是不错的思路,可以解决这个问题,但还可以在此基础上进行优化。
考虑到连接的的数据大小是有规律的,不像 Java 通用集合一样不知道实现要装多少数据故只能一步一步做动态扩容。我们可以对连接的数据“一步扩容到合适的大小”,避免普通动态扩容的缺点。考虑下从 4KB 一步一步的翻倍扩容到几百 MB 需要浪费多少性能。
针对特定数据规律扩容,假设大部分数据是 4KB 以内,少量数据是 10MB 以内,剩下的是大文件。那就可以把扩容分成三个坎:
- 初次分配 4KB 的缓冲区,满足绝大部分的请求,无需扩容。
- 4KB 不满足则分配 10MB 的缓冲区,比如少量的图片、小文件传输。
- 剩下极少量情况是传输大文件,其大小不可估计,但是因为是极少量情况,所以可以直接分配数据块允许的最大大小的缓存。
其他实现缓冲区的方式有
- 直接创建一个超大 buffer,各个 Reader 复用这个超大 buffer 的一部分作为缓冲区。
- 使用链表法,缓冲区 buffer 太小就再分配一个 buffer,用链表连接新旧的 buffer 组成更大的 buffer。
- 有些消息有通用的格式,会在消息头写下本次消息的大小,程序根据这个大小分配相应的 buffer 即可。
- 写入数据
在非阻塞模式下,调用 write 操作都是直接返回,不能保证每次都把数据写完。这时就需要实现一个写入数据缓冲区和相应的逻辑代码来完成写入。