前言
最近花了接近四五天来啃这本书,一方面是为了深入地了解JAVA里的NIO知识,另一方面则是因为导师项目的需要——需要编写一个接受设备数据的服务端程序,而且提供的设备还比较奇葩,不采用mqtt之类的基于订阅-发布模式的协议(因为这样的协议一般会配套高性能消息队列中间件使用,对服务端程序的要求就没那么高了)就算了,还采用了UDP通信,这些都算了,竟然还采用了设备端做服务器的方式进行通信——这多浪费固定IP资源啊(实话是:我还得主动发起连接,代码又变多了QAQ)
回到正题。看完这本书,我的第一个感觉是,Netty也不过是对JDK NIO类库的封装,提供了一系列实用的类库简化NIO编程——比如,可以自动处理半包读/写的问题,集成了SSL编解码包,继承了WebSocket&Http协议栈等等等,同时为用户开放了丰富的接口,用户可以自行定制消息的处理方式。
这本书的前半段,在讲基础的NIO知识和Netty应用;后半段,在讲Netty的源码。因此这是一本行知合一的书,总体来说挺好理解。既可以使读者快速上手Netty,也可以让有兴趣的人进一步了解Netty的工作原理。
----序2----
经过了春招,其实感觉Netty这里不算是基础知识,一般是加分项。对于大部分的Java开发岗来说,Netty只要能大致讲出有哪些组成部分即可,然后就是基本的AIO NIO对比,计网基础,线程模型等
1. Java的IO演进之路
JDK1.4发布,引入了异步io,也就是nio;
JDK1.7发布,引入了nio2.0,也就是真正的aio。
1.1 IO基础入门
JDK1.4之前的版本在开发高性能IO程序的时候,会面临一些困难和挑战:
没有数据缓冲区,IO性能存在问题;
没有Channel,只有输入输出流;
同步阻塞式通信(BIO)通信,导致通信线程长时间被阻塞;
支持的字符集有限,硬件可移植性不好。
1.1.1 Linux网络IO模型简介
由于Linux将所有外部设备都看作一个文件,而socket也是如此。
根据UNIX网络编程对IO模型的分类,UNIX提供了5中IO模型,分别如下:
1.阻塞IO模型:缺省状态下,所有的IO都是阻塞的。例如,在调用recvfrom方法时,其系统调用只有当数据包到达且被复制到应用的缓冲区或者发生错误时才会返回,期间会一直等待。
2.非阻塞IO模型:recvfrom被调用时,如果缓冲区没有数据,直接返回一个错误码,而不会阻塞等待错误发生或数据包到达。
3.IO复用模型:Linux提供select&poll,通过将一个或多个fd传给s&p系统调用,虽然s&p操作也会阻塞,但是可以帮我们探测多个fd是否处于就绪状态。s&p是采用顺序扫描fd是否就绪,因此效率较低,所以有最大数量的限制。此外,还提供了epoll系统调用,其使用基于事件驱动的方式代替顺序扫描,因此性能更高,当有fd就绪时,立刻回调函数。
4.信号驱动IO模型:通过系统调用注册信号,当数据就绪时,为该进程生成一个SIGIO信号。程序中可以注册这个信号的回调函数进行处理
5.异步IO:通知内核启动某个动作,并在内核完成后通知用户程序。
信号驱动和异步IO的区别在于,前者由内核通知何时可以开始一个IO操作,后者通知我们IO操作何时完成。
1.1.2 IO多路复用技术
通过把多个IO的阻塞复用到同一个select的阻塞上。
epoll相比于select的改进在于:
1.一个进程打开的socket描述符不受限制,仅受限于操作系统的最大文件句柄数;
2.IO效率不会随着fd数目增加而线性下降:当拥有很大的socket集合时,每一时刻只有少量socket是活跃的,但是select&poll会线性扫描全部集合,导致效率线性下降。而epoll通过每个fd上的callback函数实现,只对活跃的socket进行操作。在这一点上,epoll实现了一个伪AIO
3.使用mmap加速内核和用户空间的消息传递:不将内核空间里的数据复制到用户空间,而是通过mmap公用同一块内存实现的
需要注意的是:epoll只是Linux上的实现,其它的操作系统也有类似的克服select缺点的实现。
1.2 JAVA 的IO演进
JDK1.4之前,JAVA的IO全是BIO,这种一请求一应答的通信模型简化了上层应用的开发,但是性能存在巨大瓶颈。因此,大型应用服务器都采用C/CPP开发,因为它们可以直接使用操作系统提供的IO多路复用或者AIO能力
JDK1.4新增了java.nio包,提供了如下的类和API:
进行异步IO操作的缓冲区ByteBuffer等;
进行异步IO操作的管道Pipe;
进行各种IO(异步&同步)操作的Channel,包括ServerSocketChannel&SocketChannel;
多种字符集编解码能力;
实现非阻塞IO操作的多路复用器Selector;
文件通道FileChannel(但是依然是阻塞的);
基于Perl实现的正则表达式类库;
但是它仍有不完善的地方,特别是对文件系统的处理能力仍然不足:
没有统一的文件属性(如读写权限);
API能力较弱,如目录级联创建和递归遍历都需要自己实现;
底层存储系统的高级API无法使用;
所有**文件**操作都是同步阻塞调用,不支持异步文件读写操作。
JDK1.7发布,对原有的NIO类库进行了升级,被称为NIO2.0。主要提供了以下三个方面的改进:
提供批量获取文件属性的API;
提供AIO功能,支持针对文件和网络套接字的异步IO;
完成JSR-51定义的通道功能,包括对配置和多播数据包的支持等。
2.NIO入门
本章基于NIO2.0的使用进行详细说明。
2.1 传统的BIO编程
BIO通信步骤大致如下:
服务器绑定端口,等待客户端连接;
客户端指定服务器ip:port进行连接;
服务端开启一个新的线程来处理和这个客户端的连接,然后主线程继续监听端口等待连接;
连接建立成功,双方通过输入和输出流进行同步阻塞式通信(也即:一个线程一个连接,双方一问一答);
等待某个事件发生(如一方主动断开连接,或者发送断开信号等),关闭输入输出流,关闭socket,销毁线程。
该模型最大问题是线程与并发数是1:1的关系,当客户端并发量增加时,系统性能急剧下降。此外,内核的线程堆栈空间是有限制的,继续增大并发量会导致新线程无法创建。
2.2 伪异步IO编程
为了改进一线程一连接的模型,演进出了一种通过线程池或消息队列实现的一线程对多个客户端的模型,但是其底层还是同步阻塞IO。
这种方法可以通过线程池对线程资源进行灵活分配,设置线程的最大数量,避免海量并发导致线程耗尽。
但是由于在网络连接中,大多数线程都是不活跃的。BIO通信会导致多数线程都阻塞在等待数据上。如果一方由于异常断开连接(不是主动断开连接,发送4次数据包的那种),另一方就会苦苦等待,浪费资源。这样,最终会造成队列中等待的连接频繁出现连接超时的问题。
再者,当调用OutputStream.write()方法时,也会发生阻塞,直到所有字节都写入缓冲区或者发生异常才会返回。如果接收方处理缓慢,不能及时从TCP缓冲区读取数据,这会导致发送方的TCP wsize一直减小,最终发送方的输出缓冲区满,write出错。
2.3 NIO编程
相对于BIO中的ServerSocket&Socket,NIO中也有对应的ServerSocketChannel&SocketChannel。这两种新增的通道都支持阻塞和非阻塞模式。
2.3.1 NIO类库简介
提供了高速的、面向块的IO。由于提供了JAVA语言层面的API,因此无需native代码即可利用低级优化(即系统底层的特性)。
缓冲区Buffer
Buffer是一个对象,包含着要写入或者读出的数据。任何使用要访问NIO中的数据,都要通过缓冲区。实际上操作系统底层全都是缓冲区
ByteBuffer是最常见的缓冲区,实际上是一个字节数组。实际上每个基本类型都有对应的一种缓冲区。
通道Channel
通道的功能与BIO中流的地位相同,可以通过它进行数据的读写。它是双向的,而流是单向的。Channel的工作方式与操作系统的通道相似,因此可以更好地映射底层API
多路复用器Selector
Selector会不断询问注册在其上的Channel,如果某个Channel上有新的连接、读、写事件,会被选出,放在已就绪的集合中。
经典NIO客户端/服务端编程范例
需要注意:
非阻塞Socket的read操作是非阻塞的,写是异步的。
客户端编程中,需要注意,发起连接是异步的,可能连接没有立即建立成功,因此需要注册一个OP_CONNECT事件,这是比服务端编程稍微多一点的东西。
下方为服务端编程范例:
public void selector() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);//分配一个buffer,长度为1024字节
Selector selector = Selector.open();//获得一个多路复用器
ServerSocketChannel ssc = ServerSocketChannel.open(); //获取一个带有channel的socket
ssc.configureBlocking(false);//设置非阻塞。非阻塞socket进行读操作都是非阻塞的
ssc.socket().bind(new InetSocketAddress(8080));//绑定socket
ssc.register(selector, SelectionKey.OP_ACCEPT);//向多路复用器注册监听事件
while(selector.select() > 0) {
//这一步会阻塞线程,直到有新的IO事件到来。但是可以把条件改成true,然后通过下一句来判断是否有新的IO事件。这样就可以不阻塞线程。此外select还有一个带timeout参数的实现。
Set<SelectionKey> selectKeys = selector.selectedKeys();//这一步获取所有就绪的IO事件。如果不使用select()阻塞线程,则可以通过这一句来判断是否有就绪的事件,这样可以做到完全不阻塞线程。
for(SelectionKey key:selectKeys) {
if((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
// 接受客户端连接,创建新的SocketChannel,设置为非阻塞模式,并且将它的读事件注册到多路复用器内
// 这里忽略掉这部分代码,强调一下,这里会把每个连接都注册OP_READ事件
} else if((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
// 这边处理和每个客户端的连接中收到的数据
}
}
}
}
2.4 AIO编程
NIO2.0引入了异步通道的概念,并提供了异步文件通道和异步Socket通道的实现。
NIO2.0是真正的异步非阻塞IO,对应网络编程中的事件驱动IO(AIO)。它不通过多路复用器对注册的通道进行轮询即可实现异步读写,因此简化了NIO编程模型。
简单来说,需要指定上下文和具体事件的handler。当事件发生时,handler会收到上下文,上下文中可以存储服务器套接字等信息。
AIO中的连接、读和写全都是异步的。甚至连读写缓冲区从用户态到内核态的拷贝也是异步的。可以从后面的AIO NIO示例代码中看到:在普通的NIO中,比如accept(),其实是阻塞的,需要等客户端的第三次握手到来才能返回;再比如write()和read()函数,在复制内存的过程中也是阻塞的。其实NIO,只是无需阻塞在等待对方发送的这个环节(就好像有一个人跟你微信聊天,聊着聊着突然没声音了,你可以转去跟别人聊天,等这个人回复你了,你再回他就行),毕竟这个环节耗时最长。但是一些耗时较短的IO过程还是阻塞的,比如内核到用户态的内存复制,等待第三次握手的请求等。
2.5 四种IO对比
以下为本书的IO概念澄清
异步非阻塞IO
虽然很多人称JDK1.4提供的NIO为异步非阻塞IO,但是实际上按照UNIX的概念来区分,只是非阻塞IO罢了。
JDK1.5 update10将底层的select替换成了epoll,性能得到了提高,但是非阻塞IO的本质没有改变。
多路复用器Selector
虽然有的书会称之为选择器,但是多路复用器才能反映出它的功能和特点。
伪异步IO
在通信(也就是网络IO)线程和业务线程之间做一个缓冲区,用于隔离IO线程和业务线程的直接访问,这样业务线程就不会被IO线程阻塞。
宏观上来看的确是异步IO,但是由于本书所讲内容的关系(映射到操作系统级的IO方式),将其称为伪异步IO,因为它底层还是同步IO
2.6 选择Netty的理由
开发出高质量的NIO程序并不简单。抛开NIO的复杂性和BUG不谈,处理额外的网络断开、安全认证、半包读写等复杂问题都非常复杂。此外,NIO是一个IO线程处理多条链路,因此调试和跟踪非常麻烦。
而Netty是业界最流行的NIO框架之一,已经得到了非常多的成功商业实践。它主要有如下优势:
API使用简单;
功能强大,可以做C也可以S,可以UDP也可以TCP,还支持异步文件传输;
可定制,对通信框架进行扩展;
性能高;
成熟且稳定;
应用广泛,社区活跃,BUG可以及时修复,新功能加入也更快。
Netty应用部分
接下来的几章内容全都是偏向Netty应用的。实际上对于应用来说,只要把握应用的基本框架,剩下的就是查手册,挑选合适的类填入框架中罢了。因此对于应用部分,除了第3章比较详细地给出了CS双方的代码之外,后续都是非常快地掠过了,等到真正需要使用的时候再查。
3.Netty入门应用
使用Netty搭建一个基本的时间服务器,其作用是接到客户端的任意请求,都会打印出来,并返回客户端当前的服务器时间。
而客户端则更加简单,发起异步连接,当连接成功时,向服务端发送一个简单的消息。对于服务端发送过来的消息,打印出来即可。
所以总体的流程就是:服务端启动并监听端口,客户端连接服务器并发送一条“abc”消息,服务端接收并打印这条消息并回复客户端当前的时间,客户端接收并打印这条消息。
以下的代码展现了服务端、客户端的代码
public class TimeServer {
private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//将TimeServerHandler的实例添加到pipeline中。pipeline实际上是职责链模式的变种。
//可以继续添加其它Handler
ch.pipeline().addLast(new TimeServerHandler());
}
}
//这里继承的是ChannelInboundHandlerAdapter,说白了是负责处理读事件(包括active、accept、read三大事件)
class TimeServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf byteBuf =