深入分析Java IO机制(博客重写计划Ⅱ)

2,298 阅读15分钟

博客重写计划-更新日志:2021.4.23 -> 1. 增加对于系统调用、内核空间与用户进程空间的描述 2. 增加I/O多路复用的实现函数selectepoll的介绍,并通过redis中的实际使用进行回顾; 3. 修复一些语义、逻辑不连贯的问题。 PS: 回头看自己一年前写的博客简直无法忍受,必须改进一下,优化主要参考《Unix网络编程》、《Redis设计与实现》。

TODO_LIST:

  • [对于Java NIO的应用实例-Tomcat连接池的分析]
  • [对于Java NIO模块的更新 ]

IO介绍:

Java IO分类:

  1. IO按照处理的数据类型 可分为:(1)面向字节操作的I/O接口:inputStream,outputStream (2)面向字符操作的接口:Reader,Writer
  2. IO按照数据的传输方式 可分为:(1)面向磁盘操作的I/O接口: File (2)面向网络操作的I/O接口:Socket
  3. 所以I/O主要的操作可以总结为将什么类型的数据以何种传输方式传到什么地方去。

Unix IO:

进程通过recvfromselectepollaio_read系统调用函数进行IO操作,因此我们先要讲解一下用户态、内核态以及系统调用的概念,便于我们之后理解I/O模型为什么经过内核拷贝至用户空间。

用户态、内核态以及系统调用

image.png

内存依据使用权限分为了多个等级(通过DPL的大小来表示),其中操作系统使用的内存区域(即内核)所对应的描述符的 DPL= 1 ,而用户进程所使用的内存区域对应的描述符的 DPL = 3,并且计算机在硬件层面限制了 DPL由高处访问到低处,因此,用户进程是无法在自己的进程空间中直接访问内核空间的;

所以为了解决这个问题,操作系统实现了系统中断-systemCall(系统中断相关程序虽然在内核中,但此处的DPL被设置为3,因而用户进程可以访问),并通过systemCall命令对应的中断向量表映射到不同的中断处理函数,比如write()系统调用在中断向量表中对应的中断函数号为4,可以理解为策略模式的一种实现-通过中断函数号赋予systemCall不同的行为。

基于此操作系统对用户态进程的访问内核提供了一个接口-系统调用

Unix中IO的五种模型

I/O的宏观图像,以网络IO为例:

当客户端发送的网络包经过路由器和交换器的转发后到达对应服务端的网络适配器(网卡),然后网络适配器会将其收到的数据通过DMA传输到内核,再由内核空间拷贝至用户进程缓冲区,然后此时用户进程才可以通过访问进程缓冲区使用数据。

Unix网络编程这本书中概述了完成上述操作的五种模型,首先先了解一下阻塞与非阻塞、同步与非同步的概念:

阻塞与非阻塞、同步与非同步

这两组名词其实只是对同一个场景的两种不同的描述方式:

(1)阻塞与非阻塞: 阻塞与非阻塞主要是从 CPU 的消耗上来说的,阻塞就是 CPU 停下来等待一个慢的操作完成后CPU 才接着完成其它的事。非阻塞就是在这个慢的操作在执行时 CPU 去干其它别的事,等这个慢的操作完成时,CPU 再接着完成后续的操作。

(2)同步与非同步: 同步与非同步主要是从程序方面来说的,同步指程序发出一个功能调用后,没有结果前不会返回。非同步是指程序发出一个功能调用后,程序便会返回,然后再通过回调机制通知程序进行处理。

阻塞式IO模型(BIO):

PS:此时通信双方已经通过三次握手建立了连接,可以通过套接字文件进行数据的交换。

在此模型下应用进程在发起recvfrom系统调用后,便会阻塞以等待发送方发送的数据依次到达网卡->内核->用户进程缓冲区后返回;

