阅读 1282

Java NIO底层详解

网络I/O模型概念

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

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

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

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

同步非阻塞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函数去拿数据。而这个监听器就是多路复用器,他还可以同时绑定多个事件。

多路复用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)。

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

文章分类
后端
文章标签