NIO
简介
- 随着JavaIO类库的不断发展和改进,基于Java的网络编程会变得越来越简单。随着异步IO功能的增强,基于JavaNIO开发的网络服务器甚至不逊色与C++开发的网络程序。
- 记录一下学习BIO、NIO编程模型以及JDK1.7提供的NIO2.0的使用。
传统的BIO编程
- 这个可以搜索一下socket,就有很多。
- 通过一个线程来监听所有的socket连接,连接成功则新建线程去处理客户端操作。
- 问题是伸缩性差,随着并发访问量增大,会很好系统资源,可能造成处理失败。由于是阻塞时的读写,会造成较大的读写延迟。
- 源码略。
伪异步IO编程
- 为了解决传统的编程模型问题,有人使用线程池或者消息队列实现N各线程处理M个客户端的模型。M远大于N。
模型图
- Acceptor是一个线程,通过死循环来监听socket连接,如果有连接成功,则新建Runnable对象,提交给线程池处理。
源码分析
- 跟BIO的代码差不多,只是在Server端加了线程池,来处理客户端socket连接。并将连接封装到Runnable对象,并交给ThreadPool处理。
弊端
- 通过以上模型及代码分析,很容易知道通信底层还是使用的socket,读写还是同步阻塞的,因此,以上优化只是减小了过多创建销毁线程的开销,并不能从根本上解决阻塞读写产生的问题。
NIO编程
简介
- NIO(New I/O),较多人喜欢称作Non-block I/O.
- NIO新增了SocketChannel和ServerSocketChannel两种套接字通道。都支持阻塞和非阻塞两种模式。
- 相关概念
- 缓冲区Buffer
- 缓冲区实际上是一个数组,封装了对数据结构化访问以及维护读写位置等信息。
- 在NIO库中,所有数据都是用缓冲区处理的,在读取数据时,直接读取到缓冲区。写入数据时,直接写入写缓冲区。任何时候访问NIO中的数据,都是 通过缓冲区进行操作。
- 最常用的的缓冲区是ByteBuffer。大部分Java基本类型都对应一种缓冲区。类图如下
- 通道channel
- Channel 是一个通道,可以通过它读取和写入数据。InputStream和OutputStream各自只能在一个方向上操作。
- Channel是全双工的,所以它可以比流更好地映射底层的api。
- Channel类图如下:
- 多路复用器Selector
- Selector是NIO的编程基础。多路复用器提供选择已经就绪的任务的能力。
- Selector会不断轮询注册在其上的Channel,如果channel上面有了新的TCP连接、读取或者写事件,这个channel就是就绪状态,会被Selector轮询出来。然后通过SelectionKey集合可以获取就绪的Channel集合,进行IO操作。
*一个Selector可以同时轮询多个Channel,由于JDK使用了epoll()代替传统的select实现,所以没有最大连接句柄1024/2048的限制。这意味着只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端。
- 缓冲区Buffer
NIO服务端序列图
步骤1 打开ServerSocketChannel,用于监听客户端连接,它是所有客户端连接的父管道,代码示例如下:
ServerSocketChannel acceptorSvr = ServerSocketChannel.open();
步骤2 绑定监听端口,并设置非阻塞模式,示例代码如下:
acceptorSvr.socket().bind(InetAddress.getByName("IP"),port); acceptorSvr.configureBlocking(false);
步骤3 创建Reactor线程,创建多路复用器并启动线程,代码如下:
Selector selector = Selector.open();
new Thread(new ReactorTask()).start();步骤4 将ServerSocketChannel注册到Reactor线程的多路复用器Selector上,监听ACCEPT事件,代码如下:
SelectionKey key = accptorSvr.register(selector,SelectionKey.OP_ACCEPT,ioHandle);
步骤5 Selector在线程run方法内轮询准备就绪的key,代码如下:
int num = selector.select(); Set selectedKey = selector.selectedKeys(); Iterator it = selectedKeys.iterator(); while(it.hasNext()){ SelectionKey key = it.next(); //handle IO operation }
步骤6 Selector 监听到有新的客户端接入请求,处理新的处理请求,完成TCP三次握手,建立物理链路,代码如下:
SocketChannel channel = svrChannel.accept();
步骤7 设置客户端链路为非阻塞式,示例代码如下:
channel.configBlocking(false); channel.socket().setReuseAddress(true);
步骤8 将新接入的客户端连接注册到Reactor线程的Selector上,监听读写操作。用来读取客户端发送的网络信息,代码如下:
SelectionKey key = socketChannel.register(selector,SelectionKey.OP_ACCEPT,ioHandle);
步骤9 异步读取客户端请求消息到缓冲区,代码如下:
int num = channel.read(buffer);
步骤10 对ByteBuffer进行编解码。如果有半包消息指针reset,继续读取后续的报文,将读取的信息封装成Task交给线程池处理。代码如下:
//伪代码 while(buffer.hasRemaining()){ buffer.mark(); Object message = decode(buffer); if(message==null){ buffer.reset(); break; } messageList.add(message); } if(!buffer.hasRemaining()) buffer.clear(); else buffer.compact(); if(messageList!=null&& !messageList.isEmpty()){ for (Object object:messageList) { handleTask(object); } }
步骤11 将POJO编码成ButeBuffer.调用SocketChannel的异步write()方法将消息异步发送给客户端,代码如下:
socketChannel.write(buffer);
如果缓冲区满,会导致写半包。此时,需要注册监听写操作位,循环写,知道整包消息写入TCP缓冲区。
源码分析
- 见文末源链接
NIO客户端处理序列图
步骤1 打开SocketChannel,绑定客户端本地地址(可选,默认系统会随机分配一个可用的本地地址),代码示例如下:
SocketChannel clientChannel = SocketChannel.open();
步骤2 设置channel为非阻塞的,同时设置TCP连接参数,代码示例如下:
clientChannel.configBlocking(false);
socket.setReuseAddress(true);步骤3 异步连接服务端,代码示例如下:
boolean connected = clientChannel.connect(new InetSocketAddress("ip",port));
步骤4 判断是否连接成功,如果成功则注册到Selector,否则重新连接,代码示例如下:
if(connected){ clientChannel.register(selector,SelectionKey.OP_READ,ioHandle); }else{ clientChannel.register(selector,SlectionKey.OP_CONNECT,ioHandle); }
步骤5 向Reactor线程的Selector注册OP_CONNECT状态位,监听服务端的TCP ACK应答,代码示例如下:
步骤6 创建ReActor线程,创建Selector并启动线程,代码示例如下:
Selector selector = Selector.open(); new Thread(new ReactorTask()).start();
步骤7 Selector在线程run方法中无限循环轮询准备就绪的key,代码示例如下:
int num = selector.selector(); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator it = selectionKeys.iterator(); while(it.hasNext()){ SelectionKey selectionKey = it.next(); //处理IO操作 }
步骤8 接收connect事件并处理,代码示例如下:
if(key.isConnectable()){ //处理连接请求 }
步骤9 判断连接结果,如果连接成功,则注册读事件到selector中,代码示例如下:
if(channel.finishConnect()){ //registerRead() }
步骤10 注册读事件到selector,代码示例如下:
clientChannel.register(selector,SelectionKey.OP_READ,ioHandle);
步骤11 异步读客户端请求消息到缓冲区,代码示例如下:
int readNumber = channel.read(readBuffer);
步骤12 对读取到的消息进行编解码,如果有半包消息接收,缓冲区reset,继续读取后续的报文,将解码成功的消息封装成Task,丢到线程池中处理。代码示例如下:
//伪代码 while(buffer.hasRemaining()){ buffer.mark(); Object message = decode(buffer); if(message==null){ buffer.reset(); break; } messageList.add(message); } if(!buffer.hasRemaining()) buffer.clear(); else buffer.compact(); if(messageList!=null&& !messageList.isEmpty()){ for (Object object:messageList) { handleTask(object); } }
步骤13 将POJO对象编码成ByteBuffer,调用SocketChannel的异步write接口将消息异步发送到客户端,代码示例如下:
socketChannel.write(buffer);
客户端源码解析
- 见文末源码链接
对比
- NIO编程复杂度比BIO大很多。
- NIO的优点
- 客户端发起的连接请求是异步的,可以通过直接在多路复用器注册OP_CONNECT操作等待后续操作,不需要同步阻塞等待结果可用。
- SocketChannel的读写都是异步的,如果没有可读写的数据它不会同步等待,直接返回,这样IO通信线程可以处理其他链路,不要同步等待这个链路可用。
- 线程模型的优化:由于JDK的Selector在Linux等主流OS中通过epoll实现,没有连接句柄数的限制,这意味着Selector线程可以同时处理成千上万的客户端连接,而且性能不会随着客户端的增加而线性下降,因此非常适合做高性能、高并发的网络服务器。
- JDK7中升级了NIO类库,升级后的NIO类库被称为NIO2.0.正式提出了异步文件IO操作,同时提供了与UNIX网络编程事件驱动IO对应的AIO。
AIO编程
简介
- NIO2.0引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。
- 异步通道提供两种方式获取结果:
- 通过java.util.concurrent.Future类来表示异步操作的结果
- 在执行异步操作的时候传入一个java.nio.channels.
- CompletionHandle接口的实现类作为操作完成的回调。
*NIO2.0的异步套接字通道都是真正的异步阻塞的IO,它对应UNIX网络编程中的事件驱动IO(AIO),它不需要通过多路复用器对注册的通道进行轮询操作即可实现异步读写,从而简化了NIO的编程模型。
源码分析
- 见文末源码链接
4种IO的对比
- 阻塞与非阻塞。异步与同步
- 阻塞与非阻塞指的是当不能进行读写时(网卡满时的写或者网卡空时的读),IO操作立即返回还是阻塞。
- 同步异步指的是当数据ready时读写操作是同步读还是异步读。–以上解释摘自知乎。
- 不同IO模型由于线程模型、api等差别很大,用法差异也挺大。几种IO模型的功能和对比如下表:
同步阻塞IO(BIO) | 伪异步 | 非阻塞IO(NIO) | 异步IO(AIO) | |
客户端个数:IO线程数 | 1:1 | M:N(一般M大于N) | M:1 | M:0(不需要额外启动线程,被动回调) |
IO类型(阻塞) | 阻塞IO | 阻塞 | 非阻塞IO | 非阻塞IO |
IO类型(同步) | 同步 | 同步 | 同步 | 异步 |
API使用难度 | 简单 | 简单 | 复杂 | 复杂 |
调试难度 | 简单 | 简单 | 难 | 难 |
可靠性 | 非常差 | 差 | 高 | 高 |
吞吐量 | 低 | 中 | 高 | 高 |
总结
- 本文是笔者阅读Netty权威指南所作的笔记,感谢李林峰老师。
- 如果你阅读了本文,发现了书写错误,请直接评论。感谢!
- 我将书中的源码分析部分写入到代码中了,如有需要可自行下载。链接:http://download.csdn.net/detail/chenzhao2013/9729887