传统BIO网络编程知识点总结与Java NIO简介
本篇作为《Netty高并发编程及性能调优实战经验分享》的补充。
本篇内容包括:
- 传统BIO编程知识点总结
- Java NIO简介
传统BIO编程知识点总结
下图为我用印象笔记所做的《Netty高并发编程与性能调优》思维导图。这是我对网络编程的总结吧,我算是很早就开始接触Socket编程的,在大学就学过Socket编程,虽然那时候学的是C#,但原理是一样的,不分语言。
我参加过中国软件杯,做的就是一个“同步手绘板”应用,使用Socket实现移动端和PC端同步绘制,虽然没得奖。专科实习的时候,做过一个监控摄像头设备的系统,比如远程控制拍照角度、自动拍照、获取电量等。从第一家公司辞职之后,也自己做了一个模仿微信的聊天系统。升本期间,了解到有NIO,Netty这些云云的存在,也跟风学了一把,后面毕设也用Netty实现一个恋爱APP的私密聊天功能的服务器。对BIO、NIO也算有点了解,最近为了项目的性能调优,也啃了好久的Netty的源码,虽然现在也只看懂了些皮毛,不过对本次性能调优还是非常有帮助的。
我对传统BIO编程的一点总结,几个我认为最重要的知识点。
知识点一:Socket套接字复用池
不管任何一门高级语言,Socket编程都是服务端一个线程处理客户端的一个连接,因为读写都是阻塞的。为避免频繁的线程创建和消毁,都会使用线程池来实现线程的复用。对于BIO,一边会根据服务器硬件配置估算服务所能并发处理的最大连接数,据此设置线程复用池的大小。
知识点二:内存缓存池
用于接收和发送字节数据的缓存区,也是用于避免频繁向系统申请和释放内存。对应的,Netty也有缓存池的概念,相对复杂些,分直接内存和堆内存两种,直接内存就是jvm堆外内存,不被jvm所管。
知识点三:消息队列,解析数据包
有了内存缓存池,为啥还要有个消息队列。服务端读取到客户端发送过来的消息,可能不是一个完整的数据包,所以就需要对接收到的字节数据做解析,根据所使用的协议去解析字节数据,解析成一个个完整的数据包。
知识点四:自定义通信协议
做为后端开发人员,我们最熟悉不过的就是HTTP协议了。使用Socket编写网络程序,我们可以自定义通信协议,这相当的好玩。自定义协议可以避免别人识别你的通信协议拦截数据包分析,也可对数据包进行加密传输。自定义协议的数据包体积小,可跟据业务需求修改。
知识点五:心跳保活
TCP协议,需三次握手才建立连接,需四次挥手才释放连接。但避免不了的是客户端或是服务器意外断开连接,而对方并不知道。比如客户端蹭隔壁老王的Wifi被发现了…。如果客户端意外断开后,服务端还往客户端写消息,就会抛出异常。如果长时间没有读写数据,那线程一直会阻塞在那,占用线程。心跳包除了能检测连接是否可用外,还可实现断开自动重新连接。
在某些业务场景下,我们可以设置,当连接多久没有读写过数据时,服务端主动断开连接。这就是空闲检测。
关于JAVA NIO
NIO与BIO的区别:
BIO: 同步阻塞式IO,服务器需要为每一个客户端创建一个线程处理连接。
NIO:同步非阻塞式IO,服务端可以使用一个或多个线程监听客户端的连接请求,并将连接注册到多路复用器Selector上,使用Selector轮询I/O就绪事件,当监听到有就绪事件时,才会为准备就绪的连接开启一个线程去处理。
NIO与传统的BIO模型相比,节省了为每个连接绑定一个线程的开销,支持同时与大量的客户端建立连接,而只受限于系统最大打开文件描述符的数量。
NIO
Channel:channel是一个通道,可以通过它读取和写入数据。对于网络编程而言,网络数据通过channel接受客户端发来的消息,也可以通过channel向客户端发送消息,channel是全双工的,对应的类分别为SocketChannel、ServerSocketChannel。
ServerSocketChannel: 用于监听TCP连接的通道,类似于ServerSocket。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 绑定服务端监听端口
serverSocketChannel.socket().bind(new InetSocketAddress(this.port), 1024);
// 监听客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
SocketChannel:用于TCP网络连接的通道。实现与客户端数据传输。可以设置为非阻塞。类似于Socket。
Socket于Channel的区别:socket数据流是单向的,客户端与服务器实现双向通信需要一个Input流和一个Output流,一个用于接收数据,一个用于发送数据。而Channel是全双工的,即可以接受客户端发来的数据,也可以向客户端发送数据。
Selector:选择器,或多路复用器。用于轮询检查一个或多个NIO 通道(Channel)的状态。对于网络编程而言,Socket有四种状态,监听连接、连接准备就绪、读准备就绪、写准备就绪,对应的SelectorKey的取值如下。
SelectorKey:
OP_READ = 1 << 0; 0000 0001
OP_WRITE = 1 << 2; 0000 0100
OP_CONNECT = 1 << 3; 0000 1000
OP_ACCEPT = 1 << 4; 0001 0000
I/O多路复用:I/O指的是网络I/O,多路指多个TCP连接(BIO: socket, NIO:channel),复用指的是只用一个或多个线程处理事件。总的来说,就是使用一个或多个线程处理多个TCP连接。不再像BIO的一个连接一个线程处理,NIO则可以只用一个线程处理所有连接,也可以使用n个线程处理所有连接。
Channel通过register方法与Selector多路复用器绑定,并指定自己感兴趣的事件,比如:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
绑定后生成一个SelectionKey,SelectionKey持有Channel。
通过轮询监听,通过Selector的select()方法可以选择已经准备就绪的通道,这些通道包含你注册的事件。比如你对读、写就绪的通道感兴趣,那么select()方法就会返回读事件已经就绪的那些通道和写事件已经就绪的那些通道。select方法是一个阻塞方法,至少有一个通道在你注册的事件准备就绪时,才会返回。返回结果为准备就绪的SelectionKey总数。
当监听到有事件准备就绪时,再通过Selector的selectedKeys()获取准备就绪的SelectionKey集合。通过遍历处理事件。处理完后需要移除,否则下次selectedKeys()还会重复拿到。
for (;;) {
try {
// 获取到之后返回总数,否则线程将处于阻塞状态
int readyCount = this.selector.select();
if (readyCount == 0){
continue;
}
// 获取当前所有准备就绪的SelectionKey
Set<SelectionKey> readyKeys = this.selector.selectedKeys();
for (SelectionKey selectionKey : readyKeys) {
// 需要注册新的感兴趣事件
handleEvent(selectionKey);
// 处理完要移除,否则下次selectedKeys还是能拿到
readyKeys.remove(selectionKey);
}
} catch (IOException e) {
e.printStackTrace();
}
}
select方法底层通过调用native方法实现事件监听,在linux上,就是调用系统的epoll方法,网卡设备对应一个中断信号, 当网卡收到网络端的消息的时候会向CPU发起中断请求, 然后CPU处理该请求。通过驱动程序进而操作系统得到通知,系统再通知epoll,epoll通知用户代码。