阅读 72

一文理解nio中的三大核心要素Channel、Buffer和Selector

文章目录


1. Overview

Java中的NIO主要包括三个核心概念:

  • Channels:管道
  • Buffers:缓冲区
  • Selectors:选择器

当然nio中还有很多其他的类和成分,但是上述的三个是其他所有实现的核心,其他的实现更像是一些工具类,用于粘合ChannelsBuffersSelectors。其中Channel可以看做是一个流,程序可以从Channel中读取数据写入到Buffer,也可以从Buffer中读取数据写入到Channel。


在这里插入图片描述

nio中Channel的主要类型有网络IO和文件IO,相应的实现类主要有:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

nio中Buffer的主要实现有:

  • ByteBuffer
  • CharBuffer
  • DoubleBUffer
  • FloatBuffer
  • IntBuffer
  • LongBUffer
  • ShortBuffer

它对于Java中不同类型的数据都提供了相应的实现类。

Selector允许单线程同时处理多个Channel,它主要应用于:当程序存在多个连接,但每个连接传输的数据很少,使用Selector可以减少资源消耗,提高处理的效率。例如,如下是针对于3个Channel的Selector:


在这里插入图片描述


Selector可以通过调用select方法管理注册的所有Channel,该方法会一直阻塞,直到其中的一个Channel对应的事件发生。当事件返回时,Selector对应的线程便可以处理返回的事件。


2. Channel


Channel和通常所说的流(Stream)有相似的地方,但也有不同的地方,例如:

  • Channel是双向的,既可以向Channel中写入数据,也可以从Channel中读取数据。而Streams通常是单向的,如输入流、输出流
  • Channel支持异步的读取和写入数据
  • Channel通常和Buffer一起来实现数据的读取和写入


Channel的常用实现类有:

  • FileChannel:支持从文件中读取数据
  • DatagramChannel:支持通过UDP来读取和写入数据
  • SocketChannel:支持通过TCP来读取和写入数据
  • ServerSocketChannel:支持监听连接的TCP连接,每一个连接对应一个SocketChannel


例如,我们使用FileChannel来读取文件中的内容,如下所示:

/**
 * @Author dyliang
 * @Date 2020/10/25 9:17
 * @Version 1.0
 */
public class ChannelDemo {
    public static void main(String[] args) throws IOException {
        RandomAccessFile file = new RandomAccessFile("test.txt", "rw");
        FileChannel channel = file.getChannel();

        ByteBuffer buffer = ByteBuffer.allocate(48);
        int bytesRead = channel.read(buffer);

        while(bytesRead != -1){
            System.out.println("Read " + bytesRead);
            buffer.flip();
            while(buffer.hasRemaining()){
                System.out.println((char) buffer.get());
            }

            buffer.clear();
            bytesRead = channel.read(buffer);
            System.out.println(bytesRead);
        }
        file.close();
    }
}
复制代码

上面的例子实现的是Buffer和Channel之间的数据传输,另外,Channel还提供了transferTo方法和transferFrom方法用于Channel之间直接的数据传输。例如:

RandomAccessFile fromFile = new RandomAccessFile("fromFile.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();

RandomAccessFile toFile = new RandomAccessFile("toFile.txt", "rw");
FileChannel toChannel = toFile.getChannel();

long position = 0;
long count = fromChannel.size();

toChannel.transferFrom(fromChannel, position, count);
复制代码


另外,一些SocketChannel实现可能只传输SocketChannel已经在它的内部缓冲区中准备好的数据,即使SocketChannel以后可能有更多可用的数据。因此,它可能不会将所请求的全部数据(计数)从SocketChannel传输到FileChannel。

transferTo方法和上面的transferFrom用法类似,不同之处在于方法调用的形式不同:

fromChannel.transferTo(position, count, toChannel);
复制代码

同样的,SocketChannel调用transferTo方法时,只能转移和它缓冲区容量一致的数据。


3. Buffer

Buffer常和Channel配合使用,数据可以从Buffer写入到Channel中,同样可以从Channel中获取数据写入到Buffer中。Buffer本质上是一块可以读写数据的内存空间,它被包装成NIO中的Buffer对象,并且提供了一系列的方法使得程序可以轻松的操作这部分内存空间。

通过Buffer来读写数据主要分为如下的四步:

  • 向Buffer中写入数据
  • 调用buffer.flip()方法
  • 从Buffer中读取数据
  • 调用buffer.clear()或者buffer.compact()来回收buffer空间

当程序向Buffer中写入数据时,buffer会持续记录写入的数据量。当需要从Buffer中读取数据时,需要调用flip方法将buffer从写模式转换为读模式,然后可以读取buffer中所有的数据。另外,一旦读取数据完毕,程序需要清空buffer,便于后续其他数据的写入。NIO中提供了两种方式来实现:

  • clear方法:清空,它将清空整块的buffer空间
  • compact方法:压缩整理,它只清空已经被读取的数据所占的内存空间,剩下还没有被读取的数据将移动到buffer的起始位置,后续写入的数据将在它之后保存

例如:

ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = channel.read(buffer);   // 向buffer写入数据

while(bytesRead != -1){
    System.out.println("Read " + bytesRead);
    buffer.flip();   // 转换为读模式
    while(buffer.hasRemaining()){
        System.out.println((char) buffer.get());   // 获取数据
    }

    buffer.clear();   // 清空buffer内存空间复制代码


Buffer本质上就是一块可以重复进行读写的内存空间,为了理解它是如何使用内存来进行读写,需要理解如下的三个概念:

  • capacity:容量,缓冲区的总长度,如果缓冲区已满还需要写入数据,就需要先清空再写入
  • position:位置,下一个要操作的数据元素的位置。起始位置为0,随着数据的写入不断的后移,最大为capacity - 1。当从buffer中读取数据时,position重置回0,记录下一个要读取数据的位置
  • limit:缓冲区中不可操作的下一个元素的位置,用于限制程序可以写入或者读取的数据量,通常为limit <=capacity

读取的数据量不能超过写入的数据量。

在这里插入图片描述

Buffer的常用实现有:

  • ByteBuffer
  • CharBuffer
  • DoubleBUffer
  • FloatBuffer
  • IntBuffer
  • LongBUffer
  • ShortBuffer

针对于不同类型的数据可以选择相应的实现使用。Buffer常用的方法有:

方法名描述
allocate用于获取指定容量和类型的Buffer对象,例如:ByteBuffer buf = ByteBuffer.allocate(48);
向Buffer写入数据nio提供了两种方法向buffer中写入数据: - 从Channel获取数据写入,例如:int bytesRead = inChannel.read(buf) - 自行写入:buf.put(11)
flip将Buffer从写模式转换为读模式,将position重置回0,limit标记写入的数据量
从Buffer读取数据nio提供了两种方法来从Buffer读取数据: - 读取数据写入Channel:int WriteCount = channel.write(buf) - 自行读取:buf.get()
rewind用于将position重置为0,便于重新读取数据
clear清空Buffer内存空间
compact它只清空已经被读取的数据所占的内存空间,剩下还没有被读取的数据将移动到buffer的起始位置,后续写入的数据将在它之后保存
mark标记缓冲区中的给定位置
reset将位置重置回标记的位置
equals用于比较两个Buffer的内容,两个Buffer的内容相等需要满足: - 类型相同 - 剩余的数据量相同 - 剩余的数据内容相同
compareTo以词典顺序进行比较,它在缓冲区参数小于、等于或者大于引用 compareTo( )的对象实例时,分别返回一个负整数,0 和正整数


其中,clear方法和compact方法清空Buffer时,并不表示Buffer内的数据被清空。原来的数据相当于是被遗忘了,程序无法再次读取buffer中的数据,后续写入的新数据会直接覆盖掉旧数据。这涉及到另一个概念mark, 它用于记录当前position的前一个位置或者默认是-1。当调用clear方法清空buffer时,实际上做的工作如下:

public final Buffer clear() {
    position = 0;  // 将position置为0,下次直接从头开始写入数据
    limit = capacity;  // limit和capacity一致
    mark = -1;   // mark置为-1
    return this;
}
复制代码

4. Scater、Gather

nio提供了scatter(分散)和gather(收集)的支持,用于向Channel写入数据或者从Channel读取数据,用于需要单独处理多个部分数据的场景。scatter用于将从Channel中读取的数据写入到多个Buffer之中,gather用于将多个Buffer中的数据写入到一个Channel之中。

scatter用来从单个Channel读取数据到多个buffer中,如下所示:


在这里插入图片描述

例如,将Channel中的数据写入到两个ByteBuffer中:

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);

ByteBuffer[] bufferArray = { header, body };

channel.read(bufferArray);
复制代码

scatter按照数组的顺序向buffer中写入数据,一旦buffer被写满,它将移动到另一个buffer执行写操作,不适于动态的调整写入数据的大小。

gather用于将多个buffer中的数据写入到Channel中,如下所示:


在这里插入图片描述

例如:

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);

//write data into buffers

ByteBuffer[] bufferArray = { header, body };

channel.write(bufferArray);
复制代码

此时,只有在缓冲区的位置和限制之间的数据被写入。例如,如果缓冲区的容量为128字节,但只包含58字节,则只有58字节从该缓冲区写入到Channel。


5. Selector