这个模型最大的问题就是操作系统中最典型的CPU速度与外设速度不匹配的问题,网络适配器的速度相对于CPU的速度是极慢的,但是此时CPU却一直在阻塞。

非阻塞式IO模型:

在非阻塞式IO模型下,当用户进程发起recvfrom系统调用后,如果此时内核中套接字文件还没有准备好,则recvfrom会直接返回一个错误信息;

因此此时CPU就可以切换其他进程使得处理器自身不再阻塞,而该进程会不断获取CPU时间片进行轮询套接字文件是否已经准备好。

因此该模式下虽然是非阻塞,但是其进程切换是很频繁的,所以通过该方式增加的CPU使用时间与进程上下文切换的成本还是需要考量的;并且当数据准备好后,并且进程获取到时间片再次调用recvfrom时,进程还是需要等待数据从内核拷贝至用户进程缓冲区的。

多路复用IO模型:(Java NIO原理)

该模型通过select系统调用,该系统调用一直会阻塞到IO事件的到来(即内核中的套接字文件可读、可写)后再返回,这个时候我们的应用进程再调用recvfrom时只需要等待数据从内核拷贝至用户进程缓冲区即可;

并且select方法可以注册、监听多个套接字文件上的读、写事件, 联系到Java NIO中时,就是多个线程可以向同一个Selector注册多个事件,从而达到了多路复用的效果。

(TODO:Acceptor模型)

select函数

函数定义如下:

int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

每个套接字文件在文件系统中都对应着一个文件描述符(fd),select函数维护着读写文件描述符集(fd_set),通常是两个整数数组其中每个整数对应的每一位对应一个文件描述符,比如使用32位的整数,那么数组中第一个整数表示文件描述符为o~31的文件,并通过设置位来表示select函数是否关心该位对应的文件上的读、写事件。

UNIX中提供了FD_ZERO|FD_SET|FD_CLR|FD_ISSET这四个宏来设置文件描述符集中的位,如通过FD_SET(4, &readset)设置读文件描述符集的第一个整数位为 0000100...,则表示当前select函数关心fd为4的套接字描述符的读事件;设置*readset、*writeset后,进程调用select会一直阻塞直到其关心的套接字上产生了读写事件并返回已就绪的文件描述符信息。

注意: 就绪的文件描述符信息并不是通过int返回,而是通过将读写描述符集中未产生事件的文件描述符位置0,这样应用程序就可以通过遍历字符集来知道哪些套接字文件准备好了(遍历字符集是很低效的,这正是epoll需要优化的点)。但是这也需要我们每次调用select前重新设置关心的读写描述符集 (而这也是epoll要优化的一个点)。

PS: 我们一直提读事件、写事件,即套接字文件可读可写,那么究竟什么时候套接字文件可读可写呢?

产生读事件的条件为:一个套接字可读,即该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小;

产生写事件的条件为:一个套接字可写,即该套接字发送缓冲区可用空间的数据字节数大于等于套接字发送缓冲区低水位标记的当前大小。

最后我们来看下redis中是如何使用select的:

#include <sys/select.h>
#include <string.h>

// 定义包裹读写fd_set的struct
typedef struct aeApiState {
    fd_set rfds, wfds;
    /* We need to have a copy of the fd sets as it's not safe to reuse
     * FD sets after select(). */
    fd_set _rfds, _wfds;
} aeApiState;

// 初始化方法,通过FD_ZERO宏初始化读写fd_set,初始化即为都置0
static int aeApiCreate(aeEventLoop *eventLoop) {
    aeApiState *state = zmalloc(sizeof(aeApiState));
    // 初始化fd_set
    FD_ZERO(&state->rfds);
    FD_ZERO(&state->wfds);
    eventLoop->apidata = state;
    return 0;
}

// 注册事件,通过FD_SET宏设置对应文件描述符使得select关心该文件描述上的读、写事件
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;
    // 读事件
    if (mask & AE_READABLE) FD_SET(fd,&state->rfds);
    // 写事件
    if (mask & AE_WRITABLE) FD_SET(fd,&state->wfds);
    return 0;
}

