Java I/O模型 BIO NIO

https://developer.ibm.com/zh/articles/j-lo-javaio/
本文几乎完全参考了这篇文章,写的非常好,强推

I/O 类库的基本架构

java的io操作类可以分为四组,分别为:

  • 基于字节操作的 I/O 接口:InputStream 和 OutputStream
  • 基于字符操作的 I/O 接口:Writer 和 Reader
  • 基于磁盘操作的 I/O 接口:File
  • 基于网络操作的 I/O 接口:Socket

前两组主要是根据传输数据的格式,后两组主要根据传输数据的方式。
I/O的核心问题是将什么样的数据,写到什么地方的问题,因此要么是数据格式影响I/O操作,要么是传输方式影响I/O操作。
在这里插入图片描述

基于字节的I/O操作接口

流最终写入的地方,只可能是网络或者是磁盘

基于字符的I/O操作接口

不管是磁盘,还是网络,最小的存储单元都是字节,而不是字符,但是为了程序方便,也提供了直接写字符的I/O接口。因此在使用字符接口时,需要指定编码。
如下是一个解码的示例
在这里插入图片描述

磁盘I/O工作机制

磁盘的唯一最小描述是文件,应用程序只能通过文件来操作磁盘数据。Java中的File不代表一个真实存在的文件对象,当指定一个文件描述符时,会返回一个代表文件路径相关联的对象。然后会根据这个File对象创建真正读取文件的操作对象FileDescriptor,这个对象可以真正控制磁盘文件。下图举例的是从磁盘读一段文本字符。
在这里插入图片描述

网络I/O工作机制

Socket

Socket是描述计算机之间完成互相通信的一种抽象功能,大部分情况下,我们使用的都是基于TCP/IP的流套接字。
主机A的应用程序要想和主机B的应用程序通信,必须建立Socket连接,而建立Socket连接需要底层TCP/IP协议来建立TCP连接。建立TCP连接需要底层IP协议寻址网络中的主机,与主机上指定的应用程序通信要通过TCP或UDP协议的地址,也就是端口号来指定,这样就可以通过一个Socket实例唯一代表一个主机上的应用程序的通信链路了。
在这里插入图片描述

建立通信链路

当客户端需要与服务器端通信时,客户端需要首先创建一个Socket实例,操作系统将为这个Socket实例分配一个没有被使用的本地端口,并创建一个包含本地、远程地址、端口号的Socket数据接口,这个数据结构将一直保存在系统中直到连接关闭。创建Socket实例的构造函数正确返回之前,要进行TCP三次握手协议,握手完成后,Socket实例对象将创建完成,否则将抛出IOException错误。
与之对应的服务端将创建一个ServerSocket实例,该实例只需要指定端口号。操作系统会为ServerSocket实例创建一个底层数据结构。当调用其accept方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求过来时,将为这个链接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源的地址和端口。这个新创建的数据结构会关联到一个未完成连接数据结构列表中,等到三次握手之后,服务端的Socket实例才会返回,并将这个Socket实例对应的数据结构从未完成列表中移到已完成列表中。

数据传输

当连接已经建立成果,服务端和客户端都会有一个Socket实例,每个实例都有一个InputStream 和 OutputStream,当Socket对象创建时,操作系统将会为InputStream 和 OutputStream分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓冲区完成的,写入端将数据写到OutputStream对应的SendQ队列中,当队列填满时,数据将发送到另一端InputStream的RecvQ,如果这时RecvQ满了,那么OutputStream的write 方法将会阻塞直到RecvQ队列有足够的空间容纳SendQ发送的数据。缓冲区的大小以及写入端的速度、读取端的速度非常影响这个连接的数据传输效率,可能发生阻塞。

BIO的问题

大规模访问量时,线程阻塞成为问题,如果大量的分配线程,则很难通过设置线程的优先级来提高服务优先级,以及需要线程同步的场景很多,会提升问题的复杂性

NIO工作机制

在这里插入图片描述

