从BIO到NIO之Channel、Buffer、Selector

BIO介绍

在jdk1.4之前,java的I/O是使用基于流的抽象模型来做的,io流模型把设备抽象成一个个管道,管道里的每个数据单元依次排列,这是一种同步阻塞模型。


image.png

类似于一个自来水管,源头放在数据源上,出口是你的java程序,流是有方向的,水流出去了就没办法让它流回来。

最基础的两个抽象是 InputStream和OutputStream, IO流都使用隐式的指针记录当前准备从哪个数据单元读取,,其余的类功能都是在这两个类的基础上做装饰的。例如为了简化操作提高I/O效率的BufferedInputStreamReader,一次java函数调用就能读写大量的内容(应用层看来),而不是每次处理一个数据单元。

BIO缺陷

jdk1.4之前,java的io包里的输入流和输出流抽象都是面向流的模型, 一次只能处理一个字节,效率不高,并且会阻塞进/线程。

在网络编程中,BIO没有引入I/O多路复用模型,并且BIO里的方法都是同步阻塞的,所以通常都是起一个accepter线程去阻塞监听客户端连接,接收到请求之后起一个新的线程去处理。


image.png

这样有几个明显的缺点:

  • 客户端连接很多的时候,会创建大量的处理线程,每创建一个线程都需要分配一定的栈空间,一般是1K~1M,那么4G内存也只能起4000~40000个线程。
  • 线程多导致上下文切换严重
  • 阻塞导致负责网络数据读写的线程不可复用

其中, 1可以使用线程池来解决,但是线程池的处理能力是有限的。其中, 1可以使用线程池来解决,但是线程池的处理能力是有限的。阻塞导致负责网络数据读写的线程不可复用, 在高并发大量连接的场景下,假如某个线程要从socket里读1k的数据,但是现在客户端网络不行,只发了0.1k, 那么这个线程池里的线程也阻塞在那里, 没有让出资源去读别的socket中的数据,导致整体效率不高,整体连接数也有限。

NIO的设计

为了解决BIO的问题,在JDK1.4以后,加入了NIO(New IO, 也有说法称Non-Blocking-IO) NIO的设计依然是基于流的,只是可以非阻塞的读写了。

NIO的作者Doug Lea大佬似乎从AWT中得到了启发,使用了Java的事件驱动的设计(Event Driven Design)来设计NIO:
在NIO中,它们的概念实现分别是(NIO网络核心):

  • Channel, 对非阻塞的支持, Channel可以连接文件,socket等
  • Buffer, 类似数组, 可以直接由Channnel读写, DirectByteBuffer可以分配堆外内存
  • Selector, 告知哪些Channel上发生了I/O事件
  • SelectionKey, 代表I/O事件状态和绑定

其中,Selector就是I/O多路复用在Java里的封装,由内核来完成事件分发和告知,这样使得1个Java线程能处理很多链接。

在NIO的API中,Channel就是实现非阻塞的组件,而事件分发(Dispatcher)使用的是Selector组件,在传统的I/O流(Stream)是有方向的,而NIO支持双向读写,这样就需要将流中的数据读取到某个缓冲组件里,即Buffer组件.(Buffer组件还有个特殊的实现DirectByteBuffer, 可以申请堆外内存)

Java NIO中,channel用于数据的传输。类似于传统IO中的流的概念。channel的两端是buffer和一个entity,不同于IO中的流,channel是双向的,既可以写入,也可以读取。而流则是单向的,所以channel更加灵活。我们在读取数据或者写入数据的时候,都必须经过channel和buffer,也就是说,我们在读取数据的时候,先利用channel将IO设备中的数据读取到buffer,然后从buffer中读取,我们在写入数据的时候,先将数据写入到buffer,然后buffer中的数据再通过channel传到IO设备中。

我们知道NIO的特点就是将IO操作更加类似于底层IO的流程。我们可以通过底层IO的机制更好的理解channel。

所有的系统I/O都分为两个阶段:等待就绪和操作。

  • 等待就绪就是从IO设备将数据读取到内核中的过程。
  • 操作就是将数据从内核复制到进程缓冲区的过程。

channel就可以看作是IO设备和内核区域的一个桥梁,凡是与IO设备交互都必须通过channel,而buffer就可以看作是内核缓冲区。这样整个过程就很好理解了。

我们看一下读取的过程先从IO设备,网卡或者磁盘将内容读取到内核中,对应于NIO就是从网卡或磁盘利用channel将数据读到buffer中,然后就是内核中的数据复制到进程缓冲区,对应于就是从buffer中读取数据。

