在研究Netty源码的过程中,可能是因为对java的nio编程方式不够熟悉,关于Selector以及线程那一块,看起来总是觉得差一点。于是,抽空研究了下jdk 的 nio与bio。不得不说,我对于nio与bio的区别理解的不够透彻,而这非常影响对于Netty的学习。这一篇博客会总结下我对nio与bio的学习与理解。(一部分 未完)
为了能更深入的说明两者之间的区别,会从 socket编程 -> nio编程 开始一步步说明。
文章比较长,如果时间紧可以看下阻塞IO的局限性
这一段
socket编程
客户端bio编程
这里举的例子非常简单了,忽略了异常、超时等等各种情况。实际使用也不大可能仅仅只是读几条数据,就直接关流。这样写主要是为了简单,便于说明API。
简单来说,可以总结这样几点:
1)创建socket(指定要连接的ip及端口)
2)获取并处理输入流
3)关闭socket(真实场景这一步可能是在退出APP后)
服务端bio编程
服务端也非常简单,大致就是:
1)启动一个服务端,监听某一个端口
2)接收客户端连接
3)根据客户端的请求,写入响应(这里客户端请求没有数据)
4)关闭服务器(真实场景可能在服务器停止时触发)
阻塞IO处理多个客户端请求
上述示例只是为了说明服务端API的使用。真实场景当然不可能只有一个客户端连接
为了支持多个客户端,我们简单的加个循环。
1)通过for循环,服务端就变的能处理多个客户端连接
2)注释掉serverSocket.close();
是因为上面while(true)最后一条语句怎么样都执行不到了。真实场景肯定会有个触发close的地方。
某个连接处理导致服务端无法响应
上述写法,从逻辑上来看改成伪代码如下:
这种写法有个非常严重的问题:
由于整个接收请求和处理请求都是在同一个线程里(本示例是主线程)当处理客户端请求这一步发生了阻塞,或者说慢了,
后来的所有连接请求都会被阻塞住
。
解决方法也很简单,启动一个线程去专门处理每一个请求
这样处理流程就变成这样了:
上述方式虽然解决了,某个客户端请求阻塞导致的服务端无法处理连接的问题。但是每次一个新的连接,都会启动一个线程。其他不说,假设有1百万个连接,按照一个连接最少64k来算,64k*1000000 约 61G
(关于一个线程需要多少内存,可以看这个启动一个线程所需内存)按这么算,当连接足够多时,服务端啥都不用干,内存就会被撑爆。
使用线程池处理超多连接
解决方式也很简单,不再每次连接进来都去启动一个线程,而是改成使用线程池
整个流程大致如下:
不活跃连接过多导致其他连接无法响应问题
使用线程池意味着,一个线程可能会处理来自多个客户端连接的请求,比如(这里想错了,谢谢@SHERLOCK_c3f3 的指正)A客户端
和B客户端
刚好请求都被提交给 线程C
,那么结果就是,A客户端的处理慢了,B客户端会连带着响应的特别慢。或者A客户端的请求阻塞了,B客户端的请求也会连带着阻塞了。
但是如果不活跃的连接逐渐增多,线程池里的线程慢慢的也都开始阻塞等待IO,线程池里真正在运行的线程数会越来越少,当线程池处理不过来时,会放置到线程池配置的BlockingQueue中,队列塞满后,慢慢的线程池中线程的数目会逐渐达到线程池配置的maximumPoolSize,如果再处理不过来,执行拒绝策略。也就是说,最终会导致请求无法及时处理。
这样带来的问题是,假设有线程池corePoolSize设置为100,只要有100个不活跃的连接正在阻塞读写IO,就会把前corePoolSize线程都阻塞住,后续的请求就无法及时处理。
阻塞IO的局限性
那么如何解决这个问题呢?在深入研究NIO和BIO的区别时,我第一反应就是使用非阻塞IO
呀。但是,其实我没有弄清楚到底非阻塞IO和阻塞IO的核心区别是啥。
首先非阻塞IO和阻塞IO最重要的一点区别,我认为是,非阻塞IO的读、写、接收连接是不会产生阻塞的
啥意思呢,首先回到之前写的服务端的示例:
当时我没有说明一个非常重要的情况,假设一直没有客户端的连接进来,这一步就会阻塞住。而这完全是没有必要的,因为可能在一段时间,根本不会有客户端去连接服务端。我们希望的情况是,客户端有连接了,我们再去accept
,打个比方,我再卖菜,我当然希望有人来买菜了,我才去收银。而不是,就在收银台那边干等着,浪费时间。
再来看看上述问题——单个线程处理阻塞导致的其他连接无法响应,我们首先要问,为啥会产生阻塞?
第一个原因,因为业务处理很慢。比如读写DB,可能业务就是要读取、写入很多数据,这种慢是没有办法的,无论怎么样,就是需要这么多时间。
第二个原因,socket的读写慢了。因为阻塞IO的读与写都是阻塞的。也就是说,假设服务端开始读了,服务端在客户端发送数据之前会一直阻塞住
啥问题呢,如下图:
假设客户端A和客户端B都是在一个线程中处理,客户端A已经开始读了(调用了 InputStream.read方法),但是由于没数据,服务端只能阻塞住
。客户端B呢,虽然它有数据准备发给服务端,但是因为该线程已经被阻塞住了,所以客户端B的连接也只能等着。
写的场景也一样,假设服务端准备往A写数据,但是呢数据还没准备好,导致客户端B也只能在那等着。(真实场景,这种情况可能比较少。比如收到一个查询db的请求,我们都是从db里读取了数据之后,才会调用write方法写数据的。很少会出现没有数据的问题)
那么比较理想的情况是啥呢?只有客户端有数据发过来了,服务端才去读,才去处理
这也就是非阻塞IO。
到了这里,阻塞IO与非阻塞IO一个非常重要的区别应该就清楚了,阻塞IO的读、写、连接都会阻塞整个线程
非阻塞IO的写法
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8888));
serverSocketChannel.configureBlocking(false); //设置服务端操作都是非阻塞的
Selector selector = Selector.open(); //选择器
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //对客户端的accept事件关心
while (true) {
selector.select(); //会阻塞住,直到有事件触发
Set<SelectionKey> selectionKeys = selector.selectedKeys(); //看下有哪些事件被触发了
System.out.println("selectionKeys:" + selectionKeys);
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isAcceptable()) {
//客户端 accept被触发了
ServerSocketChannel serverChannel = (ServerSocketChannel)key.channel();
SocketChannel clientChannel = serverChannel.accept();
System.out.println("channel is acceptable");
clientChannel.configureBlocking(false);
//客户端channel注册OP_WRITE事件
clientChannel.register(selector, SelectionKey.OP_WRITE);
} else if (key.isWritable()) {
//客户端可以往里写数据了
System.out.println("channel is writeable");
String data = "hello world\n";
//注意这里的是客户端的channel,因为是使用客户端channel注册OP_WRITE事件
SocketChannel clientChannel = (SocketChannel)key.channel();
ByteBuffer buffer = ByteBuffer.allocate(data.length());
buffer.put(data.getBytes());
buffer.flip();
clientChannel.write(buffer);
}
key.cancel(); //取消事件
iterator.remove();
}
}
}
因为这里只是为了说明java nio的写法,所以写的不是很严谨。仅供参考。。实际使用别这么写。
第一次看nio写法时候,很乱,不能理解为啥阻塞IO写起来那么清楚,到了非阻塞IO就变得这么复杂了呢?这里的Selector到底是啥?这里的SelectionKey又是啥?为啥要判断 acceptable为啥?为啥又要判断writable?
首先牢记一点非阻塞IO的所有操作都是异步的
,这意味着什么?首先当我们直接调用 serverSocketChannel.accept();
很可能直接返回一个null,因为客户端没有连接进来。而阻塞IO会一直等到客户端有连接
在拿服务端读作为例子,当我们直接调用SocketChannel.read()
时,可能获取到的就直接是null
,而阻塞IO会一直阻塞,直到客户端向服务端发送了数据
这样来看,因为所有请求都是异步,服务端必须要有某种机制,能知道:
1)客户端的连接过来了
2)客户端发送数据过来了
3)客户端可写了
....
而得知这些的方式,就是Selector。通过Selector的select操作,我们能遍历出当前有哪些事件准备好了,比如客户端连接过来了、客户端有数据过来了、可以往客户端发送数据了。
到目前为止,应该能说明为啥非阻塞IO的API设计是这样。更详细的java nio的使用,会在后面的博客里说明。
一个小问题:非阻塞IO是不是一定比阻塞IO性能要好?
分析到这里,可能会有种感觉,非阻塞IO性能一定比阻塞IO性能要好。但是其实这样说,并不准确。非阻塞IO解决了服务端有很多不活跃连接
的问题,比如说,客户端连接后,很长一段时间不发送任何请求,这样服务端处理该连接的线程就会一直卡在那里。
但是当连接不多时,并且每个连接都很活跃时,阻塞IO性能可能比非阻塞要好
。
总结
这篇博客说明了java 阻塞IO与非阻塞IO的一个非常重要的区别——IO操作是否阻塞。非阻塞IO解决了大量不活跃连接的问题。
问题
非阻塞IO与阻塞IO的区别当然不止这些,对于非阻塞IO的API说明也没有非常详尽。比如非阻塞IO里的buffer是啥?为啥非阻塞IO使用起来,要比阻塞IO复杂的多?后面博客会详细说明这几点。