在这里插入图片描述
Channel 要比 Socket 更加具体,它可以比作某种具体的交通工具,如汽车或是高铁等,而 Selector 可以比作为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态:是已经出战还是在路上等等,也就是它可以轮询每个 Channel 的状态。Buffer 类,它也比 Stream 更加具体化,我们可以将它比作为车上的座位,Channel 是汽车的话就是汽车上的座位,高铁上就是高铁上的座位,它始终是一个具体的概念,与 Stream 不同。Stream 只能代表是一个座位,至于是什么座位由你自己去想象,也就是你在去上车之前并不知道,这个车上是否还有没有座位了,也不知道上的是什么车,因为你并不能选择,这些信息都已经被封装在了运输工具(Socket)里面了,对你是透明的。NIO 引入了 Channel、Buffer 和 Selector 就是想把这些信息具体化,让程序员有机会控制它们,如:当我们调用 write() 往 SendQ 写数据时,当一次写的数据超过 SendQ 长度是需要按照 SendQ 的长度进行分割,这个过程中需要有将用户空间数据和内核地址空间进行切换,而这个切换不是你可以控制的。而在 Buffer 中我们可以控制 Buffer 的 capacity,并且是否扩容以及如何扩容都可以控制。

public void selector() throws IOException {
    
    
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Selector selector = Selector.open();
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);//设置为非阻塞方式
        ssc.socket().bind(new InetSocketAddress(8080));
        ssc.register(selector, SelectionKey.OP_ACCEPT);//注册监听的事件
        while (true) {
    
    
            Set selectedKeys = selector.selectedKeys();//取得所有key集合
            Iterator it = selectedKeys.iterator();
            while (it.hasNext()) {
    
    
                SelectionKey key = (SelectionKey) it.next();
                if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
    
    
                    ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
                 SocketChannel sc = ssChannel.accept();//接受到服务端的请求
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                    it.remove();
                } else if
                ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
    
    
                    SocketChannel sc = (SocketChannel) key.channel();
                    while (true) {
    
    
                        buffer.clear();
                        int n = sc.read(buffer);//读取数据
                        if (n <= 0) {
    
    
                            break;
                        }
                        buffer.flip();
                    }
                    it.remove();
                }
            }
        }
}

调用 Selector 的静态工厂创建一个选择器,创建一个服务端的 Channel 绑定到一个 Socket 对象,并把这个通信信道注册到选择器上,把这个通信信道设置为非阻塞模式。然后就可以调用 Selector 的 selectedKeys 方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生时,将会返回所有的 SelectionKey,通过这个对象 Channel 方法就可以取得这个通信信道对象从而可以读取通信的数据,而这里读取的数据是 Buffer,这个 Buffer 是我们可以控制的缓冲器。
在上面的这段程序中,是将 Server 端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在实际应用中,我们通常会把它们放在两个线程中,一个线程专门负责监听客户端的连接请求,而且是阻塞方式执行的;另外一个线程专门来处理请求,这个专门处理请求的线程才会真正采用 NIO 的方式,像 Web 服务器 Tomcat 和 Jetty 都是这个处理方式,关于 Tomcat 和 Jetty 的 NIO 处理方式可以参考文章《 Jetty 的工作原理以及与 Tomcat 的比较》

Buffer的工作方式

Buffer 可以简单的理解为一组基本数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态,也就是有四个索引。
在这里插入图片描述
我们通过 ByteBuffer.allocate(11) 方法创建一个 11 个 byte 的数组缓冲区,初始状态如上图所示,position 的位置为 0,capacity 和 limit 默认都是数组长度。当我们写入 5 个字节时位置变化如下图所示:
Figure xxx. Requires a heading

这时我们需要将缓冲区的 5 个字节数据写入 Channel 通信信道,所以我们需要调用 byteBuffer.flip() 方法,数组的状态又发生如下变化:

Figure xxx. Requires a heading
这时底层操作系统就可以从缓冲区中正确读取这 5 个字节数据发送出去了。在下一次写数据之前我们在调一下 clear() 方法。缓冲区的索引状态又回到初始位置。

Channel 获取的 I/O 数据首先要经过操作系统的 Socket 缓冲区再将数据复制到 Buffer 中,这个的操作系统缓冲区就是底层的 TCP 协议关联的 RecvQ 或者 SendQ 队列,从操作系统缓冲区到用户缓冲区复制数据比较耗性能,Buffer 提供了另外一种直接操作操作系统缓冲区的的方式即 ByteBuffer.allocateDirector(size),这个方法返回的 byteBuffer 就是与底层存储空间关联的缓冲区,它的操作方式与 linux2.4 内核的 sendfile 操作方式类似。

猜你喜欢

转载自blog.csdn.net/u010659877/article/details/108864125