写入的过程则是:先从进程将数据写到内核中,对应于就是进程将数据写入到buffer中,然后内核中的数据再写入到网卡或者磁盘中,对应于就是,buffer中的数据利用channel传输到IO设备中。

Channel有如下特点:

  • 与传统IO中的流不同,channel是双向的,可读可写
  • channel从buffer中读取数据,写入数据也是先写入到buffer
  • channel可以实现异步读写操作
  • channel可以设置为阻塞和非阻塞的模式
  • 非阻塞模式意味着,当读不到数据或者缓冲区已满无法写入的时候,不会把线程睡眠
  • 只有socket的channel可以设置为非阻塞模式,文件的channel是无法设置的。文件的IO一定是阻塞的
  • 如果是文件channel的话,channel可以在channel之间传输数据

Channel主要实现有:

FileChannel
文件的读写是不可以设置为非阻塞模式
SocketChannel
根据tcp和udp,服务端和客户端,又可以分为, SocketChannel, ServerSocketChannel and DatagramChannel.它们是可以设置为非阻塞模式的

buffer

Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,是将数据直接写入或者将数据直接读到 Stream 对象中。

在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,它都是放到缓冲区中。

缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不 仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区类型

最常用的缓冲区类型是 ByteBuffer。一个 ByteBuffer 可以在其底层字节数组上进行 get/set 操作(即字节的获取和设置)。ByteBuffer 不是 NIO 中唯一的缓冲区类型。事实上,对于每一种基本 Java 类型都有一种缓冲区类型:

ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer

每一个 Buffer 类都是 Buffer 接口的一个实例。 除了 ByteBuffer,每一个 Buffer 类都有完全一样的操作,只是它们所处理的数据类型不一样。因为大多数标准 I/O 操作都使用 ByteBuffer,所以它具有所有共享的缓冲区操作以及一些特有的操作。

NIO Buffer Characteristics

buffer是java NIO中的块的基础
buffer可以提供一个固定大小的容器来读取和写入数据
在只读的模式下,buffer的内容不可变,但是的他/她的几个变量,position,limit都是可变的
默认情况下,buffer不是线程安全的

缓冲区内部细节
本节将介绍 NIO 中两个重要的缓冲区组件:状态变量和访问方法 (accessor)。
状态变量是前一节中提到的"内部统计机制"的关键。每一个读/写操作都会改变缓冲区的状态。通过记录和跟踪这些变化,缓冲区就可能够内部地管理自己的资源。
在从通道读取数据时,数据被放入到缓冲区。在有些情况下,可以将这个缓冲区直接写入另一个通道,但是在一般情况下,还需要查看数据。这是使用访问方法 get() 来完成的。同样,如果要将原始数据放入缓冲区中,就要使用访问方法 put()。

状态变量
可以用三个值指定缓冲区在任意时刻的状态:position,limit,capacity
这三个变量一起可以跟踪缓冲区的状态和它所包含的数据.在这我们举一个数据从一个输入通道拷贝到一个输出通道的例子来说明。

Position
您可以回想一下,缓冲区实际上就是美化了的数组。在从通道读取时,您将所读取的数据放到底层的数组中。 position 变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪一个元素中。因此,如果您从通道中读三个字节到缓冲区中,那么缓冲区的 position 将会设置为3,指向数组中第四个元素。同样,在写入通道时,是从缓冲区中获取数据。 position 值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的 position 将被设置为5,指向数组的第六个元素。

Limit
limit 变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
position 总是小于或者等于 limit。

Capacity
缓冲区的 capacity 表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ― 或者至少是指定了准许我们使用的底层数组的容量。
limit 决不能大于 capacity。

实例:
我们首先观察一个新创建的缓冲区。出于本例子的需要,我们假设这个缓冲区的 总容量 为8个字节。 Buffer 的状态如下所示


image.png

回想一下 ,limit 决不能大于 capacity,此例中这两个值都被设置为 8。我们通过将它们指向数组的尾部之后(如果有第8个槽,则是第8个槽所在的位置)来说明这点


image.png

第一次读取
现在我们可以开始在新创建的缓冲区上进行读/写操作。首先从输入通道中读一些数据到缓冲区中。第一次读取得到三个字节。它们被放到数组中从 position 开始的位置,这时 position 被设置为 0。读完之后,position 就增加到 3,如下所示:


image.png

以上Channel涵盖了文件limit 没有改变。
第二次读取
在第二次读取时,我们从输入通道读取另外两个字节到缓冲区中。这两个字节储存在由 position 所指定的位置上, position 因而增加 2:


image.png

limit 没有改变。

