[NIO和Netty] NIO和Netty系列(一): NIO中selector、channel和buffer

最近看zookeeper源码,发现底层的通信使用到了NIO和netty,接下来的系列记录下NIO和Netty的学习,记录完接着zookeeper源码的学习。

java.io中最为核心的概念是流(stream),是面向流的编程,一个流要么是输入流,要么是输出流,不可能同时即是输入流又是输出流;而java.nio是面向块(block)或面向缓冲区(buffer)编程,块或者缓冲区既可以作为输入也可以作为输出,nio中有三个核心的概念,即SelectorChannelBuffer

Channel

NIO中的Channel类似于IO中Stream,但与Stream又有所不同:

  • IO中一个Stream要么是输入流,要么是输出流,不能同时即是输入流又是输出流,而NIO中既可以从Channel中读取数据,也可以向同一个Channel中写数据;
  • Channel可以异步的读写,而Stream是同步的;
  • Channel总数向Buffer写入数据,或者从Buffer读取数据,在NIO编程中不能绕过Buffer直接与Channel读写数据;

如下图所示为NIO中的数据流向:
在这里插入图片描述
在NIO中一个Channel代表一个连接,例如连接到一个设备、文件、网络socket等,Channel定义在java.nio.channels.Channel中,有如下几个重要的实现类:

  • FileChannel:从文件读取或向文件写入数据,还可以将文件映射到一块内存中(通过java.nio.channels.FileChannel#map方法);
  • DatagramChannel:通过UDP协议读写网络数据;
  • SocketChannel:通过TCP协议读写网络数据;
  • ServerSocketChannel:用来监听TCP连接,一旦有连接进来通过调用java.nio.channels.ServerSocketChannel#accept方法获取SocketChannel连接;

如下示例为通过FileChannel将文件内容读取到Buffer中:

    RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
    // 获取FileChannel
    FileChannel inChannel = aFile.getChannel();

	 // 分配一个48字节大小的buffer,内部其实就是一个byte数组
    ByteBuffer buf = ByteBuffer.allocate(48);

	 // 通过FileChanne将文件内容读取到buffer中,返回的bytesRead是读取的字节数
    int bytesRead = inChannel.read(buf);
    while (bytesRead != -1) {

      System.out.println("Read " + bytesRead);
      // 将buffer切换到读取模式,之前FileChannel写入buffer是写入模式,在Buffer相关内容会学习这个方法的作用
      buf.flip();

      // 将buffer中的数据一个一个字节读取出来
      while(buf.hasRemaining()){
          System.out.print((char) buf.get());
      }
      
      // 相当于复位,为下次将文件内容写入buffer做准备buffer内容中会学习该方法作用
      buf.clear();
      // 继续通过FileChannel将文件内容读取到buffer
      bytesRead = inChannel.read(buf);
    }
    aFile.close();

Buffer

顾名思义,NIO中的Buffer就是缓冲区,数据都是从Channel读取到Buffer,或者从Buffer写入到Channel,Buffer就是我们编写代码和Channel之间的一个缓冲区。Buffer内部缓存数据的载体通常是一个特定类型的数组(ByteBuffer、IntBuffer…)或一块内存(DirectByteBuffer…)。

buffer的基本使用分为四个步骤,如下图所示:
在这里插入图片描述

  1. 首先从Channel或我们的代码中往Buffer写入数据;
  2. 然后调用flip方法切换到读模式;
  3. 将数据从Buffer读取到我们的代码中或读取后写入到Channel中;
  4. 调用clear方法为下一次向Buffer写入数据做准备;

使用到了Bufferclearcompactflip等方法,在了解这些方法之前我们先了解Buffer中维护缓存数据的几个属性: positionlimitcapacitymark.

Buffer中的Position、Limit、Capacity和Mark

Buffer中维护了如下几个属性:

  • Capacity:Buffer的容量,即Buffer最大能缓存的元素个数,这个值是在各个子类创建Buffer的allocate(size)方法中指定的,一旦Buffer创建好该值不能更改;
  • Limit:第一个不能读取或写入的位置的索引,也就是一个Buffer最大能读取或写入的位置索引是(Limit - 1),Limit总是小于或等于Capacity;
  • Position:下一个能读取或写入的位置的索引,Position的值总是要小于或等于Limit;

此外Buffer中还维护了Mark属性,Mark顾名思义就是标记,举个例子:假设要往Buffer中放入10个数字,当放到第五个数字时调用Buffer的mark()方法标记下,此时Buffer中mark属性的值就是4(从0开始),接着继续往Buffer中放入剩下的数字,此时position值为9,若此时调用Buffer的reset方法,则position被重置为mark的值(4),可以继续往Buffer中放入数据,覆盖mark位置之后的数据。

Buffer中几个重要的操作

Buffer提供了几个常用的方法来修改上述几个属性的值,包括clearfliprewindsliceduplicate等。

clear操作

clear方法的源代碼如下:

    public Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }

可见clear方法只是将position重置为0,将limit重置为capacity,相当于将Buffer恢复到初始状态,但Buffer里面保存的数据并未清除,当下次向Buffer中写入数据时会才会将这些数据清除。

