Java IO 和 NIO

2018-11-26  本文已影响19人  wean_a23e

同步和异步、阻塞和非阻塞

不能一概认为同步、阻塞就是低效的。不同的场景有不同的功能需求。

Java 提供了哪些 IO 方式

Java IO 方式很多,基于不同的 IO 抽象模型和实现方式,可以简单区分。

指的是 java.io 包和 java.net 包下的部分网络 API。它们基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象类、RandomAccessFile 类、输入输出流、Socket、ServerSocket、HttpURLConnection 等类。这些类的交互特点是同步、阻塞的方式。也就是说,在读取输入输出流时,在读、写操作完成前,线程会一直阻塞在那里,它们之间的调用是可靠的线性关系。

Java 1.4 中引入了 NIO 框架 (java.nio) ,提供了 Selector、Channel、Buffer 等新的抽象,可以用来构建多路复用、同步、非阻塞的 IO 程序,同时也提供了更接近操作系统底层的高性能数据操作方式 DMA。

在 Java 1.7 中引入了异步、非阻塞 IO 。也有人把它叫做 NIO2。异步 IO 基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

基础 API 功能与设计,InputStream/OutputStream 和 Reader/Writer 等模式的设计和实现原理

Java IO

NIO 的基础组成

NIO 解决的问题

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

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

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

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

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

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

  1. NIO 提供的多路复用思路

可以看到,在传统 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 以内,剩下的是大文件。那就可以把扩容分成三个坎:

其他实现缓冲区的方式有

  1. 写入数据

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

上一篇下一篇

猜你喜欢

热点阅读