flip
现在我们要将数据写到输出通道中。在这之前,我们必须调用 flip() 方法。这个方法做两件非常重要的事:
它将 limit 设置为当前 position。
它将 position 设置为 0。
前一小节中的图显示了在 flip 之前缓冲区的情况。下面是在 flip 之后的缓冲区:

image.png

我们现在可以将数据从缓冲区写入通道了。 position 被设置为 0,这意味着我们得到的下一个字节是第一个字节。 limit 已被设置为原来的 position,这意味着它包括以前读到的所有字节,并且一个字节也不多。
第一次写入
在第一次写入时,我们从缓冲区中取四个字节并将它们写入输出通道。这使得 position 增加到 4,而 limit 不变,如下所示

image.png

第二次写入
我们只剩下一个字节可写了。 limit在我们调用 flip() 时被设置为 5,并且 position
不能超过 limit。所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。这使得 position增加到 5,并保持 limit 不变,如下所示:

image.png

clear
最后一步是调用缓冲区的 clear() 方法。这个方法重设缓冲区以便接收更多的字节。 Clear 做两种非常重要的事情:

  • 它将 limit 设置为与 capacity 相同。
  • 它设置 position 为 0。
    下图显示了在调用 clear() 后缓冲区的状态:
image.png

缓冲区现在可以接收新的数据了。

How to Read from NIO Buffer

* 首先创建一个指定大小的buffer
* ByteBuffer byteBuffer = ByteBuffer.allocate(512);
* 将buffer转换为读模式
* byteBuffer.flip();
* 然后从channel中读取数据到buffer中
* int numberOfBytes = fileChannel.read(byteBuffer);
* 用户从buffer中读取数据
* char c = (char)byteBuffer.get();

How to Write to NIO Buffer

Create a buffer by allocating a size.
ByteBuffer byteBuffer = ByteBuffer.allocate(512);//512 becomes the capacity

Put data into buffer
byteBuffer.put((byte) 0xff);

Selector

Selector是NIO中用来实现事件分发的组件,受AWT线程的启发,用于接收I/O事件并分发到合适的处理器。
Selector底层使用的依然是操作系统的select,poll和epoll系统调用,支持使用一个线程来监听多个fd的I/O事件。
Selector可以同时监控多个SelectableChannel的IO状况,是非阻塞IO的核心,一个Selector 有三个SelectionKey集合

  • 所有的SelectionKey集合,代表了注册在该Selector上的Channel
  • 被选择的SelectionKey集合:代表了所有可以通过select 方法获取的,需要进行IO处理的Channel
  • 被取消的SelectionKey集合:代表了所有被取消注册关系的Channel,在下次执行select方法时。这些 Channel对应的SelectKey会被彻底删除

SelectableChannel代表可以支持非阻塞IO操作的Channel对象,它可以被注册到Selector上, 这种注册关系由SelectionKey实例表示

下面举个聊天室的例子

import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
 * 使用NIO来实现聊天室
 */
public class NServer {

    // 用于检测所有Channel状态的selector
    private Selector selector = null;

    // 定义实现编码,解码的字符集对象
    private Charset charset = StandardCharsets.UTF_8;

    public void init() throws Exception {
        selector = Selector.open();
        //通过open方法来打开一个未绑定的ServerSocketChannel实例
        ServerSocketChannel server = ServerSocketChannel.open();
        InetSocketAddress isa = new InetSocketAddress("127.0.0.1", 8888);
        // 绑定到指定地址
        server.bind(isa);
        // 设置以非阻塞的方式工作
        server.configureBlocking(false);
        // 将Server注册到指定的Selector对象
        server.register(selector, SelectionKey.OP_ACCEPT);

        while (selector.select() > 0) {
            // 依次处理selector上的已选择的SelectionKey
            for (SelectionKey sk : selector.selectedKeys()) {
                //从selector上的已选择key集中删除正在处理的SelectionKey
                selector.selectedKeys().remove(sk);
                //如果sk对应的Channel包含客户端的连接请求
                if (sk.isAcceptable()) {
                    //调用accept方法接受此连接,产生服务器端的SocketChannel
                    SocketChannel accept = server.accept();
                    //采用非阻塞模式
                    accept.configureBlocking(false);
                    //将该SocketChannel注册到selector
                    accept.register(selector, SelectionKey.OP_READ);
                    //将sk对应的Channel设置成准备接受其他请求
                    sk.interestOps(SelectionKey.OP_ACCEPT);

                }

                // 如果sk对应的Channel有数据需要读取
                if (sk.isReadable()) {
                    // 获取该SelctionKey对应的Channel,该Channel有可读的数据
                    SocketChannel channel = (SocketChannel) sk.channel();
                    //定义准备执行读取数据的ByteBuffer
                    ByteBuffer buffer = ByteBuffer.allocate(1024);

                    String content = "";
                    //开始读取数据
                    try {
                        while (channel.read(buffer) > 0) {
                            buffer.flip();
                            content += charset.decode(buffer);
                        }
                        //打印从该SK对应的Channel读取到的数据
                        System.out.println("读取的数据" + content);
                        //将sk对应的channel设置成准备下一次读取
                        sk.interestOps(SelectionKey.OP_READ);
                    } catch (Exception e) {
                        //如果捕获到了该SK对应的Channel出现了异常,即表明
                        //该Channel对应的Client出现了问题,所以从selctor中取消该Sk的注册

                        sk.cancel();
                        if (sk.channel() != null) {
                            sk.channel().close();
                        }

                    }
                    //如果content的长度大于0,即聊天信息不为空,
                    if (content.length() > 0) {
                        //遍历该selecor里注册的所有SelectionKey
                        for (SelectionKey key : selector.keys()) {
                            //获取该key对应的channel
                            SelectableChannel target = key.channel();
                            //如果该Channel是SocketChannel对象
                            if (target instanceof SocketChannel) {
                                // 将读取到的内容写入该channel中
                                SocketChannel dest = (SocketChannel) target;
                                dest.write(charset.encode(content));
                            }
                        }
                    }

                }
            }
        }

    }

