Java BIO/NIO(Non-blocking I/O)详解
IO模型
IO模型简单点说就是使用什么样的通道进行数据的发送和接收,这种通道的特性决定了程序通信的性能, 比如这个通道是否是异步还是同步,是阻塞还是非阻塞,是否有缓存,是单向通道还是双向通道。
Java中IO模型
Java中共支持3中网络IO模型:BIO,NIO,AIO。
1. BIO:
同步并阻塞(传统的阻塞型),服务器实现模式为一个连接一个线程,就是客户端发送连接请求时候,服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销,比如socket阻塞在 accept 等待连接。
BIO可以通过多线程的方式来改善并发性能,不过底层还是一个线程对应一个连接。
2. NIO(Non-blocking I/O,在Java领域,也称为New I/O,因为是原始IO之后出现的),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,服务器实现模式为一个线程处理多个请求(连接),就是客户端发送的连接请求都会注册到多路复用器上,多路复用器轮训到连接有I/O请求就进行处理。NIO现在已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
Netty目前是基于NIO模型来实现的。
NIO在JDK1.4就引入了,不过还存在一些问题,在后续的版本中一直在修复,知道JDK1.8才稳定下来。
NIO并不是在原始的IO基础上扩展的,而是从新设计了一套IO标准,为什么要重新设计呢?其实就是作为原始IO的补充,主要就是为了应对高并发,高性能IO的场景。
3. AIO(NIO.2):
异步非阻塞,AIO引入异步通道的概念,采用了Proactor模式,简化了程序的编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多而且连接世界比较长的应用。
BIO,NIO,AIO适用场景:
1. BIO方式适用于连接数比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限的应用中,JDK1.4以前是唯一的选择,但程序简单容易理解。
2. NIO方式适用于连接数目较多且连接比较短的架构,比如聊天服务器(长连接),弹幕系统,服务器间通讯,编程比较复杂,从JDK1.4开始支持。
3. AIO方式适用于连接数目较多且连接比较长的重操作的架构,比如相册服务器,充分调用OS参与并发的操作,编程比较复杂,从JDK7开始支持。
BIO模型
BIO是基于阻塞IO实现的。
BIO工作机制
1. 首先服务端启动一个ServerSocket用于监听客户端的请求。
2. 客户端发起建立连接请求,启动Socket对服务器通信,默认情况下服务器需要对每个客户端建立一个线程与之通信。
3. 服务端在接收到客户端的请求后会创建一个新的线程,客户端发出请求后,先询问服务器是否有线程响应,如果没有则会等待,或者被拒绝。
4. 上面服务端新创建的线程会和客户端建立Socket连接,然后响应客户端成功信息,此时客户端已经知道已经成功和服务端成功连接,随时可以向服务端发送数据。
5. 服务端会一直等待客户端发来数据,此时这个服务端线程相当于阻塞等待客户端发来的数据。
BIO缺点:
1. 由于基于阻塞时IO模型,就会导致在大量并发的情况下,需要创建大量的线程,同时如果客户端一直不发送请求,会导致服务端线程一直存在。
2. 客户端线程和服务端线程的数量比是1:1,一个客户端就对应一个服务端线程,所以会造成弹性伸缩能力差,由于线程是Java虚拟机非常宝贵的资源,当线程数膨胀后,容易造成堆栈溢出,造成创建线程失败,机器宕机,再强大的服务器也承受不住同时成千上万的线程。
3. 服务端存在大量线程的创建,销毁,调度会消耗很多的资源。
Java BIO就是传统的java.io编程,其相关接口和类在java.io中。
伪异步I/O
当有新的客户端接入的时候,将客户端的socket封装成一个Task丢入到后端的一个线程池中处理。线程池维护一个消息队列和N个活跃的线程,对消息队列中的任务进行处理,当有M个客户端接入的时候,服务端将会创建具有N个线程的线程池来对客户端请求进行处理。由于线程池可以设置消息队列的大小和线程池最大线程数,因此它的占用资源是可控的,无论多少个客户端的访问都不会导致服务端的资源耗尽。
这种通过线程池或者消息队列实现1个或多个线程处理N个客户端连接的模型,由于它的底层通信机制仍然会用同步阻塞I/O,所以被称为“伪异步”。
但是也有缺点就是,当有大量客户端并发访问时候,随着并发访问量的增加会导致线程池阻塞。
下图是伪异步IO通信模型:
和BIO最大区别是伪异步IO的服务端不会为每个客户端Socket创建一个独立线程,由一个独立的线程池统一的维护线程的接入。
NIO模型
相较于BIO,NIO要复杂写,NIO是基于非阻塞式IO构建的。
NIO工作基本思路
NIO多了一个Selector组件,是NIO核心,主要作用就是管理与所有客户端建立的连接,负责监听注册上面的事件,比如有新的连接接入,或者某个连接已经就绪,有可读消息。一旦监听到事件触发,就会调用事件所对应的处理器来完成对事件的响应。
1. 首先在Selector中注册建立连接的事件。
2. 客户端向服务器发起建立连接请求就会监测到建立连接的事件。
3. 触发了建立连接事件,就会启动建立连接事件对应的处理器(Acceptor Handler),也就是对应的方法,注意一点和BIO不同这里的Acceptor Handler不是一个线程,而是一个方法,可以依次处理多个请求。
4. 处理器(Handler)会创建与客户端的连接(Socket),并且响应与客户端连接成功的信息。
5. 处理器(Handler)会将上面新创建的Socket连接注册到Selector上,并且注册连接为可读(Read)事件。
6. 客户端再次发起请求到Selector,Selector会监听到可读事件,然后Selector启动连接读写处理器(Read&Write Handler)。
7. 读写处理器(Read&Write Handler)对发送过来的请求来处理相应的读写业务逻辑,并直接响应客户端,最后在将可读事件注册到Selector上。
NIO模型对比BIO模型:
1. NIO模型是一种非阻塞IO,避免了BIO那种一个客户端就需要服务器建立与之对应的一个线程。
2. 弹性伸缩能力加强了,因为服务器端不再是多个线程,而是一个线程可以处理多个请求。
3. 因为单线程所以节省资源,也不用频繁创建,销毁,切换线程,性能大大提升。
NIO三个实现类
NIO有三个主要组成部分
1. Channel:通道
1)双向性
通道是信息传输的通道,是JDK对输入输出的抽象,可以类比BIO中流的概念,但是和流不同的是,流只是单向的,有InputStream,OutputStream,而通道支持双向,即可读也可写。
2)非阻塞
传统的流是阻塞模式,而通道是非阻塞式,所以这个特点,通道构成了NIO网络的基础。
3)操作唯一性
基于数据块的操作,只能通过buffer来操作,具体可以看下面的buffer内容。
Channel实现类
文件类:FileChannel,用于文件的读写。
UDP类:DatagramChannel,用于UDP数据读写。
TCP类:ServerSocketChannel / SocketChannel,基于TCP数据的读写。
2. Buffer:缓冲区
Buffer是NIO Api中新加入的类,用于和NIO Channel进行交互,可以读写Channel中的数据。Buffer本质上是一块可以读写的内存区域,这块内存区域被NIO包装成NIO Buffer对象,并提供了一组方法来方便的操作这块内存。在NIO中所有数据都是Buffer处理的,读取/写入数据时候都是直接读取或者写入到缓冲区中,任何时候访问NIO中的数据都是通过Buffer操作。
Buffer属性:
Buffer有四个属性,一切对Buffer的操作都是对这四个属性的操作。
1)Capacity:容量
标明Buffer最大容量(单位:字节),只能往里写capacity个byte、long,char等类型,一旦超过这个数量必须将其清空后才能继续写数据往里写数据。
2)Position
当你写数据到Buffer中时,Position表示当前的位置。初始的position值为0,当一个byte、long等数据写到Buffer后, Position会向前移动到下一个可插入数据的Buffer单元。Position最大可为capacity – 1,相当于数组的下标最大值。当读取数据时,也是从某个特定位置读,当将Buffer从写模式切换到读模式,position会被重置为0,当从Buffer的Position处读取数据时,Position向后移动到下一个可读的位置。
3)Limit:上限
在写模式下,Buffer Limit表示最多可以往Buffer中写入多少数据,此模式下Limit = Capacity。当切换到读模式时,Limit表示最多可以从Buffer中读取多少数据,此时Limit会被设置成写模式下的Position值。
4)Mark:标记
Mark存储一个特定的Position位置,之后可以调用Buffer的reset方法可以恢复到这个Position位置。
Buffer核心方法的使用
网路编程中使用字节类型的ByteBuffer比较多,下面就介绍ByteBuffer相关方法。
1. 调用allocate来初始化
/** * 初始化长度为10的byte类型的buffer */ ByteBuffer.allocate(10);
此时Position等于0,Limit和Capacity都等于10。
2. 写入数据
使用put方法向byteBuffer中写入三个字节:
byteBuffer.put("abc".getBytes(Charset.forName("UTF-8")));
abc写到下标为0 1 2的地方,此时Position指向3,说明第三个位置可以插入数据,Limit和Capacity还是等于10。
3. 切换读模式
将byteBuffer从写模式切换成读模式
byteBuffer.flip();
Position变成0,Limit变成了3,Limit代表从这个buffer中最多可以读取的数据数量。
4. 读取数据,从byteBuffer中读取一个字符。
byteBuffer.get();
5. 使用mark方法,记录当前Position的位置
byteBuffer.mark();
6. 先调用get方法读取下一个字节,接着在调用reset方法将Position位置重置到mark位置上。
byteBuffer.get();
byteBuffer.reset();
先调用get方法读取2这个位置,此时Position会移动到下标为2的位置,接着调用reset方法会将原来的Position位置重置为上一次Mark的位置,也就是Position=1。
7. 调用clear方法将所有属性重置。
byteBuffer.clear();
3. Selector:选择器或多路复用
Selector是NIO网络编程的基础,整个NIO都是建立在非阻塞IO和多路复用器之上的。用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写状态。如此可以实现单线程管理多个channels,从而可以管理多个网络链接。
通俗的说就是Selector会不断的轮询它上面的Channel,如果某个Channel上发生了读或者写事件,这个Channel就处于就绪状态,就会被Selector轮询出来,然后被selectionKey可以获取就绪Channel集合然后进行后续IO操作。由于JDK采用epoll而不是select模型,所以不会受到最大连接数的限制,可以接入大量的客户端。
下面是Selector主要方法:
//使用静态方法 创建Selector对象 Selector selector = Selector.open(); // 将channel注册到Selector上,并传入希望监听的事件,监听读就绪事件 SelectionKey selectionKey = channel.register(selector,SelectionKey.OP_READ); // 阻塞等待channel有就绪事件发生,调用select方法,不同的系统底层支持不同,如果发现已经就绪就返回就绪的个数,如果没有就会一直阻塞 int selectNum = selector.select(); // 获取发生就绪事件的channel集合 Set<SelectionKey> selectedKeys = selector.selectedKeys();
1) 四种就绪常量(SelectionKey)
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
NIO实现步骤
1. 创建Selector。
2. 创建SelectorSocketChannel,并绑定监听端口。
3. 将Channel设置为非阻塞模式。
4. 将Channel注册到Selector上,监听连接事件。
5. 循环调用Selector的select方法,检测就绪情况。
6. 调用selectedKeys方法获取就绪channel集合。
7. 判断就绪事件种类,调用相应的业务处理方法。
8. 根据业务需要是否再次注册监听事件,重复执行第三步操作。
NioServer.java
package com; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.util.Iterator; import java.util.Set; /** * NIO服务端 */ public class NioServer { public void start() throws IOException { // 创建Selector Selector selector = Selector.open(); // 通过SelectorSocketChannel创建channel, ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 为channel绑定监听端口,所有到客户端到对该端口到接入都是通过这里serverSocketChannel处理 serverSocketChannel.bind(new InetSocketAddress(8000)); // 这一步很重要,设置channel为非阻塞模式,才能被Selector多路复用器来统一管理 serverSocketChannel.configureBlocking(false); // 将channel注册到Selector上,并监听连接事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("服务器启动成功,开始监听"); // 循环等待监听新接入到连接请求 for (;;) { // 获取可用到channel数量。下面select本身是一个阻塞方法,只有当注册到上面serverSocketChannel中所监听到到事件已经就绪了才会返回 int readyChannels = selector.select(); if (readyChannels == 0) { continue; } // 获取注册到selector上到可用集合。该方法会返回一个SelectionKey到set集合 Set<SelectionKey> selectionKeys = selector.selectedKeys(); // 遍历上面集合 Iterator iterator = selectionKeys.iterator(); while (iterator.hasNext()) { // selectionKey实例 SelectionKey selectionKey = (SelectionKey) iterator.next(); // 还需要移除当前selectionKey,也就是上面到Set<SelectionKey>中 // 因为当selector监听到一个channel已经就绪后,会放入到Set<SelectionKey>,下次又检测到它就绪后还会将其通过selector.selectedKeys()方式放入到Set<SelectionKey>中,否则集合会越来越多 iterator.remove(); } } // 根据不同到就绪状态,调用对应 // 如果是接入事件 // 如果是可读事件 } /** * 接入事件处理器 */ private void acceptHandler() { } /** * 接入事件处理器 */ private void readyHandler() { } public static void main(String[] args) { NioServer nioServer = new NioServer(); } }
原生NIO缺点
1. 类库和API繁杂,需要熟练掌握。
2. 入门门槛高,比如需要掌握Java多线程,因为NIO使用了Reactor模式。所以必须对多线程和网络编程非常熟悉才能开发出高质量的NIO程序。
3. 工作量和难度比较大,比如客户端会出现断连,重连,网络抖动,网络拥塞,半包,异常码流等问题需要处理。
4. JDK NIO Bug,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU飙升到100%,在JDK1.7中官方声称已经修复,其实只是降低了该问题出现的概率,并没有根本去解决,也就是说原生NIO是存在缺陷的。
AIO模型
AIO:异步非阻塞IO。AIO是连接注册读写事件和回调函数。读写方法异步,同时它是主动通知程序的。
模型总结:
1. 同步阻塞:BIO。
2. 同步非阻塞:NIO。
3. 异步非阻塞:AIO。