
熟练掌握 BIO,NIO,AIO 的基本概念以及少量常见问题是你准备面试的过程中不可或者缺的一部分,另外这些知识点也是你学习 Netty 的基础。
Java 中的 BIO、NIO和 AIO 了解为是 Java 语言对操作系统的各种 IO 模型的封装。程序员在使用这些 API 的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。只要要使用Java的API即可以了。
在讲 BIO,NIO,AIO 之前先来回顾一下这样几个概念:同步与异步,阻塞与非阻塞。
同步与异步
同步: 同步就是发起一个调用后,被调用者未解决完请求之前,调用不返回。
异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以解决其余的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待解决结果,被调用者会通过回调等机制来通知调用者其返回结果。
阻塞和非阻塞
阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其余任务,只有当条件就绪才能继续。
非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其余事情。
那么同步阻塞、同步非阻塞和异步非阻塞又代表什么意思呢?
举个生活中简单的例子,你妈妈让你烧水,小时候你比较笨啊,在哪里傻等着水开(同步阻塞)。等你略微再长大一点,你知道每次烧水的空隙可以去干点其余事,而后只要要时不时来看看水开了没有(同步非阻塞)。后来,你们家用上了水开了会发出声音的壶,这样你就只要要听到响声后就知道水开了,在这期间你可以随意干自己的事情,你需要去倒水了(异步非阻塞)。
同步阻塞I/O模式,数据的读取写入必需阻塞在一个线程内等待其完成。
BIO通信(一请求一应答)模型图如下(图源网络,原出处不明):

采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听用户端的连接。我们一般通过在 while(true) 循环中服务端会调用 accept() 方法等待接收用户端的连接的方式监听请求,请求一旦接收到一个连接请求,即可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其余用户端连接请求,只能等待同当前连接的用户端的操作执行完成, 不过可以通过多线程来支持多个用户端的连接,如上图所示。
假如要让 BIO 通信模型 能够同时解决多个用户端请求,就必需使用多线程(主要起因是 socket.accept()、 socket.read()、 socket.write() 涉及的三个主要函数都是同步阻塞的),也就是说它在接收到用户端连接请求之后为每个用户端创立一个新的线程进行链路解决,解决完成之后,通过输出流返回应答给用户端,线程销毁。这就是典型的 一请求一应答通信模型 。我们可以设想一下假如这个连接不做任何事情的话就会造成不必要的线程开销,不过可以通过 线程池机制 改善,线程池还可以让线程的创立和回收成本相对较低。使用FixedThreadPool 可以有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了N(用户端请求数量):M(解决用户端请求的线程数量)的伪异步I/O模型(N 可以远远大于 M),下面一节"伪异步 BIO"中会详细详情到。
我们再设想一下当用户端并发访问量添加后这种模型会出现什么问题?
在 Java 虚拟机中,线程是宝贵的资源,线程的创立和销毁成本很高,除此之外,线程的切换成本也是很高的。尤其在 Linux 这样的操作系统中,线程本质上就是一个进程,创立和销毁线程都是重量级的系统函数。假如并发访问量添加会导致线程数急剧膨胀可能会导致线程堆栈溢出、创立新线程失败等问题,最终导致进程宕机或者者僵死,不能对外提供服务。
为理解决同步阻塞I/O面临的一个链路需要一个线程解决的问题,后来有人对它的线程模型进行了优化逐个一后台通过一个线程池来解决多个用户端的请求接入,形成用户端个数M:线程池最大线程数N的比例关系,其中M可以远远大于N.通过线程池可以灵活地调配线程资源,设置线程的最大值,防止因为海量并发接入导致线程耗尽。
伪异步IO模型图(图源网络,原出处不明):

