Java NIO底层详解

网络I/O模型概念

在咱们进行一次网络的读写处理过程当中会涉及到几个步骤,好比客户端和服务器进行Socket通讯:java

  1. 客户端:首先客户端经过 write 系统调用将用户缓冲区的数据复制到内核缓冲区;内核将缓冲区中的数据写入网卡,再将数据发送给服务器;
  2. 服务器:从网卡中读取数据,存放到内核缓冲区中;经过调用 read 函数将内核缓冲区中的数据复制到用户缓冲区进行处理。

网络IO的复制过程.png
关于read和write函数不懂的可使用man readman write查看。linux

同步阻塞IO(BIO)

当发生read或write系统调用后,用户空间被会被一直阻塞,直到read或write对应的内核空间返回结果。在Java中,Socket和ServerSocket类的IO操做就是典型的阻塞IO。
阻塞IO的大体交互以下: BIO模型.png 从上面的图中能够发现,在用户空间从发起read系统调用到拿到结果这个过程是阻塞的,在内核空间中内核缓冲区等待数据,内核缓冲区复制到用户缓冲区这两个过程也是阻塞的。咱们能够经过java来实现一个BIO程序:编程

public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = new ServerSocket(9000);
    while (true) {
        Socket socket = serverSocket.accept();
        BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        PrintWriter writer = new PrintWriter(socket.getOutputStream());
        String value = reader.readLine();
        System.out.println("客户端发送的消息:" + value);
        writer.write(value);
        writer.flush();
    }
}
复制代码

以上代码中 serverSocket.accept();形成系统调用发生阻塞,在Linux系统中经过man accept能够查看关于accept的详细说明(我这里只复制部分信息):数组

[root@izbp1hvx6s6h8yr3sgj333z ~]# man accept
ACCEPT(2)                                                      Linux Programmer's Manual                                                      ACCEPT(2)

NAME
       accept, accept4 - accept a connection on a socket

DESCRIPTION
       The  accept() system call is used with connection-based socket types (SOCK_STREAM, SOCK_SEQPACKET).  It extracts the first connection request on
       the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file  descriptor  referring
       to that socket.  The newly created socket is not in the listening state.  The original socket sockfd is unaffected by this call.
复制代码

accept() 系统调用与基于链接的套接字类型(SOCK_STREAM、SOCK_SEQPACKET)一块儿使用。它提取第一个链接请求 侦听套接字 sockfd 的挂起链接队列建立一个新的链接套接字,并返回一个新的文件描述符引用 到那个插座。新建立的套接字未处于侦听状态。原始套接字 sockfd 不受此调用的影响。服务器

对于BIO模型存在的最大的问题是,每次监听链接、读写操做都会对用户线程形成阻塞,这个线程什么都不能干,只能等着,对于咱们追求高并发的系统来讲有很大的限制,基于这种思想,咱们能够借助与多线程和线程池技术以异步的思想去将问题避开,可是同时又会带来新的问题:多线程的频繁上下文切换、受操做系统线程数的限制。markdown

同步非阻塞IO(None Blocking IO)

上面的同步阻塞IO咱们发现问题的本质是阻塞,而同步非阻塞的优点就在于他能够在不使用多线程异步的状况下让咱们的用户线程不阻塞(注意:对于内核空间来讲,仍是会有阻塞的,可是他不会影响到用户线程);实现思路是,调用read函数若是数据尚未到达用户缓冲区的话,直接返回,不阻塞,过一会来查一下read的状态有么有执行成功,若是没有就再返回;不停的重复这个动做,若是调用read函数发现数据已经到达内核缓冲区了,那么就会进行用户缓冲区的复制,这个过程是阻塞的。网络

同步非阻塞IO.png 咱们能够经过Java来实现一个同步非阻塞模型的程序:多线程

public static void main(String[] args) throws IOException {
    ServerSocketChannel server = ServerSocketChannel.open();
    server.bind(new InetSocketAddress(9000)).configureBlocking(false);
    List<SocketChannel> list = new ArrayList<>();
    while (true) {
        SocketChannel socket = server.accept();
        if (socket != null) {
            socket.configureBlocking(false);
            System.out.println("已有客户端接入...");
            list.add(socket);
        }
        Iterator<SocketChannel> iterator = list.iterator();
        while (iterator.hasNext()) {
            SocketChannel channel = iterator.next();
            ByteBuffer buffer = ByteBuffer.allocate(32);
            int read = channel.read(buffer);
            if (read > 0) {
                System.out.println("收到消息:" + new String(buffer.array()));
            } else if(read == -1) {
                System.out.println("断开链接");
                iterator.remove();
            }
        }
    }
}
复制代码

上面设置了configureBlocking(false),因此在server.accept();channel.read(buffer);的时候不会阻塞,用户线程不停的进行IO系统调用,轮询判断数据有没有准备好,可是这么作存在的问题是:带来大量的CPU的空轮询的开销,同时也没法知足高并发的状况。并发

多路复用IO