// 取消事件注册,通过FD_CLR宏完成
static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask) {
    aeApiState *state = eventLoop->apidata;

    if (mask & AE_READABLE) FD_CLR(fd,&state->rfds);
    if (mask & AE_WRITABLE) FD_CLR(fd,&state->wfds);
}

// 轮询函数,在redis主线程中调用用于读写事件就绪的套接字描述符
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    aeApiState *state = eventLoop->apidata;
    int retval, j, numevents = 0;
    
    /* We need to have a copy of the fd sets as it's not safe to reuse
     * FD sets after select(). */
    memcpy(&state->_rfds,&state->rfds,sizeof(fd_set));
    memcpy(&state->_wfds,&state->wfds,sizeof(fd_set));

    // 调用select函数,并将读写fd_set传入
    retval = select(eventLoop->maxfd+1,&state->_rfds,&state->_wfds,NULL,tvp);
    if (retval > 0) {
        // 遍历文件描述符,通过宏FD_ISSET找到被置为的文件描述符
        for (j = 0; j <= eventLoop->maxfd; j++) {
            int mask = 0;
            aeFileEvent *fe = &eventLoop->events[j];
            
            // 当前文件描述符未被置位则返回
            if (fe->mask == AE_NONE) continue;
            // 如果读fd_set中该文件描述符被置位则标记
            if (fe->mask & AE_READABLE && FD_ISSET(j,&state->_rfds))
                mask |= AE_READABLE;
            // 如果写fd_set中该文件描述符被置位则标记
            if (fe->mask & AE_WRITABLE && FD_ISSET(j,&state->_wfds))
                mask |= AE_WRITABLE;
            eventLoop->fired[numevents].fd = j;
            eventLoop->fired[numevents].mask = mask;
            numevents++;
        }
    }
    return numevents;
}

epoll函数

epoll函数是对select函数的优化,主要在于:

  • select函数需要每次在调用时重新设置其所关心的读、写描述符集,多次调用后这是一个比较大的开销;因为我们关心的套接字描述符集并不会经常发生改变,所以我们可以通过一个额外的数据结构来表示当前epoll函数所关心的套接字集,而不是像select函数那样仅使用一个值-结果参数来表示;因此我们需要提供一个操作此额外数据结构的一套增删改查的方法,并且让epoll依赖该数据结构即可。

  • select函数需要遍历读写字符集才可以知道那些套接字文件准备好。因此我们可以通过额外维护读写事件的就绪队列来避免遍历。

而这正是epoll进行优化的主要思路和方向,具体可以参考这篇文章,本文限于篇幅不再赘述。

如果这篇文章说不清epoll的本质,那就过来掐死我吧!

异步IO(AIO):

该模型通过操作系统提供的异步IO方法aio_read,应用程序调用后便直接返回,并且不需要像前几种模型一样需要等待数据拷贝至内存;

但其内在的实现还是很复杂的,底层还是使用BIO实现的,就不展开描述了,因为对编程人员好像并没有太大的作用。

信号驱动IO:

其实笼统点讲,AIO和多路复用IO其实也是某种信号进行驱动的IO,即都不需要应用程序阻塞在网络适配器(网卡)的数据准备好的这个过程中 ,而都是通发出信号进行通知应用程序,虽然信号的实现方式或是用 select 或是用更底层的方式,但本质上还是很相似的;但信号驱动IO也是需要线程等待数据拷贝至用户空间的。

二、Java BIO:

2.1 简介:

注:《深入理解计算机系统》中定义,Linux将所有外设抽象成文件,与外设的通信被抽象成文件的读写;而网络也只是外设的一种;客户端与服务器端建立连接时互相交换了彼此的文件描述符,之后两端进行通信即为向这两个文件描述符对应的套接字文件中写值