Selector支持单线程来管理多个Channel,用于检测管理的Channel中哪些准备好执行读操作,或是写操作,提供了一种IO多路复用的机制。如果不使用Selector管理Channel,那么每个Channel都需要单独的一个线程。线程需要占用一定的资源,并且线程上下文的切换开销很大。因此,Selector通过支持单线程来管理多个Channel,减少了线程的使用,充分利用硬件的多核性能,从而降低了性能开销。


下面,我们通过程序看一下如何使用Selector。首先,创建一个Selector:

Selector selector = Selector.open();
复制代码

调用register方法向Selector中注册需要管理的Channel:

channel.configureBlocking(false); // Channel此时必须是非阻塞类型

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
复制代码

其中register方法的最后一个参数是interest set,它表示程序想要通过Selector监听Channel中发生的感兴趣的事件类型。它有四个取值:

  • Connect
  • Accept
  • Read
  • Write

如果一个Channel成功的连接到server称为connect ready;如果server的socket channel接收了一个连接,称为accept ready;如果一个Channel准备好读取数据,称为read ready;如果一个Channel准备好写数据,称为write ready。SelectionKey的四个取值表示上述的四种事件:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

如果感兴趣多种类型的事件,可以使用 | 进行组合。其中register方法会返回一个SelectionKey对象,它包含如下几方面的信息:

  • interest set
  • ready set
  • Channel
  • Selector
  • an attached object

其中interest set就是前面调用register方法时绑定的想要监听的事件,通过interestOps方法获取到对应的事件标号,如下所示:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;    
复制代码

SelectionKey的定义中,不同的事件通过不同的数字表示,所以可以通过与运算来判断监听的是哪个事件。

public abstract class SelectionKey {
    public static final int OP_READ = 1;
    public static final int OP_WRITE = 4;
    public static final int OP_CONNECT = 8;
    public static final int OP_ACCEPT = 16;
    private volatile Object attachment = null;
    
    // 省略
}
复制代码

ready set表示Channel已经准备好执行的操作类型,通过调用readyOps方法获取对应的标识:

int readySet = selectionKey.readyOps();
复制代码

通过下面的四个方法可以判断Channel具体准备好执行操作的类型。

selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
复制代码

另外,还可以通过方法来获取对应的Selector和Channel。

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();  
复制代码

attach object是一个Object对象,它用于向SelectionKey附加一些额外需要的东西,如和Channel一起使用的Buffer,或者任何想要附加的东西。

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();
复制代码

另外,在前面的register方法中也可以将attach object通过参数传入进行操作。

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
复制代码

一旦在Selector中注册好了多个Channel,已经对每个Channel感兴趣的事件进行了监听,就可以调用select方法。当注册的多个Channel中有一些Channel中监听的事件发生了,那么就会返回这些Channel。其中,select方法如下的三种形式:

  • int select():方法阻塞直到至少一个Channel准备好了对应的事件操作
  • int select(long timeOut):类似于上者,不过增加了一个最长的等待时间,不是一直阻塞
  • int selectNow():没有发现满足的Channel就立即返回,不阻塞

返回值表示准备好的Channel的个数。

一旦通过select方法返回了一个或多个Channel,可以调用selectKeys方法来获取对应的selectionKey的集合。

Set<SelectionKey> selectedKeys = selector.selectedKeys();   
复制代码

通过迭代遍历得到的集合就可以获取到这些准备好的Channel。

Set<SelectionKey> selectedKeys = selector.selectedKeys();

Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

while(keyIterator.hasNext()) {
    
    SelectionKey key = keyIterator.next();
	
    // 通过SelectionKey.channel()获取对应的Channel
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.

    } else if (key.isConnectable()) {
        // a connection was established with a remote server.

    } else if (key.isReadable()) {
        // a channel is ready for reading

    } else if (key.isWritable()) {
        // a channel is ready for writing
    }

    keyIterator.remove();
}
复制代码

注意每次迭代结束时调用的keyIterator.remove方法。Selector不会从集合中移除SelectionKey实例。当处理完Channel时,必须手动的将其移除。等到下次Channel变为ready时,选择Selector将再次将其添加到集合中。

由于管理Selector的单线程在所有的Channel都没有准备好的情况下,它会一直阻塞。如果想要该线程立刻唤醒,可以通过另一个线程调用Selector.wakeUp方法实现。如果另一个线程调用了wakeUp方法,并且当前在select方法中没有阻塞线程,那么下一个调用select方法的线程将立即唤醒。一旦Selector使用完毕,可以调用Selector的close方法,所有注册的SelectionKey实例都将作废,但是管理的Channel并不会关闭。


6. 参考

Java NIO Overview
Java NIO Channel
Java NIO Buffer
Java NIO Scatter / Gather
Java NIO Channel to Channel Transfers
Java NIO Selector

文章分类
后端
文章标签