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 机制作为资源释放的最后把关。
Java IO

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 解决的问题

  1. 传统 IO 在特定情境的困境

NIO 被用来构建多路复用程序,如果问为什么需要 NIO,那应该就是为了解决传统 IO 在遇到瓶颈、不能满足我们需求时引入的多路复用方案的实现方式了。

在传统 Socket IO 中,服务器对每个客户端连接都启动一个单独的线程来对应,而 Java 语言目前的线程实现是比较重量级的,启动或者销毁一个线程有明显的开销。例如在 32 位的 JVM 中,启动一个线程的堆栈开销是 320K,在 64 位 JVM 中这个数字是 1024K,如果是少量线程还能正常运行,若是遇到百万线程级别的场景,1024K * 1M = 1TB,这个内存开销就不能接受了,这还不算线程内部的运行程序的内存开销。

同时,线程上下文切换在这种极多线程的情况下,也会极大拖累程序的运行,成为系统吸能的瓶颈。

如果引入线程池机制,通过一个固定大小的线程池,来负责管理工作线程,避免频繁的创建、销毁线程的开销,这也是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解:

如果连接不多,只有最多几百个连接的普通应用,这种工作方式在大部分情况下可以工作得很好。但是,如果并发数量急剧上升,线程上下文切换的开销就很大。如果有些线程长时间占用着线程池中的资源,其他线程就得不到操作。

  1. NIO 提供的多路复用思路
  • 首先创建一个 Selector 作为调度员。

  • 然后创建 ServerSocketChannel,配置为非阻塞模式(在阻塞模式下,注册操作是不被允许的。配置阻塞操作后,Channel 的操作会变为非阻塞。另外也可从此推出,FileChannel 不可以配置非阻塞模式,不可以注册)并向 Selector 注册,通过指定 Selector.OP_ACCEPT,告诉调度员关注新的连接请求。

  • Selector 阻塞在 select 操作,当有关注的操作发生时,就会被唤醒。

  • 唤醒后,通过 SelectionKey 获取对象进行相应的输入输出操作。

可以看到,在传统 IO 操作中,程序都是同步阻塞模式,需要以多线程多任务的方式处理。而 NIO 则是利用了一个线程对应一个 Selector 的轮询机制来高效定位就绪的 Channel,来决定做什么,仅仅 select 操作是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题。

NIO 程序设计

相比于传统 IO,Java 提供的 NIO 在使用时要考虑的因素很多,编写出来的程序也比较复杂。不是 NIO 就一定比 IO 好,在选择技术方案时还是要根据需求具体决定。

  1. 使用 Selector

可以把对各个事件的处理操作封装成 component 来对接 Selector

如果所有监听事件都注册到同一个 Selector 中时,每个处理操作会遍历到不需要监听的对象,浪费性能和时间。可以开多几个 Selector 配合 component 处理特定的连接集合。

  1. 读取数据

NIO 中的读取的数据是没有分界的,我们不知道一次读取了多少数据,所以需要构建一个 Reader 来解析这些数据块。

我们不知道每个数据块中包含多少条数据,可以是不足一条,可以一条多,可以更多条。如果数据块不足一条,那么我们需要缓存下来,等待后续的数据块拼接出一个完整的数据。这就要求给每个连接分配一个 Reader,并分配缓冲区。

实现后的程序大概是这样

  1. 缓冲区设计

从第二点可以知道我们的 Reader 必须一个缓冲区,然而如何设计一个缓冲区又是需要考虑的问题。

假设简单地按照所有可能到来的消息的最大值,给每个 Reader 分配一个最大值大小的缓冲区,那在连接少量时,还能正常工作。但是,连接少量时,我们也可以选择传统 IO 这种更简单的实现方式。假设这个最大值是 1M,那百万连接就需要 1TB 的内存作为缓冲区,这还是没考虑如果有些连接传送数据块的最大值是 16MB 、 128 MB 甚至更大的情况。

所以,我们应该设计一个动态缓冲区。最简单的实现方式就像数组的动态扩容,每次翻倍。这是最容易想到,也是不错的思路,可以解决这个问题,但还可以在此基础上进行优化。

考虑到连接的的数据大小是有规律的,不像 Java 通用集合一样不知道实现要装多少数据故只能一步一步做动态扩容。我们可以对连接的数据“一步扩容到合适的大小”,避免普通动态扩容的缺点。考虑下从 4KB 一步一步的翻倍扩容到几百 MB 需要浪费多少性能。

针对特定数据规律扩容,假设大部分数据是 4KB 以内,少量数据是 10MB 以内,剩下的是大文件。那就可以把扩容分成三个坎:

  • 初次分配 4KB 的缓冲区,满足绝大部分的请求,无需扩容。
  • 4KB 不满足则分配 10MB 的缓冲区,比如少量的图片、小文件传输。
  • 剩下极少量情况是传输大文件,其大小不可估计,但是因为是极少量情况,所以可以直接分配数据块允许的最大大小的缓存。

其他实现缓冲区的方式有

  • 直接创建一个超大 buffer,各个 Reader 复用这个超大 buffer 的一部分作为缓冲区。
  • 使用链表法,缓冲区 buffer 太小就再分配一个 buffer,用链表连接新旧的 buffer 组成更大的 buffer。
  • 有些消息有通用的格式,会在消息头写下本次消息的大小,程序根据这个大小分配相应的 buffer 即可。
  1. 写入数据

在非阻塞模式下,调用 write 操作都是直接返回,不能保证每次都把数据写完。这时就需要实现一个写入数据缓冲区和相应的逻辑代码来完成写入。

你可能感兴趣的:(Java IO 和 NIO)