Java中的Socket是对进行通信的两端的抽象,其封装了一系列TCP/IP层面的底层操作; 代码如下:

  1. 客户端:
// 通过一个IP:PORT套接字新建一个Socket对象,确定要连接的服务器的位置和端口
Socket socket = new Socket("127.0.0.1", 8089);
// 通过Socket对象拿到OutputStream,用于向套接字发送缓冲区中写入数据
OutputStream outputStream = socket.getOutputStream();
//使用默认的字符集去解析outputStream的字节流
PrintWriter printWriter = new PrintWriter(outputStream, true);
/*向服务器发送一个HTTP1.1的请求*/
printWriter.println("GET /index.html HTTP/1.1");
printWriter.println("Host: localhost:8080");
printWriter.println("Connection Close");
printWriter.println();
  1. 服务端:
// ServerSocket在该套接字上监听连接事件
ServerSocket serverSocket = new ServerSocket(8089, 1, InetAddress.getByName("127.0.0.1"));
// 服务端阻塞在accept()方法上,直到客户端的connect()请求,并返回一个新的套接字对象
socket = serverSocket.accept();
// 从返回的Socket对象中获取该Socket对应的套接字文件的内容并进行读取
InputStream inputStream = socket.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
int i = 0;
while (i != -1) {
       i = bufferedReader.read();
           System.out.println("拿到的数据为:"+(char)i);
       }
socket.close();

其实Java BIO 即为对Unix系统提供的网络I/O方法的封装;

2.2 Java BIO 带来的问题:

我们一般都是使用Acceptor模型来进行服务端的创建,即通过一个ServerSocket()创建一个监听套接字监听来自客户端的连接请求,而建立连接后便会通过线程池获取一个子线程进行相应的逻辑处理;

而上述逻辑带来了一系列问题:

  1. 响应时间层面:Acceptor是一个单线程,即所有连接的请求都是串行处理的,而ServerSocket是通过 backlog这个参数来表明在服务端拒绝连接请求之前,可以排队的请求数量,所以这样的模型注定了BIO性能的局限性(排队的通信线程可能要阻塞一段时间),处理量的局限性;
  2. 资源消耗层面: 阻塞IO天生的问题,即需要一个线程对应一个连接,所以对资源的要求比较高;
  3. 一些特殊的应用场景,如多个线程需要共享资源的时候,而BIO模型下每个线程之间是不共享资源的。

三、 Java NIO:

3.1 与BIO对比,改变了什么,又为什么要这么改变?

与BIO的区别在于:

  1. Java NIO实现了多路复用IO的模型,通过单个Selector线程管理多个连接,解决了BIO最致命的一个问题-一个线程对应一个连接;

  2. 无论是In/OutputStream还是Java NIO中的通道channel 本质上都是对网络I/O文件的抽象,与前者不同,channel是双通道的,既可以读又可以写。

所以按照I/O多路复用 的模型,当channel中的数据准备好了的时候会返回一个可读的事件,并且通过selector进行处理,安排相应的Socket进行相应数据的读取,这是一个数据可读的事件,而Selector可监听的事件有四种:

SelectionKey.OP_CONNECT // 连接事件
SelectionKey.OP_ACCEPT //接收事件
SelectionKey.OP_READ //数据可读事件
SelectionKey.OP_WRITE //可写事件
  1. 为什么要引入Buffer机制? 在BIO的时候我们一般是通过类似于socket.getInputStream.write() 方法来直接进行读写的,而NIO中向channel中写入数据必须从buffer中获取,而channel 也只能向buffer写入数据,这样使得这样的操作更为接近操作系统执行I/O的方式;细一点讲,是因为在向OutputStream中write() 数据即为向接收方Socket对象中的InputStream 中的RecvQ队列中,而如果 write()的数据大于队列中每个数据对象限定的长度,就需要进行拆分,而这个过程,我们是不可以控制的,而且涉及到用户空间与内核空间地址的转换;但是当我们使用Buffer后,我们可以控制Buffer的长度,是否扩容以及如何扩容我们都可以掌握。 参考文章:www.ibm.com/developerwo…