flip操作

flip操作的源码如下:

    public Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }

可见flip方法只是将limit重置为position,将position值重置为0,mark重置为-1。那么为什么在切换读写buffer模式时需要调用flip方法呢,如下图所示:
在这里插入图片描述
灰色部分代表该位置实际不存在,可见调用flip方法的作用,将position指向第0个位置,limit指向可以读取的最后一个数据的下一个位置,这样限定了从Buffer读取数据的首末位置(读取position到limit之间的数据)。每次由向Buffer写入数据切换到从Buffer读取数据前必须调用flip方法,否则读取的数据内容不确定

rewind操作

rewind操作源码如下:

    public Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
    }

rewind会将position重新置为0,通常用在这样希望重复读取Buffer的场景,比如已经向Buffer中写入了数据并且调用flip限定读取范围后读取了Buffer一次,然后又想再次读取Buffer的内容,就可以调用rewind方法将position重置为0(limit位置不变),重新读取数据。

slice操作

使用当前Buffer创建一个新的Buffer,新Buffer和原来的Buffer使用同一个数据载体(同一个数组或同一块内存),因此,当修改旧Buffer中数据时,会影响到新Buffer中的数据,新Buffer中的数据时旧Buffer中position到limit之间的数据,slice操作过程如下图所示:
在这里插入图片描述
旧Buffer如果是只读的,那么slice返回的Buffer也是只读的。新Buffer数据载体和旧Buffer是共享的,但positionlimitcapacity等属性是独立于旧Buffer的。

DirectByteBuffer和MappedByteBuffer

DirectByteBuffer用于管理(分配、回收、读写数据)堆外内存。堆外内存是相对于JVM堆内内存来说的,堆内内存是jvm所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循jvm的内存管理机制,jvm会采用GC回收机制统一管理堆内内存。堆外内存就是存在于jvm管控之外的一块内存区域,因此它是不受jvm的管控。

DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。

使用如下代码即可分配一块大小为1024的堆外内存:

ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

DirectByteBuffer提供了putXXX和getXXX系列方法来使用堆外内存,例如java.nio.DirectByteBuffer#putShort(short)方法将一个short值放入到堆外内存当中,java.nio.DirectByteBuffer#getShort(long)方法用于将内外内存的short数据取回来。

MappedByteBuffer是DirectByteBuffer的父类,MappedByteBuffer通过java.nio.channels.FileChannel#map将一个文件映射到一块堆外内存区域,本质上是通过反射构造了一个DirectByteBuffer对象,并持有一个指向该文件的文件描述符(FileDescriptor,非文件映射的堆外内存该描述符为null)。使用MappedByteBuffer减少了一次文件数据由内核空间拷贝到用户进程空间的过程。更多详细信息将会在零拷贝相关文章中分析。

Selector

Selector用语检测一个或多个NIO Channel是否可读、可写、可连接等状态,使用Selector可以实现用一个线程来管理多个Channel,从而减少线程的数量。如下示例为向Selector注册Channel:

// 打开一个监听连接的socket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 设置该socket以非阻塞模式监听
serverSocketChannel.configureBlocking(false);
// 绑定 地址+端口
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(new InetSocketAddress(8899));

// 打开一个Selector
Selector selector = Selector.open();
// 向Selector注册监听连接的Channel,并传入感兴趣的操作,返回一个SelectionKey
SelectionKey listenKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

注册完得到一个对SelectionKey.OP_ACCEPT操作感兴趣的SelectionKey。

SelectionKey

与Selector密切相关的另一组件是SelectionKey。当向Selector注册一个Channel时会返回一个SelectionKey。一个SelectionKey在以下任何一种情况都会失效:

  • 调用SelectionKey的cancel方法;
  • 关闭该SelectionKey对应的Channel;
  • 关闭该SelectionKey对应的selector;

Selector的实现中会维护三个SelectionKey的集合:

  • key set:该集合保存了当前向Selector注册的所有Channel对应的SelectionKey的集合,每当向Selector注册Channel时就会向key set集合添加一个SelectionKey;
  • selected-key set:Selector会检测所有注册的Channel,一旦该Channel发生了某个事件,并且对应的SelectionKey刚好对这个事件感兴趣,那么该SelectionKey就被选中放到selected-key set集合中;
  • cancelled-key:当一个SelectionKey已经被取消,但对应的Channel没有被注销,则该SelectionKey被放入到cancelled-key,被cancel的key将会在下一次select操作时从key set删除,对应的Channel也会从Selector注销;

SelectionKey的实现中包含两个操作集合:

  • interest set:注册Channel时指定对应SelectionKey感兴趣的操作集合;
  • ready set:当Channel发生的操作在interest set集合中时,该SelectionKey会被选中放到selected-key set集合当中,并且该操作会加入到ready set,表示该操作已经就绪;

