【NIO实战】深入理解NIO三大组件:buffer、channel、selector

前言

根据IO模型,可以把IO分为下面三种

  1. BIO

BIO(Blocking IO)是最传统的一种IO模型,BIO在读/写数据如果没有可以读取/写入时会发生阻塞。BIO 读写是面向流的,一次性只能从流中读取一个或者多个字节,并且读完之后流无法再读取,需要我们自己讲数据缓存起来,BIO位于java.io包中。

  1. NIO

NIO(New IO)采用非阻塞模式,它是基于Reactor模式,IO调用不会被阻塞,它是NIO位于java.nio包中。

  1. AIO

AIO 也就是 NIO 2,在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞 的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是说 AIO 模式不需要selector 操作,而是是事件驱动形式,也就是当客户端发送数据之后,会主动通知服务器,接着服务器再进行读写操作。AIO位于java.nio包中。

BIO、NIO、AIO对比

BIONIOAIO
IO模型同步阻塞同步非阻塞(多路复用)异步非阻塞
编程难度简单复杂复杂
可靠性
吞吐量
JDK版本1.01.41.7

NIO概述

Java NIO由以下几个核心部分组成

  • Channels
  • Buffers
  • Selectors

Channel

Channel可以翻译成"通道",一个Channel表示一个对象的连接,例如文件,网络socket或者执行不同IO操作的程序。Channel只能是打开或者关闭状态,一旦Channel关闭了就不能再次打开。

它可以对应到BIO中的Stream(流),Channel与Stream的区别是Stream是单向的,Channel是双向的,我们在使用流的时候有InputStreamOutputStream,而Channel是双向的,它即可以用来进行读操作,页可以进行写操作。

Channel是一个接口,它提供了如下两个方法

public interface Channel extends Closeable {
		// channel是否是打开状态
    public boolean isOpen();
		// 关闭channel
    public void close() throws IOException;
}
复制代码

Channel接口与它的主要实现类的类图如下所示,从图中可以看出Channel的主要实现类有FileChannelDatagramChannelSocketChannelServerSocketChannel,分别对应的是文件IO、UDP,TCP Client,TCP Server。

Buffer

Buffer可以翻译成"缓冲区",Buffer是一个基于原始数据类型的线性的、有限元素序列容器,它的本质是一块可以写入数据,然后可以从中读取数据的内存,这块内存被包装成Buffer对象,并提供了一组方法,用来方便的操作该内存。Buffer的主要作用是与Channel进行交互,读取数据时,数据是从Channel读入Buffer,写入数据时,数据是从Buffer写入Channel。

Buffer属性介绍

Buffer是一个抽象类,它位于java.nio包内,它包含如下属性

// 缓冲区的位置
private int position = 0;
// 缓冲区的限制
private int limit;
// 缓冲区的标记
private int mark = -1;
// 缓冲区的容量
private int capacity;
复制代码

1. position属性

它是要读取或写入的下一个元素的索引。在buffer进行读写模式改变时,position的值会进行相应调整

  • 写模式

在刚进入写模式时,position的值为0,表示当前写入位置为从头开始,每当一个数据写入到缓冲区之后,position会向后移动到下一个可写位置,当position的值达到limit时,缓冲区就已经无空间可写了。

  • 读模式

当缓冲区刚进入读模式时,position会被重置为0,每读一个数据,position会向后移动到下一个可读为止,当position的值达到limit时,缓冲区就已经无数据可读了。

2. limit属性

可以写入或者读取的数据最大上限

  • 写模式

limit属性值的含义为可以写入数据的最大上限,在刚进入写模式时,limit的值会别设置成Buffer的capacity值,表示一直可以将缓冲区内容写满

  • 读模式

limit值的含义为最多能从buffer读取数据的最大上限,flip方法将缓冲区切换到读模式时,limit的值会被设置为写模式的position。

3. mark属性

在缓冲区操作过程中,可以将当前position的值临时存入mark属性中,需要的时候再将mark中的属性值取出,暂存的mark值,恢复到position属性中

4. capacity属性

capacity是缓冲区包含的元素数,一旦写入对象数量超过了capacity,缓冲区就满了,不能再写入

Buffer中上述变量大小存在如下关系

0 <= mark <= position <= limit <= capacity

Buffer在读模式写模式下4个属性的关系如下图所示,以及在两种模式下的可读范围与可写范围

Buffer的创建

如果想要获得一个Buffer对象,可以通过如下方法分配一个Buffer

// 在堆空间中创建一个1024字节的ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 在直接内存中创建一个1024字节的ByteBuffer
ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(1024);
// ByteBuffer直接wrap一个bytes
ByteBuffer wrapBuffer = ByteBuffer.wrap("Hello 林师傅!".getBytes());
// 分配一个1024字符的CharBuffer
CharBuffer charBuffer = CharBuffer.allocate(1024);
复制代码

