彻底搞懂Java的网络IO

作者: 全菜工程师小辉 | 来源:发表于2019-08-01 20:18 被阅读9次

    IO是Input/Output的缩写。Unix网络编程中有五种IO模型:

    • blocking IO(阻塞IO)
    • nonblocking IO(非阻塞IO)
    • IO multiplexing(多路复用IO)
    • signal driven IO(信号驱动IO)
    • asynchronous IO(异步IO)

    背景

    • java.io包基于流模型实现,提供File抽象、输入输出流等IO的功能。交互方式是同步、阻塞的方式,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞。

    java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易成为应用性能的瓶颈。

    java.net下面提供的部分网络API,比如Socket、ServerSocket、HttpURLConnection 也时常被归类到同步阻塞IO类库,因为网络通信同样是IO行为。

    • 在Java 1.4中引入了NIO框架(java.nio 包),提供了Channel、Selector、Buffer等新的抽象,可以构建多路复用IO程序,同时提供更接近操作系统底层的高性能数据操作方式。

    • 在Java7中,NIO有了进一步的改进,也就是NIO2,引入了异步非阻塞IO方式,也被称为AIO(Asynchronous IO),异步IO操作基于事件和回调机制。

    基本概念

    在学习Java的IO流之前,需要了解同步异步、阻塞非阻塞的基本概念。

    同步与异步

    同步和异步是针对应用程序和内核的交互而言的。

    • 同步指的是用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪。例如:自己上街买衣服,自己亲自干这件事,别的事干不了。
    • 异步指的是用户进程触发IO操作以后便开始做其他的事情,而当IO操作已经完成的时候会得到IO完成的通知。例如:告诉朋友自己合适衣服的尺寸、颜色、款式,委托朋友去买,然后自己可以去干别的事。同时,你还需要告诉朋友你家衣柜在哪,方便朋友买完之后,直接将衣服放到你的衣柜。(使用异步I/O时,Java将I/O读写委托给OS处理,需要将数据缓冲区地址和大小传给OS)。

    阻塞与非阻塞

    阻塞和非阻塞是针对进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式。

    • 阻塞指的是当试图对该文件描述符进行读写时,如果当时没有东西可读,或暂时不可写,程序就进入等待状态,直到有东西可读或可写为止。去地铁站充值,发现这个时候充值员碰巧不在,然后我们就在原地等待,一直等到充值员回来为止。
    • 非阻塞指的是如果没有东西可读,或不可写,读写函数马上返回,而不会等待。在银行里办业务时,领取一张小票,之后我们可以玩手机,或与别人聊聊天,当轮到我们时,银行的喇叭会通知,这时候我们就可以去办业务了。

    注意,这里办业务的时候,还是需要我们也参与其中的。这和异步是完全不同的,是很多网上都在误导人的地方,后面会澄清这块误解。

    I/O模型分类

    应用程序向操作系统发出IO请求:应用程序发出IO请求给操作系统内核,操作系统内核需要等待数据就绪,这里的数据可能来自别的应用程序或者网络。一般来说,一个IO分为两个阶段:

    1. 等待数据:数据可能来自其他应用程序或者网络,如果没有数据,应用程序就阻塞等待。
    2. 拷贝数据:将就绪的数据拷贝到应用程序工作区。

    在Linux系统中,操作系统的IO操作是一个系统调用recvfrom(),即一个系统调用recvfrom包含两步,等待数据就绪和拷贝数据。

    同步阻塞IO

    在此种方式下,用户进程在发起一个IO操作以后,必须等待IO操作的完成,只有当IO操作完成之后,用户进程才能运行。JAVA传统的BIO属于此种方式。

    同步阻塞IO

    同步非阻塞IO

    在此种方式下,用户进程发起一个IO操作以后边可返回做其它事情,但是用户进程需要时不时的询问IO操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的CPU资源浪费。JAVA的NIO就属于同步非阻塞IO。

    同步非阻塞IO

    多路复用IO

    IO multiplexing这个词可能有点陌生,但如果换成select,epoll,大概就都能明白了,有时也称这种IO方式为事件驱动IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

    有关epoll的详细解释,欢迎点击查看

    多路复用中,通过select函数,可以同时监听多个IO请求的内核操作,只要有任意一个IO的内核操作就绪,都可以通知select函数返回,再进行系统调用recvfrom()完成IO操作。

    这个过程应用程序就可以同时监听多个IO请求,这比起基于多线程阻塞式IO要先进得多,因为服务器只需要少数线程就可以进行大量的客户端通信。

    上面描述的select函数,是NIO下的selector的成员函数。

    多路复用IO

    信号驱动式IO模型

    在unix系统中,应用程序发起IO请求时,可以给IO请求注册一个信号函数,请求立即返回,操作系统底层则处于等待状态(等待数据就绪),直到数据就绪,然后通过信号通知主调程序,主调程序才去调用系统函数recvfrom()完成IO操作。

    信号驱动也是一种非阻塞式的IO模型,比起上面的非阻塞式IO模型,信号驱动式IO模型不需要轮询检查底层IO数据是否就绪,而是被动接收信号,然后再调用recvfrom执行IO操作。

    比起多路复用IO模型来说,信号驱动IO模型针对的是一个IO的完成过程, 而多路复用IO模型针对的是多个IO同时进行时候的场景。 信号驱动式IO模型用下图表示,

    信号驱动IO

    异步IO

    在此种模式下,将整个IO操作(包括等待数据就绪,复制数据到应用程序工作空间)全都交给操作系统完成。数据就绪后操作系统将数据拷贝进应用程序运行空间之后,操作系统再通知应用程序,这个过程中应用程序不需要阻塞。

    异步IO

    I/O模型对比

    举个现实生活中的例子:

    如果你想吃一份卤肉饭,

    • 同步阻塞:你到饭馆点餐,然后在那儿等着,还要一直喊:好了没啊!
    • 同步非阻塞:在饭馆点完餐,就去遛狗了。不过遛一会儿,就回饭馆喊一声:好了没啊!
    • 多路复用:遛狗的时候,接到饭馆电话,说饭做好了,让您亲自去拿。
    • 异步非阻塞:饭馆打电话说,我们知道您的位置,一会儿给你送过来,安心遛狗就可以了。
    IO模型对比

    澄清很多人的误区

    网络上一些热度很高的博客给初学者造成了很多的误解,所以这里做一个澄清。

    阻塞、非阻塞、多路IO复用,都是同步IO,异步必定是非阻塞的,所以不存在异步阻塞和异步非阻塞的说法。真正的异步IO需要CPU的深度参与。换句话说,只有用户线程在操作IO的时候根本不去考虑IO的执行,全部都交给CPU去完成,而只需要等待一个完成信号的时候,才是真正的异步IO。所以,fork子线程去轮询、死循环或者使用select、poll、epoll,都不是异步。

    BIO

    在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞。

    传统的服务器端同步阻塞I/O处理(也就是BIO,Blocking I/O)的经典编程模型。

    public class IOServer {
        public static void main(String[] args) throws Exception {
    
            ServerSocket serverSocket = new ServerSocket(8000);
    
            // (1) 接收新连接线程
            new Thread(() -> {
                while (true) {
                    try {
                        // (1) 阻塞方法获取新的连接
                        Socket socket = serverSocket.accept();
    
                        // (2) 每一个新的连接都创建一个线程,负责读取数据
                        new Thread(() -> {
                            try {
                                int len;
                                byte[] data = new byte[1024];
                                InputStream inputStream = socket.getInputStream();
                                // (3) 按字节流方式读取数据
                                while ((len = inputStream.read(data)) != -1) {
                                    System.out.println(new String(data, 0, len));
                                }
                            } catch (IOException e) {
                            }
                        }).start();
    
                    } catch (IOException e) {
                    }
    
                }
            }).start();
        }
    }
    
    

    这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的。

    这个模型严重依赖于线程,但线程是很”贵”的资源,主要表现在:

    1. 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
    2. 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,占用的内存将非常惊人。
    3. 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
    4. 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高而且外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。

    所以,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,NIO技术应运而生。

    NIO

    基于事件驱动思想,采用reactor(反应器)模式。当发起IO请求时,应用程序是非阻塞的。当SOCKET有流可读或写的时候,由操作系统通知应用程序,应用程序再将流读取到缓冲区或者写入系统。

    有关NIO的详解,点击博主之前的博客,进行学习。

    1. 彻底搞懂NIO效率高的原理
    2. NIO效率高的原理之零拷贝与直接内存映射

    AIO

    同样基于事件驱动的思想,通常采用Proactor(前摄器模式)实现。在进行I/O操作时,直接调用API的read或write,这两种方法均为异步。对于读操作,操作系统将数据读到缓冲区,并通知应用程序,对于写操作,操作系统将write方法传递的流写入并主动通知应用程序。它节省了NIO中select函数遍历事件通知队列的代价(红黑树遍历)。

    增加的新的类如下:

    • AsynchronousChannel:支持异步通道,包括服务端AsynchronousServerSocketChannel和普通AsynchronousSocketChannel等实现。
    • CompletionHandler:用户处理器。定义了一个用户处理就绪事件的接口,由用户自己实现,异步io的数据就绪后回调该处理器消费或处理数据。
    • AsynchronousChannelGroup:一个用于资源共享的异步通道集合。处理IO事件和分配给CompletionHandler

    另外,主要在java.nio.channels包下增加了下面四个异步通道:

    • AsynchronousSocketChannel
    • AsynchronousServerSocketChannel
    • AsynchronousFileChannel
    • AsynchronousDatagramChannel

    AIO的实施需充分调用OS参与,IO需要操作系统支持、并发也同样需要操作系统的支持,所以性能方面不同操作系统差异会比较明显。因此在实际中AIO使用不是很广泛。

    代码就放弃展示了,毕竟没使用过,而且有netty的广泛使用,AIO并没有太多使用的地方

    Netty使用NIO放弃使用AIO的原因

    关于AIO,有个很热门的话题,就是Netty并没有使用AIO,只使用了NIO。

    至于原因,先看下作者原话:

    • Not faster than NIO (epoll) on unix systems (which is true)
    • There is no daragram suppport
    • Unnecessary threading model (too much abstraction without usage)

    扩展一下如下:

    1. Netty不看重Windows上的使用(这也不只是netty这一个开源框架的事)。在Linux2.6之后系统上,AIO的底层实现仍使用EPOLL,由于实现方式的不成熟,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化
    2. Netty整体架构是reactor模型, 而AIO是proactor模型, 混合在一起会非常混乱,把AIO也改造成reactor模型看起来是把epoll绕个弯又绕回来
    3. AIO有个重要的缺点是接收数据需要预先分配缓存,而NIO只需要在接收时才分配缓存, 所以对连接数量非常大但流量小的情况, 造成了大量的内存浪费。

    BIO/NIO/AIO适用场景

    • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
    • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
    • AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
    哎呀,如果我的名片丢了。微信搜索“全菜工程师小辉”,依然可以找到我

    相关文章

      网友评论

        本文标题:彻底搞懂Java的网络IO

        本文链接:https://www.haomeiwen.com/subject/mgthdctx.html