采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,它的模型图如上图所示。当有新的用户端接入时,将用户端的 Socket 封装成一个Task(该任务实现java.lang.Runnable接口)投递到后台的线程池中进行解决,JDK 的线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行解决。因为线程池可以设置消息队列的大小和最大线程数,因而,它的资源占用是可控的,无论多少个用户端并发访问,都不会导致资源的耗尽和宕机。
伪异步I/O通信框架采用了线程池实现,因而避免了为每个请求都创立一个独立线程造成的线程资源耗尽问题。不过由于它的底层任然是同步阻塞的BIO模型,因而无法从根本上处理问题。
下面代码中演示了BIO通信(一请求一应答)模型。我们会在用户端创立多个线程依次连接服务端并向其发送"当前时间+:hello world",服务端会为每个用户端线程创立一个线程来解决。
用户端
1. `/**`2. `*` 3. `* @author 闪电侠`4. `* @date 2018年10月14日`5. `* @Description:用户端`6. `*/`7. `public class IOClient {`9. `public static void main(String[] args) {`10. `// TODO 创立多个线程,模拟多个用户端连接服务端`11. `new Thread(() -> {`12. `try {`13. `Socket socket = new Socket("127.0.0.1", 3333);`14. `while (true) {`15. `try {`16. `socket.getOutputStream().write((new Date() + ": hello world").getBytes());`17. `Thread.sleep(2000);`18. `} catch (Exception e) {`19. `}`20. `}`21. `} catch (IOException e) {`22. `}`23. `}).start();`25. `}`27. `}`</pre>服务端
1. `/**`2. `* @author 闪电侠`3. `* @date 2018年10月14日`4. `* @Description: 服务端`5. `*/`6. `public class IOServer {`8. `public static void main(String[] args) throws IOException {`9. `// TODO 服务端解决用户端连接请求`10. `ServerSocket serverSocket = new ServerSocket(3333);`12. `// 接收到用户端连接请求之后为每个用户端创立一个新的线程进行链路解决`13. `new Thread(() -> {`14. `while (true) {`15. `try {`16. `// 阻塞方法获取新的连接`17. `Socket socket = serverSocket.accept();`19. `// 每一个新的连接都创立一个线程,负责读取数据`20. `new Thread(() -> {`21. `try {`22. `int len;`23. `byte[] data = new byte[1024];`24. `InputStream inputStream = socket.getInputStream();`25. `// 按字节流方式读取数据`26. `while ((len = inputStream.read(data)) != -1) {`27. `System.out.println(new String(data, 0, len));`28. `}`29. `} catch (IOException e) {`30. `}`31. `}).start();`33. `} catch (IOException e) {`34. `}`36. `}`37. `}).start();`39. `}`41. `}`</pre>在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲少量系统解决不了的连接或者请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因而,我们需要一种更高效的 I/O 解决模型来应对更高的并发量。
NIO是一种同步非阻塞的I/O模型,在Java 1.4 中引入了NIO框架,对应 java.nio 包,提供了 Channel , Selector,Buffer等笼统。
NIO中的N可以了解为Non-blocking,不单纯是New。它支持面向缓冲的,基于通道的I/O操作方法。 NIO提供了与传统BIO模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。
假如是在面试中答复这个问题,我觉得首先一定要从 NIO 流是非阻塞 IO 而 IO 流是阻塞 IO 说起。而后,可以从 NIO 的3个核心组件/特性为 NIO 带来的少量改进来分析。假如,你把这些都答复上了我觉得你对于 NIO 就有了更为深入一点的认识,面试官问到你这个问题,你也能很轻松的答复上来了。
IO流是阻塞的,NIO流是不阻塞的。
Java NIO使我们可以进行非阻塞IO操作。比方说,单线程中从通道读取数据到buffer,同时可以继续做别的事情,当数据读取到buffer中后,线程再继续解决数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入少量数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
Java IO的各种流是阻塞的。这意味着,当一个线程调用 read() 或者 write() 时,该线程被阻塞,直到有少量数据被读取,或者数据完全写入。该线程在此期间不能再干任何事情了
IO 面向流(Stream oriented),而 NIO 面向缓冲区(Buffer oriented)。
Buffer是一个对象,它包含少量要写入或者者要读出的数据。在NIO类库中加入Buffer对象,表现了新库与原I/O的一个重要区别。在面向流的I/O中·可以将数据直接写入或者者将数据直接读到 Stream 对象中。尽管 Stream 中也有 Buffer 开头的扩展类,但只是流的包装类,还是从流读到缓冲区,而 NIO 却是直接读到 Buffer 中进行操作。
在NIO厍中,所有数据都是用缓冲区解决的。在读取数据时,它是直接读到缓冲区中的; 在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
最常用的缓冲区是 ByteBuffer,一个 ByteBuffer 提供了一组功能用于操作 byte 数组。除了ByteBuffer,还有其余的少量缓冲区,事实上,每一种Java基本类型(除了Boolean类型)都对应有一种缓冲区。
NIO 通过Channel(通道) 进行读写。
通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和Buffer交互。由于 Buffer,通道可以异步地读写。
NIO有选择器,而IO没有。
选择器用于使用单个线程解决多个通道。因而,它需要较少的线程来解决这些通道。线程之间的切换对于操作系统来说是昂贵的。 因而,为了提高系统效率选择器是有用的。