ByteBuffer中allocate与allocateDirect的区别

想要知道allocate与allocateDirect的区别,我们直接来看两者的源码

// ByteBuffer#allocate
public static ByteBuffer allocate(int capacity) {
    if (capacity < 0)
        throw createCapacityException(capacity);
    return new HeapByteBuffer(capacity, capacity);
}
// ByteBuffer#allocateDirect
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}
复制代码

allocate()返回的是HeapByteBuffer对象,它对应的是JVM堆空间的内存。allocateDirect()返回的是DirectByteBuffer,它对应的是直接内存,不受JVM管理。

向Buffer中写数据

写数据到Buffer有两种方式

  • 通过Buffer#put()方法写数据
buffer.put(123)
复制代码
  • 从Channel写数据到Buffer
channel.read(buffer)
复制代码

从Buffer中读数据

从Buffer中读数据有两种方式

  • 通过Buffer#get()方法读数据
// 从byteBuffer获取一个字节
byte b = byteBuffer.get();
// 从byteBuffer中获取一个int
int intResult = byteBuffer.getInt();
// 从byteBuffer将数据写入到数组中,如果数组的长度大于Buffer中可读数据的长度,会抛出异常
byte[] dst = new byte[5];
byteBuffer.get(dst);
复制代码
  • 从Buffer读数据到Channel
channel.write(buffer)
复制代码

Buffer其他常用的方法

  • flip():翻转

调用flip()可以将Buffer从写模式切换为读模式,切换之后可以读取之前写入Buffer的数据。抵用flip()会将limit设为position,然后将position设为0,mark设为-1。

// java.nio.Buffer#flip
public Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}
复制代码
  • clear():清空

flip()是将Buffer切换为读模式,clear()就是将Buffer切换为写模式,切换之后Buffer可以从头开始写入数据,调用clear()会将position被设为0,limit被设为capacity,mark为-1

// java.nio.Buffer#clear
public Buffer clear() {
    position = 0;
    limit = capacity;
    mark = -1;
    return this;
}
复制代码
  • rewind():重新读取

Buffer中的数据读取完之后,调用rewind()方法,可以再次读取数据,它保持limit不变并将position设置为0,mark设为-1。

public Buffer rewind() {
    position = 0;
    mark = -1;
    return this;
}
复制代码
  • slice()

创建当前Buffer的浅拷贝切片,新创建的Buffer与原Buffer共享一块内存区域,只是将原Buffer从position到limit之间的数据交给新的Buffer引用。我们可以通过下面例子来理解slice()

public static void main(String[] args) {
    ByteBuffer buffer = ByteBuffer.allocate(10);
    System.out.println("---put---");
    for (int i = 0; i < 8; i++) {
        buffer.put((byte) i);
    }
    printBuf(buffer);
    buffer.flip();
    for (int i = 0; i < 5; i++) {
        buffer.get();
    }
    System.out.println("---slice---");
    printBuf(buffer);
    ByteBuffer sliceBuf = buffer.slice();
    printBuf(sliceBuf);
}

private static void printBuf(ByteBuffer buffer) {
    StringBuilder builder = new StringBuilder();
    builder.append("position:").append(buffer.position()).append(";  limit:").append(buffer.limit())
            .append(";  capacity:").append(buffer.capacity()).append("  Content:[");
    for (int i = 0; i < buffer.array().length; i++) {
        if (i != 0) 
            builder.append(",");
        builder.append(buffer.array()[i]);
    }
    builder.append("]");
    builder.append("\tcontentHashCode:").append(Arrays.hashCode(buffer.array()));
    System.out.println(builder);
}
复制代码

输出结果如下,可以看到slice之后获得的新Buffer的position会变成0,limit和capacity的值为limit-position,新Buffer中保存的还是原Buffer数组的引用。

  • duplicate()

创建缓冲区的浅拷贝,新创建的Buffer与原Buffer共享一块内存区域,新Buffer与原Buffer的mark,position,limit,capacity都相同。我们通过下面例子看理解duplicate()

public static void main(String[] args) {
    ByteBuffer buffer = ByteBuffer.allocate(10) ;
    System.out.println("---put---");
    for (int i = 0; i < 10; i++) {
        buffer.put((byte) i);
    }
    printBuf(buffer);
    System.out.println("---duplicate---");
    ByteBuffer dupBuf = buffer.duplicate();		// dup Buffer
    printBuf(dupBuf);
    System.out.println("---modify---");
    buffer.put(5, (byte) 'a');
    printBuf(buffer);													// 打印修改后的buf
    printBuf(dupBuf);													// 打印修改后的dupBuf
}
		// ... 省略printBuf代码,与sliceDemo中相同