SelectionKey中定义了如下几种操作(OP_WRITE和OP_CONNECT还不是很理解,但貌似大多数情况下使用OP_READ和OP_ACCEPT):

  • OP_READ:如果Selector检测到该SelectionKey对应的Channel准备好了可以读、或者达到了读stream的末端、或者被远端关闭读、或者有错误发生,并且该SelectionKey的interest set包含OP_READ,则会将OP_READ添加到ready set当中;
  • OP_WRITE:如果Selector检测到该SelectionKey对应的Channel准备好了可以写、或者被远端关闭写、或者有错误发生,并且该SelectionKey的interest set包含OP_WRITE,则会将OP_WRITE添加到ready set当中;
  • OP_CONNECT:如果Selector检测到该SelectionKey对应的Channel准备好了完成这次连接、或者有错误发生,并且该SelectionKey的interest set包含OP_CONNECT,则会将OP_CONNECT添加到ready set当中;
  • OP_ACCEPT:如果Selector检测到该SelectionKey对应的Channel准备好了接收另外一个连接、或者有错误发生,并且该SelectionKey的interest set包含OP_ACCEPT,则会将OP_ACCEPT添加到ready set当中;

此外SelectionKey提供了java.nio.channels.SelectionKey#attach方法可以在注册Channel时将一个对象保存到SelectionKey当中,在select操作中SelectionKey被选中的话在调用java.nio.channels.SelectionKey#attachment将该对象取出来,这样可以传递一些上下文信息。

select系列操作

Selector提供了若干个个select相关的方法:

// select本身是阻塞操作,直到有Channel准备好了对应的感兴趣操作,才会返回,返回值为选中的Channel个数
public abstract int select() throws IOException;
// select操作等待timeout时间
public abstract int select(long timeout) throws IOException;
// select立即返回,若没有ready的Channel,则返回0
public abstract int selectNow() throws IOException;
// jdk11提供,等待Channel ready并在action中消费SelectionKey,超时时间为timeout
public int select(Consumer<SelectionKey> action, long timeout) throws IOException
public int select(Consumer<SelectionKey> action) throws IOException
public int selectNow(Consumer<SelectionKey> action) throws IOException

select系列方法一旦返回并且返回值大于0的话则有Channel已经准备号(ready),接下来可以调用java.nio.channels.Selector#selectedKeys方法获取已经ready的SelectionKey的集合。接着遍历SelectionKey从调用SelectionKey的如下方法获取对应的Channel和selector:

public abstract SelectableChannel channel();
public abstract Selector selector();

Selector示例代码

如下示例为使用Selector + Channel + Buffer实现一个简化版聊天程序的服务端服务端:

import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

public class ChatServer {
	 // 用于保存所有客户端
    private static Map<SocketChannel, String> clientMap = new HashMap<>();

    public static void main(String[] args) throws Exception {
		 // 获取一个监听连接的Channel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 在selector编程中一定要设置为非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 绑定ip地址和端口
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(new InetSocketAddress(8899));

		 // 获取selector
        Selector selector = Selector.open();
        // 将监听Channel注册到selector中,对应SelectionKey感兴趣的操作为OP_ACCEPT,即接收连接
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
        	  // select阻塞直到有ready的Channel
            selector.select();
            // 获取ready的SelectionKey集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
			  // 遍历SelectionKey集合针对不同的操作做相应的处理
            selectionKeys.forEach(selectionKey -> {
                try {
                    final SocketChannel client;
                    final String key;
                    if (selectionKey.isAcceptable()) {
                    	   // SelectionKey ready的操作为接收连接,获取Channel,接收连接(客户端和服务端accept后的Channel通信)
                        client = ((ServerSocketChannel)selectionKey.channel()).accept();
                        // 设置为非阻塞模式
                        client.configureBlocking(false);
                        // 注册Channel到selector中,对应SelectionKey感兴趣的操作为OP_READ
                        client.register(selector, SelectionKey.OP_READ);
                        key = "[" + UUID.randomUUID() + "]";
                        // 保存客户端用于群发消息
                        clientMap.put(client, key);
                    } else if (selectionKey.isReadable()) {
                    		// SelectionKey ready的操作为读取数据,同样先获取到Channel(与客户端通信的那个SocketChannel)
                        client =  (SocketChannel)selectionKey.channel();
                        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                        key = clientMap.get(client);
							// 从Channel将数据读取到ByteBuffer中
                        int count = client.read(readBuffer);
                        if (count <= 0) {
                            return;
                        }
                        readBuffer.flip();
                        Charset charset = Charset.forName("utf-8");
                        String sendMsg = key + ":" + String.valueOf(charset.decode(readBuffer).array());
                        // 构造群发消息
                        ByteBuffer sendBuffer = charset.encode(sendMsg);
                        // 遍历所有Channel将消息发送给所有客户端
                        for (SocketChannel channel : clientMap.keySet()) {
                            channel.write(sendBuffer);
                            // 因为需要重复的从sendBuffer读取数据到Channel,因此将sendBuffer的position属性置为0
                            sendBuffer.rewind();
                        }
                    }
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            });
            selectionKeys.clear();
        }
    }
}

客户端可以开启多个telnet连接localhost 8899端口进行测试。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值