2.1 传统的BIO编程
采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答通信模型。
如果不创建线程,还是在主线程中处理请求,则整个服务端是单线程处理能力, 待第一个客户端请求关闭后,处理第二个客户端请求。
2.2 伪异步I/O编程
当有新的客户端接入时,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中进行处理,JDK 的线程池维护一个消息队列和N个活跃线程,对消息队列中的任务进行处理。
public int read(byte b[]) throws IOException{
return read(b,0,b.length);
}
当对Socket的输入流进行读取操作的时候,它会一直阻塞下去,直到发生如下三种事件。
有数据可读;
可用数据已经读取完毕;
发生空指针或者I/O异常。
读和写操作都是同步阻塞的,阻塞的时间取决于对方I/O线程的处理速度和网络I/O的传输速度。
2.3 NIO编程
非阻塞I/O(Non-block I/O)
与Socket类和ServerSocket类相对应,NIO也提供了SocketChannel和ServerSocketChannel两种不同的套接字通道实现。
2.3.1 NIO类库简介
1.缓冲区 Buffer
在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer),也可以使用其他种类的数组。
BtyeBuffer:字节缓冲区 CharBuffer 字符缓冲区
ShortBuffer:短整型缓冲区 IntBuffer:整型缓冲区
LongBuffer:长整型缓冲区 FloatBuffer:浮点型缓冲区
DoubleBuffer:双精度浮点型缓冲区
2.通道Channel
Channel是一个通道,它就像自来水管一样,网络数据通过Channel读取和写入。
Channel是全双工的,同时支持读写操作。
自顶向下看,前三层主要是Channel接口,用于定义它的功能,后面是一些具体的功能类(抽象类)。
实际上Channel可以分为两大类,用于网络读写的SelectableChannel和用于文件操作的FileChannel。
本书涉及的ServerSocketChannel和SocketChannel都是SelectableChannel的子类。
3.多路复用器Selector
多路复用器提供选择已经就绪的任务的能力,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面发生读或者写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
一个多路复用器Selector可以同时轮询多个Channel,epoll可以支持接入成千上万的客户端。
2.3.2 NIO服务端序列图
步骤1:
打开ServerSocketChannel,用于监听客户端的连接,它是所有客户端连接的父管道。
步骤2:
绑定监听端口,设置连接为非阻塞模式。
步骤3:
创建Reactor线程,创建多路复用器并启动线程。
步骤4:
将ServerSocketChannel注册到Reactor线程的多路复用器Selector上,监听ACCEPT事件。
步骤5:
多路复用器在线程run方法的无限循环体内轮询准备就绪的Key。
步骤6:
多路复用器监听到有新的客户端接入,处理新的接入请求,完成TCP 三次握手,建立物理链路。
步骤7:
设置客户端链路为非阻塞模式。
步骤8:
将新接入的客户端连接注册到Reactor线程的多路复用器上,监听读操作,读取客户端发送的网络消息。
步骤9:
异步读取客户端请求消息到缓冲区。
步骤10:
对ByteBuf进行编解码,如果有半包消息指针reset,继续读取后续的报文,将解码成功的消息封装成Task,投递到业务线程池中,进行业务逻辑编排。
步骤11:
将POJO对象encode成ByteBuffer,调用SocketChannel的异步write接口,将消息异步发送给客户端。
2.3.4 NIO 客户端序列图
步骤1:打开SocketChannel,绑定客户端本地地址。
步骤2:设置SocketChannel为非阻塞模式,同时设置客户端连接的TCP参数。
步骤3:异步连接服务端。
步骤4:判断是否连接成功,如果连接成功,则直接注册状态位到多路复用器中,如果当前没有连接成功(异步连接,返回false,说明客户端已经发送sync包,服务端没有返回ack包,物理链路还没有建立)。
步骤5:向Reactor线程的多路复用器注册OP_CONNECT状态位,监听服务端的TCP ACK 应答。
步骤6:创建Reactor线程,创建多路复用器并启动线程。
步骤7:多路复用器在线程run方法的无限循环体内轮询准备就绪的Key。
步骤8:接收connect事件进行处理。
步骤9:判断连接结果,如果连接成功,注册读事件到多路复用器。
步骤10:注册读事件到多路复用器。
步骤11:异步读客户端请求消息到缓冲区。
步骤12:对ByteBuffer进行编解码,如果有半包消息接收缓冲区Reset,继续读取后续的报文,将解码成功的消息封装成Task,
投递到业务线程池中,进行业务逻辑编排。
步骤13:将POJO对象encode成ByteBuffer,调用SocketChannel的异步write接口,将消息异步发送给客户端。
使用NIO编程的优点:
1)客户端发起的连接操作是异步的,可以通过在多路复用器注册OP_CONNECT等待后续结果,不需要像之前的客户端那样被同步阻塞。
2)SocketChannel的读写操作都是异步的,如果没有可读写的数据它不会同步等待,直接返回,这样I/O通信线程就可以处理其它的链路,不需要同步等待这个链路可用。
3)线程模型的优化:由于JDK的Selector在Linux等主流操作系统上通过epoll实现,它没有连接句柄数的限制,因此,它非常适合做高性能、高负载的网络服务器。
2.4 AIO编程
异步通道提供以下两种方式获取操作结果:
通过java.util.concurrent.Future类来表示异步操作的结果;
在执行异步操作的时候传入一个java.nio.channels.CompletionHandler接口的实现类作为操作完成的回调。
2.5 4种I/O的对比
同步阻塞I/O(BIO)
伪异步I/O
非阻塞I/O(NIO)
异步I/O(AIO)
客户端个数:I/O线程
1:1
M:N(其中M可以大于N)
M:1(1个I/O线程处理多个客户端连接
M:0(不需要启动额外的I/O线程,被动回调
I/O类型(阻塞)
阻塞I/O
阻塞I/O
非阻塞I/O
非阻塞I/O
I/O类型(同步)
同步I/O
同步I/O
同步I/O(I/O多路复用)
异步I/O
API使用难度
简单
简单
非常复杂
复杂
调试难度
简单
简单
复杂
复杂
可靠性
非常差
差
高
高
吞吐量
低
中
高
高
2.6 选择Netty的理由
2.6.1不选择Java原生NIO编程的原因
- NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。
- 需要具备其他的额外技能做铺垫,例如熟悉Java多线程编程。
- 可靠性能力补齐,工作量和难度都非常大。
- JDK NIO的BUG。
2.6.2为什么选择Netty
- API使用简单,开发门槛低;
- 功能强大,预置了多种编解码功能,支持多种主流协议;
- 定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展;
- 性能高,通过与其他业界主流的NIO框架对比,Netty的综合性能最优;
- 成熟,稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;
- 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会加入;
- 经历了大规模的商业应用考验,质量得到验证。Netty在互联网、大数据、网络游戏、企业应用、电信软件等众多行业已经得到了成功商用,证明它已经完全能够满足不同行业的商业应用了。
2.7 总结
通过一个简单的demo开发,即时间服务器程序,让大家熟悉传统的同步阻塞I/O、伪异步I/O、非阻塞I/O(NIO)和异步I/O(AIO)的编程和使用差异。