一文搞定NIO的三大组件

提到NIO网络编程,就不得不提一下NIO中的三大组件: Buffer、Channel、Selector,JDK源码开发者在这里用了很通用形象的三个词语,去分别赋予三个类的抽象含义,Buffer即缓冲,Channel即管道,Selector为选择器,因为底层实现机制的复杂, 便于开发者的理解用了这三个词语去定义NIO的核心类,但是除了表面上的这层含义,Java开发者还是需要去花点时间,真正深入了解其背后的原理实现。

一、Buffer缓冲区


读NIO源码会发现Buffer最终是实现了一个在内存中的字节数组,所以一个 Buffer 本质上是内存中的一块,我们可以将数据写入这块内存,之后从这块内存获取数据。这一点改变了原有的IO方式,回忆一下原来的IO方式,我们的编程范式是从Stream流里去读取数据,一个一个字节的读取,读完以后就放在自己提前定义的数组对象里面,读取的时候只能顺序移动下标、且不能回溯,但是NIO的buffer不一样了,内部使用字节数组存储数据,并维护几个特殊变量,实现数据的反复利用。

首先用mark来备份当前的position,然后用position表示当前可以写入或读取数据的位置,再用capacity来表示缓存数组大小,最后还有一个表示剩余容量的limit参数,就像下面这张图的样子。

Buffer结构

Buffer提供了八种类型的Buffer,覆盖了能从 IO 中传输的所有的 Java 基本数据类型,但是在网络编程的场景下用的最多的还是ByteBuffer。

ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer

JDK NIO原生的ByteBuffer功能虽然强大,但是在直接用于开发是有些费劲的,这时候经过Netty封装的ByteBuf则显得简单多了,ByteBuffer的实现类包括"HeapByteBuffer"和"DirectByteBuffer"两种,前者的Heap方式是基于堆内存使用的缓冲字节数组,而后者的Direct则是使用的堆外内存(系统内存)。

HeapByteBuf最终往下追溯allocateArray()方法,可以发现这个是直接实例化New出来的字节数组,毫无疑问就是交给JVM托管的对象了。

    protected byte[] allocateArray(int initialCapacity) {
        return new byte[initialCapacity];
    }

而DirectByteBuffer追溯到最终的allocatDirect方法里,可以看到此处调用JDK的native方法去往堆外空间来分配对象数组。

二、Channel通道


1.Channel和Stream的区别

通道是对原来 IO 包中的Stream流的模拟,通过Channel这个对象,我们可以读取和写入数据,并且通过Channel的所有发送数据都要读到缓冲区Buffer中,所有要接收的数据都要写到缓冲区Buffer里。NIO中的Channel和IO中的Stream最显著的区别如下:

  • 流是单向的,通道是双向的,可读可写。
  • 流读写是阻塞的,通道可以异步读写。
  • 流中的数据可以选择性的先读到缓存中,通道的数据总是要先读到一个缓存中,或从缓存中写入,如下所示:

解释一下为啥Stream流是单向的?之前用IO的方式去读写数据的时候,读写是要分离的,即必须要明确是InputStream还是OutputStream,而在Channel这里,一条连接客户端和服务端的Channel是共用的,NIO开发中可以利用channel.read()方法去读取socket缓冲区的数据,也可以通过channel.write()去刷出数据到客户端。Channel的实现类很多,这里需要重点了解的就是SocketchannelServerSocketChannel

再解释一下为何流是阻塞的而通道不是?流的read和write都是同步操作,在Stream中调用读写方法时,必须要等IO操作完成以后才能执行下一步,需要顺序执行而没有异步的方式可以用。而NIO中Channel的读写是可以设置为非阻塞的,非阻塞模式下,write()方法在尚未写出任何内容时可能就返回了,这种模式下须得在while循环中判断来调用write()。

String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
    channel.write(buf);
}

2.Socketchannel

SocketChannel 暂时可以理解成一个 TCP 客户端(其实SocketChannel还可以作为服务端中Worker线程组要处理的TCP长连接),打开一个 TCP 连接的姿势如下:

// 打开一个通道
SocketChannel socketChannel = SocketChannel.open();
// 发起连接
socketChannel.connect(new InetSocketAddress("localhost", 80));

而读写数据的方式也很方便,读时read到缓冲buffer,写时刷出缓冲buffer即可:

// 读取数据
socketChannel.read(buffer);
// 写入数据到网络连接中
while(buffer.hasRemaining()) {
    socketChannel.write(buffer);   
}

3.ServerSocketChannel

ServerSocketChannel 可以理解为服务端,ServerSocketChannel 用于监听机器端口,管理从这个端口进来的 TCP 连接。

// 实例化
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 监听端口
serverSocketChannel.socket().bind(new InetSocketAddress(80));
while (true) {
    // 一旦有一个 TCP 连接进来,就对应创建一个 SocketChannel 进行处理
    SocketChannel socketChannel = serverSocketChannel.accept();
}

ServerSocketChannel 不和 Buffer 打交道了,因为它并不实际处理数据,它一旦接收到请求后,实例化 SocketChannel,之后在这个连接通道上的数据传递它就不管了,因为它需要继续监听端口,等待下一个连接;每一个TCP连接都分配给一个SocketChannel来处理了,读写都基于后面的SocketChannel,这部分其实也是网络编程中经典的Reactor设计模式。