在基于非阻塞的思想之上作了一次升级,客户端不须要死循环去调用read函数,也不须要判断read的数据有没有拷贝到用户空间,而是对于每一个Socket链接都添加一个事件监听,当事件被触发的时候,客户端再去执行对应的操做。好比我如今要去read,可是我不直接去read,由于我不知道数据有没有准备好,我先注册一个监听器,让监听器去监听数据有没有读取完成,一旦有数据读取完成,那么监听器就会告诉我数据好了你能够去read了,此时我客户端再调用read函数去拿数据。而这个监听器就是多路复用器,他还能够同时绑定多个事件。app

多路复用IO.png
下面是一个多路复用模型在java中的实现:

public static void main(String[] args) throws Exception {
    ServerSocketChannel serverSocket = ServerSocketChannel.open();
    serverSocket.socket().bind(new InetSocketAddress(9000));
    serverSocket.configureBlocking(false);
    // 1. 这个selector就是文章中提到的监听器,也就是多路复用器
    Selector selector = Selector.open();
    // 2. 将ServerSocket绑定到selector并告诉他帮我监听一下accept事件
    serverSocket.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        // 3. 等待事件被触发,若是没有事件则会阻塞,由于没有事件你再往下执行也没意义啊
        selector.select();
        Set<SelectionKey> selectionKeySet = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectionKeySet.iterator();
        while (iterator.hasNext()) {
            SelectionKey key = iterator.next();
            if (key.isAcceptable()) {
                // 若是触发的是accept事件,说明有客户端接入了
                ServerSocketChannel serverChannel = (ServerSocketChannel)key.channel();
                SocketChannel socketChannel = serverChannel.accept();
                socketChannel.configureBlocking(false);
                socketChannel.register(selector, SelectionKey.OP_READ);
            } else if (key.isReadable()) {
                // 若是触发的是read事件,说明内核缓冲区中有数据了,能够去读了
                SocketChannel socketChannel = (SocketChannel)key.channel();
                ByteBuffer buffer = ByteBuffer.allocate(128);
                int read = socketChannel.read(buffer);
                if (read > 0) {
                    System.out.println("客户端发来的数据:" + new String(buffer.array()));
                } else if (read == -1){
                    System.out.println("客户端断开链接");
                }
            }
            iterator.remove();
        }
    }
}
复制代码

多路复用的代码和同步非阻塞的代码差很少,可是多了一个Selector对象,在selector对象上注册了一个ServerSocketChannel的accept事件和SocketChannel的read事件,经过这种事件回调的方式可使一个线程来处理不少个IO操做,这个特性是依赖于Selector.select()方法,然而这个方法的底层是调用OS的select/poll/epoll函数 的这种模式虽然能够解决阻塞的问题,可是却多了一个问题:进行一次IO操做要发起两次系统调用:第1次是select调用,第2次是read调用。也就是说多路复用IO不必定比BIO性能高,由于自己多路复用也会存在阻塞问题,但BIO存在的根本问题是没法支持高并发,而在多路复用IO中能够解决这个问题,换言之,若是个人系统不是高并发的系统的话直接使用BIO还好点,由于只涉及一次系统调用。若是要支持高并发那就可使用多路复用IO模型。

异步IO(AIO)

发生一次系统调用后,会有一个新的线程经过事件回调的方式将数据回传进来,注意这里与多路复用IO不一样的是:多路复用IO是事件监听,AIO是事件回调;事件监听是说我关心的事件被触发以后,我本身去处理,而事件回调指的是我关心的事件被触发以后,会有一个新的线程经过回调方法将数据传给我,不须要我本身再去拿。 异步IO(AIO).png
在java的nio包中也提供了对AIO的支持:

public static void main(String[] args) throws Exception {
    final AsynchronousServerSocketChannel serverChannel =
            AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));

    serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
        @Override
        public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
            try {
                System.out.println("2--"+Thread.currentThread().getName());
                // 再此接收客户端链接,若是不写这行代码后面的客户端链接连不上服务端
                serverChannel.accept(attachment, this);
                System.out.println(socketChannel.getRemoteAddress());
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                    @Override
                    public void completed(Integer result, ByteBuffer buffer) {
                        System.out.println("3--"+Thread.currentThread().getName());
                        buffer.flip();
                        System.out.println(new String(buffer.array(), 0, result));
                        socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                    }

                    @Override
                    public void failed(Throwable exc, ByteBuffer buffer) {
                        exc.printStackTrace();
                    }
                });
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void failed(Throwable exc, Object attachment) {
            exc.printStackTrace();
        }
    });

    System.out.println("1--"+Thread.currentThread().getName());
    Thread.sleep(Integer.MAX_VALUE);
}
复制代码

AIO这种编程才是真正意义上的异步,可是在linux系统中仍是使用epoll的方式去作的,因此这种方式就不多用了。

多路复用的底层实现

关于怎么编译openjdk能够参考这篇文章blog.csdn.net/qq_35559877…

下面经过openjdk的源码分析一下nio底层是怎么实现多路复用的,其中很关键的代码是以下3步:

1.Selector selector = Selector.open(); 
2.serverSocket.register(selector, SelectionKey.OP_ACCEPT); 
3.selector.select();
复制代码