    public static void main(String[] args) {
        try {
            new NServer().init();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

使用nc localhost 8888就可以测试了,多开几个终端以模拟多个客户端。

这个例子里使用了ServerSocketChannel,类似于BIO中ServerSocket,用于监听某个地址和端口, 是TCP服务端的代表.同时还可以看到accept之后得到了一个SocketChannel, 代表一个TCP socket通道.

我们均使用了非阻塞模式, 在read的时候如果读取的数据不够,也不会阻塞调用线程。

例子

使用FileChannel来读写文件的一个例子:

/**
 * 测试FileChannel模拟传统IO用竹筒多次取水的过程
 * 
 * @author sound2gd
 *
 */
public class FileChannelTest2 {

    public static void main(String[] args) throws Exception{
        FileInputStream sr = new FileInputStream("src/com/cris/chapter15/f6/FileChannelTest2.java");
        FileChannel fc = sr.getChannel(); // Channel都不是手动new出来的,基本都是用静态方法Open出来的,或者从BIO的Stream里封装得到的(本质上也是调用某Channel的open方法)。
        ByteBuffer bf = ByteBuffer.allocate(256);
        
        //创建Charset对象
        Charset charset = Charset.forName("UTF-8");
        CharsetDecoder decoder = charset.newDecoder();
        
        while((fc.read(bf))!=-1){
            //锁定Buffer的空白区
            bf.flip();
            //转码
            CharBuffer cbuff = decoder.decode(bf);
            System.out.print(cbuff);
            //buffer初始化,用于下一次读取
            bf.clear();
            
        }
        
    }
}

用法还是比较简单的,从Channel读数据到Buffer用read, 从Buffer写数据到Channel用write
这里的FileChannel就是从FileInputStream上得到的, 查看其源码:

public FileChannel getChannel() {
    synchronized (this) {
        if (channel == null) {
            channel = FileChannelImpl.open(fd, path, true, false, this);
        }
        return channel;
    }
}

可以看到,还是调用了FileChannelImpl的open方法

charBuffer例子

public static void main(String[] args) {
        // 创建Buffer
        CharBuffer buffer = CharBuffer.allocate(8);
        System.out.println("buffer的容量:" + buffer.capacity());
        System.out.println("buffer的位置:" + buffer.position());
        System.out.println("buffer的界限:" + buffer.limit());

        buffer.put('s');
        buffer.put('o');
        buffer.put('u');
        buffer.put('n');
        buffer.put('d');
        System.out.println("加入5个元素后position:" + buffer.position());

        // 调用flip
        buffer.flip();
        System.out.println("buffer的容量:" + buffer.capacity());
        System.out.println("buffer的位置:" + buffer.position());
        System.out.println("buffer的界限:" + buffer.limit());

        // 取出第一个元素
        System.out.print("buffer中的元素:" + buffer.get());
        while (buffer.hasRemaining()) {
            System.out.print(buffer.get());
        }
        System.out.println();
        System.out.println("取出第一个元素后position=" + buffer.position());

        // 调用clear
        buffer.clear();
        System.out.println("第3个元素" + buffer.get(2));

    }

小结

NIO的根本还是I/O多路复用, 操作系统告诉你哪个fd可读可写,内核帮你做了Event Loop,比在应用层用户空间做无疑是提升了太多的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342