三、Selector选择器


1.Selector原理

Selector是三大组件中的最C位的对象,Selector建立在非阻塞的基础之上,IO多路复用在Java中实现就是它,它做到了一个线程管理多个Channel,可以向Selector注册感兴趣的事件,当事件就绪,通过Selector.select()方法获取注册的事件,进行相应的操作。

具体的工作流程:Selecor通过一种类似于事件的机制来解决这个问题。首先你需要把你的连接,也就是 Channel 绑定到 Selector 上,然后你可以在接收数据的线程来调用 Selector.select() 方法来等待数据到来。这个 select 方法是一个阻塞方法,这个线程会一直卡在这儿,直到这些 Channel 中的任意一个有数据到来,就会结束等待返回数据。它的返回值是一个迭代器,你可以从这个迭代器里面获取所有 Channel 收到的数据,然后来执行你的数据接收的业务逻辑。既可以选择直接在这个线程里面来执行接收数据的业务逻辑,也可以将任务分发给其他的线程来执行,如何选择完全可以由你的代码来控制。

引用geektime.《消息队列高手课》

大致流程如下代码,也可以说是NIO编程的模板代码:

Selector selector = Selector.open();
// 实例化一个服务端的ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//开启非阻塞
serverSocketChannel.configureBlocking(false);
//绑定端口
serverSocketChannel.socket().bind(new InetSocketAddress(1234));
//ServerSocketChannel注册selector,并表示关心连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//没有socket就绪,select方法会被阻塞一段时间,并返回0
while (selector.select() > 0) {
    //socket就绪则会返回具体的事件类型和数目
    Set<SelectionKey> keys = selector.selectedKeys();
    //遍历事件
    Iterator<SelectionKey> iterator = keys.iterator();
    //根据事件类型,进行不同的处理逻辑;
    while (iterator.hasNext()) {
       SelectionKey key = iterator.next();
       iterator.remove();
       if (key.isAcceptable()) {
       ...
       } else if (key.isReadable() && key.isValid()) {
       ...
       }
       keys.remove(key);
    }
 }

一个服务端程序启动一个Selector,在Netty中一个NioEventLoop对应一个Selector,Netty在解决JDK NIO的epoll空轮询bug时,采用的策略是废弃原来的有问题的Selector,然后重建一个Selector。因此在Reactor的主从反应堆这里,不同的反应堆可以取不同的Selector事件来选择关心,可以用注册的事件有如下四种:

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

例如主Reactor通常就是设计为关心的OP_ACCEPT,而从Reactor就更关心其余的读写以及连接状态事件。Selector中的select是实现多路复用的关键,这个方法会一直阻塞直到至少一个channel被选择(即该channel注册的事件发生了为止,除非当前线程发生中断或者selector的wakeup方法被调用。

Selector.select方法最终调用的是EPollSelectorImpl的doSelect方法,在深入远吗就会发现其中的subSelector.poll() ,这里是select的核心,由native函数poll0实现了(不忍心贴代码,看懂要花挺多的时间了)。

NIO三大件的工作流程:

所以将上面NIO的三大组件串起来,并结合Reactor设计模式用于网络编程开发的,基本模板代码思路就是如下:

  • 首先创建ServerSocketChannel对象,和真正处理业务的线程池
  • 然后对上述ServerSocketChannel对象进行绑定一个对应的端口,并设置为非阻塞
  • 紧接着创建Selector对象并打开,然后把这Selector对象注册到ServerSocketChannel中,并设置好监听的事件,监听 SelectionKey.OP_ACCEPT
  • 接着就是Selector对象进行死循环监听每一个Channel通道的事件,循环执行 Selector.select()方法,轮询就绪的Channel
  • Selector中获取所有的SelectorKey(这个就可以看成是不同的事件),如果SelectorKey是处于 OP_ACCEPT状态,说明是新的客户端接入,调用 ServerSocketChannel.accept接收新的客户端。
  • 然后对这个把这个接受的新客户端的Channel通道注册到ServerSocketChannel上,并且把之前的OP_ACCEPT状态改为SelectionKey.OP_READ读取事件状态,并且设置为非阻塞的,然后把当前的这个SelectorKey给移除掉,说明这个事件完成了
  • 如果第5步的时候过来的事件不是OP_ACCEPT状态,那就是OP_READ读取数据的事件状态,然后调用本文章的上面的那个读取数据的机制就可以了。

当然Netty的网络编程风格上要优化了许多,它的工作流程步骤:

  • 创建 NIO 线程组 EventLoopGroupServerBootstrap
  • 设置 ServerBootstrap的属性:线程组、SO_BACKLOG 选项,设置 NioServerSocketChannelChannel,设置业务处理 Handler
  • 绑定端口,启动服务器程序。
  • 在业务处理 Handler处理器 中,读取客户端发送的数据,并给出响应。

参考列表


1.不二程序.Java NIO三剑客详细剖析
2.玉刚说.面试官:什么是NIO?NIO的原理是什么?
3.占小狼的博客.深入浅出NIO之Selector实现原理
4.TheLudlows.NIO之终极Selctor源码分析

![关注公众号获取更多学习资料](https://upload-images.jianshu.io/upload_images/8926909-acbea8e304b57e43.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/124

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

推荐阅读更多精彩内容