通常来说NIO中的所有IO都是从 Channel(通道) 开始的。
从通道进行数据读取 :创立一个缓冲区,而后请求通道读取数据。
从通道进行数据写入 :创立一个缓冲区,填充数据,并要求通道写入数据。
数据读取和写入操作图示:

NIO 包含下面几个核心的组件:
Channel(通道)
Buffer(缓冲区)
Selector(选择器)
整个NIO体系包含的类远远不止这三个,只能说这三个是NIO体系的“核心API”。我们上面已经对这三个概念进行了基本的阐述,这里就不多做解释了。
代码示例出自闪电侠的博客,原地址如下:
https://www.songma.com/p/a4e03835921a
用户端 IOClient.java 的代码不变,我们对服务端使用 NIO 进行改造。以下代码较多而且逻辑比较复杂,大家看看就好。
1. `/**`2. `*` 3. `* @author 闪电侠`4. `* @date 2019年2月21日`5. `* @Description: NIO 改造后的服务端`6. `*/`7. `public class NIOServer {`8. `public static void main(String[] args) throws IOException {`9. `// 1\. serverSelector负责轮询能否有新的连接,服务端监测到新的连接之后,不再创立一个新的线程,`10. `// 而是直接将新连接绑定到clientSelector上,这样就不用 IO 模型中 1w 个 while 循环在死等`11. `Selector serverSelector = Selector.open();`12. `// 2\. clientSelector负责轮询连接能否有数据可读`13. `Selector clientSelector = Selector.open();`15. `new Thread(() -> {`16. `try {`17. `// 对应IO编程中服务端启动`18. `ServerSocketChannel listenerChannel = ServerSocketChannel.open();`19. `listenerChannel.socket().bind(new InetSocketAddress(3333));`20. `listenerChannel.configureBlocking(false);`21. `listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);`23. `while (true) {`24. `// 监测能否有新的连接,这里的1指的是阻塞的时间为 1ms`25. `if (serverSelector.select(1) > 0) {`26. `Set<SelectionKey> set = serverSelector.selectedKeys();`27. `Iterator<SelectionKey> keyIterator = set.iterator();`29. `while (keyIterator.hasNext()) {`30. `SelectionKey key = keyIterator.next();`32. `if (key.isAcceptable()) {`33. `try {`34. `// (1)`35. `// 每来一个新连接,不需要创立一个线程,而是直接注册到clientSelector`36. `SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();`37. `clientChannel.configureBlocking(false);`38. `clientChannel.register(clientSelector, SelectionKey.OP_READ);`39. `} finally {`40. `keyIterator.remove();`41. `}`42. `}`44. `}`45. `}`46. `}`47. `} catch (IOException ignored) {`48. `}`49. `}).start();`50. `new Thread(() -> {`51. `try {`52. `while (true) {`53. `// (2) 批量轮询能否有哪些连接有数据可读,这里的1指的是阻塞的时间为 1ms`54. `if (clientSelector.select(1) > 0) {`55. `Set<SelectionKey> set = clientSelector.selectedKeys();`56. `Iterator<SelectionKey> keyIterator = set.iterator();`58. `while (keyIterator.hasNext()) {`59. `SelectionKey key = keyIterator.next();`61. `if (key.isReadable()) {`62. `try {`63. `SocketChannel clientChannel = (SocketChannel) key.channel();`64. `ByteBuffer byteBuffer = ByteBuffer.allocate(1024);`65. `// (3) 面向 Buffer`66. `clientChannel.read(byteBuffer);`67. `byteBuffer.flip();`68. `System.out.println(`69. `Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());`70. `} finally {`71. `keyIterator.remove();`72. `key.interestOps(SelectionKey.OP_READ);`73. `}`74. `}`76. `}`77. `}`78. `}`79. `} catch (IOException ignored) {`80. `}`81. `}).start();`83. `}`84. `}`</pre>为什么大家都不愿意用 JDK 原生 NIO 进行开发呢?从上面的代码中大家都可以看出来,是真的难用!除了编程复杂、编程模型难之外,它还有以下让人诟病的问题:
JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%
项目庞大之后,自行实现的 NIO 很容易出现各类 bug,维护成本较高,上面这一坨代码我都不能保证没有 bug
Netty 的出现很大程度上改善了 JDK 原生 NIO 所存在的少量让人难以忍受的问题。
AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后端解决完成,操作系统会通知相应的线程进行后续的操作。
AIO 是异步IO的缩写,尽管 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。(除了 AIO 其余的 IO 类型都是同步的,这一点可以从底层IO线程模型解释。
查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。