复制代码

输出结果如下所示,我们可以看到复制后的buffer与原buffer的内容,参数都完全相同,底层共用了一个同一个byte数组(数组hash值相同),修改原buffer后,duplicate出来的buffer也被修改了,这里也可以验证前面说的duplicate只是一个浅拷贝。

Buffer的主要实现类

Buffer中的主要实现类如下所示

Selector

Selector是SelectableChannel的多路复用器,也可以称为选择器,一般用于用于检查一个或多个 NIO Channel(通道)的状态是否处于可读、可写。如此可以实现单线程管理多个 channels,也就是可以管理多个网络链接。使用 Selector 的好处在于: 使用更少的线程来就可以来处理通道了,相比使用多个线程,避免了线程上下文切换带来的开销。

Selector的类图如下,可以看到Selector的最终实现类是KQueueSelectorImpl继承了SelectorImpl,在不同的操作系统中,Selector的最终实现类略有不同,在window系统中是WindowsSelectorImpl,在Linux中是EPollSelectorImpl,由于笔者用的是macOS,selector的最终实现类是KQueueSelectorImpl

Selector的创建

要获得一个Selector对象,可以通过调用Selector.open()方法创建一个Selector对象

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

打开open()的源码,可以发现是通过默认的SelectorProvider来创建的Selector,SelectorProvider是一个单例

public static Selector open() throws IOException {
    return SelectorProvider.provider().openSelector();
}
复制代码

注册Channel到Selector

SelectableChannel中注册方法,将channel注册到selector,并传入感兴趣的SelectionKey

selectableChannel.register(selector,SelectionKey)
复制代码

注意:

  1. 与Selector配合使用时,Channel必须处于非阻塞模式下
  2. 一个通道并不一定支持所有SelectionKey事件

使用Selector的好处在于:使用更少的线程就可以来处理通道,相比使用多个线程,避免了线程上下文带来的切换

轮询就绪操作

通过Selector的select()方法,可以查询出已经就绪的通道操作。

// Selector中的
// 阻塞到至少有一个通道在注册上的时间就绪了
public abstract int select() throws IOException;
// 和select()一样,但最长阻塞事件为timeout毫秒
public abstract int select(long timeout) throws IOException;
// 非阻塞,只要通道就绪就立即返回
public abstract int selectNow() throws IOException;
复制代码

SelectionKey详解

SelectionKey表示SelectableChannel在Selector中注册的标识,Chanel向Selector注册时,都会创建一个SelectionKey。SelectionKey保存了注册Channel、注册的Selector、通道事件类型操作符等信息。

SelectionKey是一个抽象类,它有两个实现类AbstractSelectionKey(抽象类)SelectionKeyImpl(最终实现类),类图如下所示。

SelectionKey包含如下4种操作类型。

// 读操作符,值为1
public static final int OP_READ = 1 << 0;
// 写操作符,值为4
public static final int OP_WRITE = 1 << 2;
// 连接操作符,值为8
public static final int OP_CONNECT = 1 << 3;
// 接收操作符,值为16
public static final int OP_ACCEPT = 1 << 4;
复制代码

SelectableChannel

SelectableChannel是一个可通过Selector多路复用的Channel。我们来看下SelectableChannel的定义,SelectableChannel是一个抽象类,并且实现了Channel方法,SelectableChannel也是DatagramChannel,SocketChannel,ServerSocketChannel的父类,SelectableChannel并不是FileChannel的父类,因此FileChannel不能被选择器复用,SelectableChannel类的源码如下

public abstract class SelectableChannel extends AbstractInterruptibleChannel implements Channel{
	// ...
  // 获取通道向给定Selector注册的密钥。
  public abstract SelectionKey keyFor(Selector sel);
  // 向给定Slelector注册此Channel,并返回SelectionKey
  public abstract SelectionKey register(Selector sel, int ops, Object att) throws ClosedChannelException;
  // 向给定Slelector注册此Channel,并返回SelectionKey
  public final SelectionKey register(Selector sel, int ops) throws ClosedChannelException{return register(sel, ops, null);}
  // ...
}
复制代码
  • 如果SelectableChannel要和Selector一起使用,可以通过上面的register方法将当前Channel注册到Selector中,并返回个新的 SelectionKey 对象,该对象表示Channel在Selector中注册的令牌

  • SelectableChannel至多只能在在某个Selector中注册一次,一旦Channel向Selector注册后,Channel将变成"已注册"状态。

  • SelectableChannel是线程安全的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值