0
点赞
收藏
分享

微信扫一扫

java nio与bio —— 阻塞IO与非阻塞IO的区别

在研究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
(关于一个线程需要多少内存,可以看这个启动一个线程所需内存)按这么算,当连接足够多时,服务端啥都不用干,内存就会被撑爆。

使用线程池处理超多连接

解决方式也很简单,不再每次连接进来都去启动一个线程,而是改成使用线程池


整个流程大致如下:


不活跃连接过多导致其他连接无法响应问题

使用线程池意味着,一个线程可能会处理来自多个客户端连接的请求,比如A客户端B客户端刚好请求都被提交给 线程C,那么结果就是,A客户端的处理慢了,B客户端会连带着响应的特别慢。或者A客户端的请求阻塞了,B客户端的请求也会连带着阻塞了。(这里想错了,谢谢@SHERLOCK_c3f3 的指正)

但是如果不活跃的连接逐渐增多,线程池里的线程慢慢的也都开始阻塞等待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复杂的多?后面博客会详细说明这几点。

举报

相关推荐

0 条评论