咱们先看一下Selector.open()作了哪些事情,点进去源码。发现是调用DefaultSelectorProvider.create()方法,而这个类在window和linux各实现了一个版本,咱们找到openjdk的源码到linux实现的版本中找到这个类 image.png
最终会调到EPollSelectorProvider.openSeletor()方法建立EPollSelectorImpl对象 image.png 在EPollSelectorImpl内部维护了一个EPollArrayWrapper对象,在建立EPollArrayWrapper的时候调用了epollCreate()方法,这个方法是native的,咱们找到jvm的底层实现:EPollArrayWrapper.Java_sun_nio_ch_EPollArrayWrapper_epollCreate image.png 发现这里是调用了epoll_create系统函数,那么epoll_create()是干吗的呢,epoll_create是Linux OS的系统函数,建立一个epoll对象去实现操做系统层面的多路复用机制:

[root@izbp1hvx6s6h8yr3sgj333z ~]# man epoll_create
EPOLL_CREATE(2)                                                Linux Programmer's Manual                                                EPOLL_CREATE(2)

NAME
       epoll_create, epoll_create1 - open an epoll file descriptor

SYNOPSIS
       #include <sys/epoll.h>

       int epoll_create(int size);
       int epoll_create1(int flags);

DESCRIPTION
       epoll_create() creates an epoll(7) instance.  Since Linux 2.6.8, the size argument is ignored, but must be greater than zero; see NOTES below.

       epoll_create()  returns a file descriptor referring to the new epoll instance.  This file descriptor is used for all the subsequent calls to the
       epoll interface.  When no longer required, the file descriptor returned by epoll_create() should be closed by using  close(2).   When  all  file
       descriptors referring to an epoll instance have been closed, the kernel destroys the instance and releases the associated resources for reuse.

   epoll_create1()
       If flags is 0, then, other than the fact that the obsolete size argument is dropped, epoll_create1() is the same as epoll_create().  The follow‐
       ing value can be included in flags to obtain different behavior:

       EPOLL_CLOEXEC
              Set the close-on-exec (FD_CLOEXEC) flag on the new file descriptor.  See the description of the O_CLOEXEC flag in open(2) for reasons why
              this may be useful.

RETURN VALUE
       On success, these system calls return a nonnegative file descriptor.  On error, -1 is returned, and errno is set to indicate the error.
复制代码

看到这里发现Selector.open()无非就是调用epoll_create()建立一个epoll对象;这个epoll对象对应的就是Java里的Selector对象。接着往下看:serverSocket.register(selector, SelectionKey.OP_ACCEPT); 点进去,跳过套娃的代码,到openjdk源码中找到linux版本的实现:EPollSelectorImpl.implRegister() image.png 第164行代码是很关键的一步,上面在建立epoll对象的过程当中顺带建立了EPollArrayWrapper对象,在这里会将epoll的channel对应的文件描述符放进去,也就是说每个须要注册的channel都会被放到EPollArrayWrapper里面。接着看selector.select(); 底层会调用到EPollArrayWrapper.poll()方法再调用到updateRegistrations()方法: image.png
updateRegistrations()里面又会调用一个native方法epollCtl() image.png 使用man epoll_ctl命令查看其DESCRIPTION部分信息:

This  system  call performs control operations on the epoll(7) instance referred to by the file descriptor epfd.  It requests that the operation
       op be performed for the target file descriptor, fd.
复制代码

翻译一下:

该系统调用对文件描述符 epfd 引用的 epoll(7) 实例执行控制操做。它要求操做对目标文件描述符 fd 执行op。

也就是说epoll_ctl函数才是真正的将channel与所关心的op绑定在一块儿,紧接着是最核心的一步,在执行完updateRegistrations()后执行了epollWait本地方法 image.png
这个方法确定是调用了OS的epoll_wait函数。然而这个函数就是用来监听epoll上所注册的事件。返回值对应的就是Java的SelectionKey。

上面一通分析以后,作一个小小的总结:其实对于IO程序来讲,jdk只是把操做系统作了一层封装,并无本身去实现(想实现也实现不了啊,IO涉及到硬件接口,Java进程处在用户态只能调操做系统),在调用系统函数的时候涉及到几个函数:
epoll_create(): 建立一个epoll对象;
epoll_ctl() :将channel与op绑定在一块儿;
epoll_wait():等待事件被触发;

在Linux的多路复用实现中,除了epoll之外还有select和poll,这也是java nio包刚出来的时候使用的。在select的底层实现是用数组,当有事件发生的时候会将数组中的全部文件描述符都循环一遍,时间复杂度为O(n);假设我如今一共有1w个链接,可是每次会触发IO操做的只有10个,就会存在9990次无效的循环,而且因为他是经过数组实现的,因此他支持的链接数是有限的。poll在select的基础上稍稍作了一点改进,将实现方式改成链表,没有链接上限,可是查询方式仍是基于循环去作的也是O(n)。而epoll是使用哈希表,当有事件发生时经过水平触发的方式对fd进行回调,时间复杂度为O(1)。

含泪播种的人必定能含笑收获。

相关文章
相关标签/搜索