3.2 我们来看一段实例代码(服务端):

/**
 * @CreatedBy:CVNot
 * @Date:2020/2/21/15:30
 * @Description: NIO Server实现
 */
public class NIOServer {
    public static void main(String[] args) {
        try {
            // 创建一个多路复用选择器
            Selector selector = Selector.open();
            // 创建一个ServerSocket通道,并监听8080端口
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
            // 设置为非阻塞
            serverSocketChannel.configureBlocking(false);
            // 监听接收数据的事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            while (true){
                selector.select();
                // 拿到Selector关心的已经到达事件的SelectionKey集合
                Set keys = selector.selectedKeys();
                Iterator iterator = keys.iterator();
                while (iterator.hasNext()){
                    SelectionKey selectionKey = (SelectionKey)iterator.next();
                    iterator.remove();
                    // 因为我们只注册了ACCEPT事件,所以这里只写了当连接处于这个状态时的处理程序
                    if(selectionKey.isAcceptable()){
                        // 拿到产生这个事件的通道
                        ServerSocketChannel serverChannel = (ServerSocketChannel)selectionKey.channel();
                        SocketChannel clientChannel = serverChannel.accept();
                        clientChannel.configureBlocking(false);
                        // 并为这个通道注册一个读事件
                        clientChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
                    }

                    else if(selectionKey.isReadable()){
                        SocketChannel clientChannel = (SocketChannel)selectionKey.channel();
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        long bytesRead = clientChannel.read(byteBuffer);
                        while(bytesRead > 0){
                            byteBuffer.flip();
                            System.out.printf("来自客户端的数据" + new String(byteBuffer.array()));
                            byteBuffer.clear();
                            bytesRead = clientChannel.read(byteBuffer);
                        }

                        byteBuffer.clear();
                        byteBuffer.put("客户端你好".getBytes("UTF-8"));
                        byteBuffer.flip();
                        clientChannel.write(byteBuffer);
                    }
                }
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

客户端:

/**
 * @CreatedBy:CVNot
 * @Date:2020/2/21/16:06
 * @Description:
 */
public class NIOClient {
    public static void main(String[] args) {
        try {
            Selector selector = Selector.open();
            SocketChannel clientChannel = SocketChannel.open();
            clientChannel.configureBlocking(false);
            clientChannel.connect(new InetSocketAddress(8080));
            clientChannel.register(selector, SelectionKey.OP_CONNECT);
            while (true) {
                //如果事件没到达就一直阻塞着
                selector.select();
                Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();
                    if (key.isConnectable()) {
                        /**
                         * 连接服务器端成功
                         *
                         * 首先获取到clientChannel,然后通过Buffer写入数据,然后为clientChannel注册OP_READ事件
                         */
                        clientChannel = (SocketChannel) key.channel();
                        if (clientChannel.isConnectionPending()) {
                            clientChannel.finishConnect();
                        }
                        clientChannel.configureBlocking(false);
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        byteBuffer.clear();
                        byteBuffer.put("服务端你好,我是客户端".getBytes("UTF-8"));
                        byteBuffer.flip();
                        clientChannel.write(byteBuffer);
                        clientChannel.register(key.selector(), SelectionKey.OP_READ);
                    } else if (key.isReadable()) {
                        //通道可以读数据
                        clientChannel = (SocketChannel) key.channel();
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        long bytesRead = clientChannel.read(byteBuffer);
                        while (bytesRead > 0) {
                            byteBuffer.flip();
                            System.out.println("server data :" + new String(byteBuffer.array()));
                            byteBuffer.clear();
                            bytesRead = clientChannel.read(byteBuffer);
                        }
                    } else if (key.isWritable() && key.isValid()) {
                        //通道可以写数据
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}

3.3 可用一张图大概总结流程: