Java NIO 随笔

来源:互联网 发布:电子相册制作软件 知乎 编辑:程序博客网 时间:2024/06/04 19:05

  • 前言
  • Java NIO 和 BIO的区别
    • 编辑方式的区别
    • BIO的通讯模型
    • NIO的通讯模型
  • Selector 的工作模式
    • Selector的工作流程图
    • 通用的NIO Socket的编写方式
  • Selector 在 SocketChannel 上的通讯流程
    • NIO 与 BIO 的比较
    • NIO的Socket通讯时序图

前言

最近在看Java NIO, 做下记录。操作平台皆基于Windows 10, JDK8, TCP Socket.
记录里面把 普通IO 成为 BIO。

记录提纲:

  • Java NIO的设计模式
  • Selector的阻塞原理
  • ServerSocketChannel的TCP通讯流程

Java NIO 和 BIO的区别

编辑方式的区别

Java BIO是基于流的,一旦收到消息就会立马处理,但是存在等到流收取事件。 NIO是基于channel的,操作系统内核协助获取完毕数据流之后才会交给业务代码处理。

以Socket为例:

普通IO:

//1、创建一个服务器端Socket,即ServerSocket,指定绑定的端口,并监听此端口ServerSocket serverSocket =newServerSocket(10086);//1024-65535的某个端口//2、调用accept()方法开始监听,等待客户端的连接Socket socket = serverSocket.accept();//3、获取输入流,并读取客户端信息InputStream is = socket.getInputStream();...socket.shutdownInput();//关闭输入流//4、获取输出流,响应客户端的请求OutputStream os = socket.getOutputStream();...//5、关闭资源...serverSocket.close();

NIO:

Selector selector = Selector.open();// 打开监听信道 ServerSocketChannel listenerChannel = ServerSocketChannel.open();// 与本地端口绑定listenerChannel.socket().bind(new InetSocketAddress(12345));// 设置为非阻塞模式listenerChannel.configureBlocking(false);// 将选择器绑定到监听信道,只有非阻塞信道才可以注册选择器.并在注册过程中指出该信道可以进行Accept操作listenerChannel.register(selector, SelectionKey.OP_ACCEPT);// 反复循环,等待IOwhile (true) {    // 等待某信道就绪(或超时)    if (selector.select(TIME_OUT) == 0) {        continue;    }    // 取得迭代器.selectedKeys()中包含了每个准备好某一I/O操作的信道的SelectionKey    Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();    while (keyIter.hasNext()) {        SelectionKey key = keyIter.next();        // 处理信息        dealWith(key);        // 移除处理过的键        keyIter.remove();    }}

可以看出, BIO需要获取到输入输出流,阻塞性的进行输入输出操作。
而NIO则使用了Selector,这个Selector可以绑定多个 Chanel, 例如实现TCP、UDP、File的操作,灵活性更高。

BIO的通讯模型

对于BIO而已,不同的IO类型处理的事件是不一样的,比如写TCP处理就必须使用ServerSocket, 读写文件处理则必须使用InputStream/OutputStream等。

由于BIO的通讯模型中所有的操作都是阻塞式的,因此为了实现对多客户端的处理,则必须频繁的创建大量的线程来针对每一个连接进行处理。

Java Socket编程----通信是这样炼成的
图片来源: Java Socket编程—-通信是这样炼成的

BIO通讯模型在 少量请求、高数据流 的情况下处理非常有效。 但是针对于大量请求、低数据流的情况, 就面临着这样的问题:

  • 大量客户端连接,则需要建立大量的线程处理。线程的创建、销毁、上下文切换开销巨大。
  • 流式读取, 每次从内核空间将字节拷贝到线程空间,处理完毕之后再交给内核空间取发送,有IO开销。
  • 阻塞式IO, 在网络环境较差的情况下,线程的read()/write()方法是阻塞的,线程周期长,线程调度会占据较多时间。
  • 如上述,如果在流式读取中,每次 read()或者write()还做一些其它的业务操作,线程调度将会占据更多的时间。
  • 在使用线程池的情况下,高并发,低数据量的IO请求,可能导致内核空间大量请求堆积。

NIO的通讯模型刚好就解决掉了上述的问题

NIO的通讯模型

NIO面向事件而设计。由 Selector进行监听和分发。 每一个处理Chanel在向Selector注册的时候必须选定监听的事件。
Java NIO:浅析I/O模型

图片来源:Java NIO:浅析I/O模型

具体流程可以浅析为:

  1. 各种Chanel(TCP/UDP/File)向Selector注册(一般只会写一种类型,否则代码的可编辑性很低)。
  2. Selector根据不同的内核空间事件通知对不同的Channel进行调度。
  3. Channel的处理面向缓冲区,通过建立堆外内存将待处理数据写入缓冲区内。
  4. 缓冲区写结束了之后Channel才会继续处理。Selector在这一系列操作中并不阻塞。

Selector 的工作模式

Selector的工作流程图

Selector除了在获取内核通知的时候,其它时候都不阻塞。见文章:Java NIO——Selector机制源码分析—转

下图是Selector调用内部类 SubSelector 的调用时序图。其中 poll0() 方法是一个本地方法。它的作用是在一个等待时间段里面,如果有注册好的Channel的IO事件则立即返回,否则等到等待时间之后返回。

Created with Raphaël 2.1.0SelectorSelectorSubSelectorSubSelectorSelector的实现:windows:WindowsSelectorImplLinux:KQueueSelectorImplselect();select(long);doSelect(long);poll();poll0(long, int, int[], int[], int[], long);

如上所述, 在poll0() 中会将当前等待时间段里面所监听到的所有的IO句柄进行归类分集,并根据数量的不同生成不同数量的线程,生成SelectKey再交给业务代码处理。需要注意的是, 在没有使用 独立线程/线程池 去处理每个SelectKey的情况下, NIO是依然同步的

通用的NIO Socket的编写方式

通常的NIO Socket会这么编写:

{    Selector selector = Selector.open();    // 打开监听信道 目前只做TCP支持    ServerSocketChannel listenerChannel = ServerSocketChannel.open();    // 与本地端口绑定    listenerChannel.socket().bind(new InetSocketAddress(ListenPort));    // 设置为非阻塞模式    listenerChannel.configureBlocking(false);    // 将选择器绑定到监听信道,只有非阻塞信道才可以注册选择器.并在注册过程中指出该信道可以进行Accept操作    listenerChannel.register(selector, SelectionKey.OP_ACCEPT);    // 反复循环,等待IO    while (true) {        // 等待某信道就绪(或超时)        if (selector.select(TIME_OUT) == 0) {            continue;        }        // 取得迭代器.selectedKeys()中包含了每个准备好某一I/O操作的信道的SelectionKey        Iterator<SelectionKey> keyIter = selector.selectedKeys().iterator();        while (keyIter.hasNext()) {            SelectionKey key = keyIter.next();            try {                if (key.isAcceptable()) {                    /*clientChannel.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize));*/                    protocol.handleAccept(key);                }                if (key.isReadable()) {                    /*key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);*/                    protocol.handleRead(key);                }                if (key.isValid() && key.isWritable()) {                    protocol.handleWrite(key);                }            } catch (IOException ex) {            }            // 移除处理过的键            keyIter.remove();        }    }}

可是, 这么编写的原因是什么呢?

首先, ServerSocketChannel是只支持SelectionKey.OP_ACCEPT的(见ServerSocketChannel#validOps)。这样的设计就是专为TCP连接而设定的。

其次,对于SocketChannel,它支持SelectionKey.OP_READ | SelectionKey.OP_WRITE | SelectionKey.OP_CONNECT。 这种设计就表示它专门用于处理ServerSocketChannel产生的连接信息,然后对其进行读写操作。

再次,因为Selector在最初注册的时候, 根本不知道有没有链接会到来,会在什么时候到来。 因此必须由代码手动注册SocketChannelSelector, 它才知道对每个不同的SocketChannel进行处理(注意Selector本身是没有显式的提供移除Channel功能的)。

继续,如果在key.isAcceptable()代码块里没有做SelectionKey.OP_READ, 将会导致key.isReadable()返回值为false。 同样的,对于一个既定的Channel而言,想要修改它在Selector上注册的操作类型,需要调用key.interestOps()方法去修改。它会让Selector重新生成一条事件通知。

最后,NIO Servlet用于处理事件处理的线程只有一个。其包含不限个数(一般是3~5个)的子线程,专用于从操作系统获取事件信息及数据发送。

如上。 普通NIO的编写模式大多大同小异。都是通过ServerSocketChannel获取SocketChannel,并向Selector注册。 然后由独立的业务代码进行处理。 可以理解为用一个线程去处理所有的请求。 如果想要使用多线程去处理, 可以使用多个Selector, 再配合线程池。 这样效率会有大幅度的提升(需要注意每个Selector都会向自己注册的Channel发起通知,业务代码需要判断“SocketChannel是否已经被其它代码处理了)。

Selector 在 SocketChannel 上的通讯流程

NIO 与 BIO 的比较

NIO和BIO是同源的,且NIO也是基于BIO的。

BIO的几个问题,NIO在技术层面上做了规避。可以把 NIO理解为BIO的聚合,把IO操作交给了操作系统去做。

阻塞性对比:
NIO的不阻塞在于: NIO是等到数据拷贝已经完毕之后才会对数据进行处理。
BIO的阻塞在于:BIO对于每次的数据请求都会立即处理。

由此,NIO可以更快的处理更多的数据,之所以说它IO不阻塞,是因为它处理IO的时候IO都还没有完成,而操作系统的IO性能是高于JVM的; BIO可以处理少量但是更大的数据,因为它是流式的。

同异步对比:
二者皆支持同步/异步模式。 默认没有使用 线程/线程池 的情况下, 二者的编写方式都是同步的。

内存消耗对比:
BIO使用的堆内存,只要每次buffer不要设置太大,更多的是IO上、线程调度的性能考量。
NIO可以使用堆外内存,分批次将数据读取至缓冲区,需要注意连接的数量和每次缓冲区的大小。

NIO的Socket通讯时序图

依据以上描述, 补充时序图如下:

Created with Raphaël 2.1.0客户端客户端服务器操作系统服务器操作系统NIO进程NIO进程业务线程业务线程请求报文操作系统建立TCP连接Selector获取到Accept事件生成ServerSocketChannel SelectKey逐个处理SelectorKey(Accept)通过ServerSocketChannel获取到SocketChannel注册SocketChannel读事件逐个处理SelectorKey(Read)将ByteBuffer数据获取业务处理注册SocketChannel写事件逐个处理SelectorKey(Write)请求处理结果响应报文