文章目录
初识NIO
什么是NIO
NIO 库是在JDK 1.4 中引入的,为弥补了原先 I/O 的不足,它在标准Java代码中提供了高速的、面向块的 I/O。NIO可以翻译成 no-blocking io或者new io
NIO和BIO的主要区别
面向流与面向缓冲
JDK提供的NIO和BIO之间最大的一个区别是,BIO是面向流的,而NIO则是面向缓冲区的。BIO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。除此之外,它也不能前后移动流中的数据。如果需要前后移动从流中读取的数据,则需要先将它缓存到一个缓冲区。而NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据
阻塞与非阻塞
BIO属于同步阻塞模型,意味着,当一个线程调用read()或者write()方法时,该线程被阻塞,直到有一些数据被读取,或数据完全写入,否则该线程在此期间不能再干任何事情了
NIO属于非阻塞模型,一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而且不会让线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)
Selector选择器机制
NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道
NIO核心组件
Selector
Selector的英文含义是“选择器”,也可以称为为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行
应用程序将向Selector选择器注册需要它关注的Channel,以及对应的Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器
关于SelectionKey
SelectionKey是一个抽象类,表示selectableChannel在Selector中注册的标识.每个Channel 向 Selector 注册时,都将会创建一个 SelectionKey。SelectionKey将Channel与Selector 建立了关系,并维护了channel事件。可以通过cancel方法取消键,取消的键不会立即从selector 中移除,而是添加到cancelledKeys中,在下一次select操作时移除它,所以在调用某个 key时,需要使用isValid进行校验
SelectionKey类型
在向Selector对象注册感兴趣的事件时,NIO共定义了四种:OP_READ、OP_WRITE、 OP_CONNECT、OP_ACCEPT(定义在 SelectionKey类中),分别对应读、写、请求连接、接受连接等网络Socket操作
操作类型 | 就绪条件及说明 |
---|---|
OP_READ | 当操作系统读缓冲区有数据可读时就绪。并非时刻都有数据可读,所 以一般需要注册该操作,仅当有就绪时才发起读操作,有的放矢,避免浪 费 CPU |
OP_WRITE | 当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空 闲空间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不 断就绪浪费 CPU;但如果是写密集型的任务,比如文件下载等,缓冲区很 可能满,注册该操作类型就很有必要,同时注意写完后取消注册 |
OP_CONNECT | 当 SocketChannel.connect()请求连接成功后就绪。该操作只给客户端 使用 |
OP_ACCEPT | 当接收到一个客户端连接请求时就绪。该操作只给服务器使用 |
服务端和客户端分别感兴趣的类型
ServerSocketChannel 和 SocketChannel 可以注册自己感兴趣的操作类型,当对应操作类 型的就绪条件满足时 OS 会通知 channel,下表描述各种 Channel 允许注册的操作类型,Y 表 示允许注册,N 表示不允许注册,其中服务器SocketChannel 指由服务器 ServerSocketChannel.accept()返回的对象
OP_READ | OP_WRITE | OP_CONNECT | OP_ACCEPT | |
---|---|---|---|---|
服务器 ServerSocketChannel | Y | |||
服务器 SocketChannel | Y | Y | ||
客户端 SocketChanne | Y | Y | Y |
服务器启动 ServerSocketChannel,关注 OP_ACCEPT 事件
客户端启动 SocketChannel,连接服务器,关注 OP_CONNECT 事件
服务器接受连接,启动一个服务器的 SocketChannel,这个 SocketChannel 可以关注 OP_READ、OP_WRITE 事件,一般连接建立后会直接关注 OP_READ 事件
客户端这边的客户端 SocketChannel 发现连接建立后,可以关注 OP_READ、OP_WRITE 事件,一般是需要客户端需要发送数据了才关注 OP_READ 事件
连接建立后客户端与服务器端开始相互发送消息(读写),根据实际情况来关注OP_READ、 OP_WRITE 事件
Channels
Channels称为通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操 作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数 据,也可以通过通道向操作系统写数据,而且可以同时进行读写
- 所有被 Selector(选择器)注册的通道,只能是继承了 SelectableChannel 类的子类
- ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才 能向操作系统注册支持“多路复用 IO”的端口监听。同时支持 UDP 协议和 TCP 协议
- ScoketChannel:TCP Socket 套接字的监听通道,一个 Socket 套接字对应了一个客户 端 IP:端口 到 服务器 IP:端口的通信连接
通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入
buffer缓冲区
Buffer 用于和 NIO 通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。 以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样, 数据总是先从通道读到缓冲,应用程序再读缓冲的数据
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存( 其实就是数组)。 这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存
重要属性
capacity
作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”.你只能往里写 capacity 个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据) 才能继续写数据往里写数据
position
当你写数据到 Buffer 中时,position 表示当前能写的位置。初始的 position 值为 0.当一 个 byte、long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单 元。position 最大可为 capacity – 1
当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position 会被重置为 0. 当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置
limit
在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等 于 Buffer 的 capacity。当切换 Buffer 到读模式时, limit 表示你最多能读到多少数据。因此,当切换 Buffer 到 读模式时,limit 会被设置成写模式下的 position 值。换句话说,你能读到之前写入的所有数 据(limit 被设置成已写数据的数量,这个值在写模式下就是 position)
Buffer的分配
堆内存分配
要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有allocate方法,分配 48 字节 capacity 的 ByteBuffer 的例子:
ByteBuffer buf = ByteBuffer.allocate(48);
直接内存分配
HeapByteBuffer 与 DirectByteBuffer,在原理上,前者可以看出分配的 buffer 是在 heap 区域的,其实真正 flush 到远程的时候会先拷贝到直接内存,再做下一步操作;在 NIO 的框 架下,很多框架会采用 DirectByteBuffer 来操作,这样分配的内存不再是在 java heap 上,经 过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比 HeapByteBuffer 要快速好几倍
直接内存与堆内存比较
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
Buffer的读写
向Buffer中写数据
读取Channel写到Buffer
//读取请求码流,返回读取到的字节数
int readBytes = sc.read(buffer);
通过Buffer的put()方法写到Buffer
//将字节数组复制到缓冲区
writeBuffer.put(bytes);
flip()方法
flip()方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
从Buffer中读取数据
从Buffer读取数据写入到Channel
//发送缓冲区的字节数组
channel.write(writeBuffer);
使用get()方法从Buffer中读取数据
byte aByte = buffer.get()
使用Buffer读写数据常见步骤
- 写入数据到Buffer
- 调用flip()方法
- 从Buffer中读取数据
- 调用clear()方法或者compact()方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面
Buffer方法总结
limit(), limit(1 0)等 | 其中读取和设置这 4 个属性的方法的命名和 jQuery 中的 val(),val(10)类似,一个负 责 get,一个负责 set |
---|---|
reset() | 把 position 设置成 mark 的值,相当于之前做过一个标记,现在要退回到之前标记 的地方 |
clear() | position = 0;limit = capacity;mark = -1; 有点初始化的味道,但是并不影响底 层 byte 数组的内容 |
flip() | limit = position;position = 0;mark = -1; 翻转,也就是让 flip 之后的 position 到 limit 这块区域变成之前的 0 到 position 这块,翻转就是将一个处于存数据状态的缓 冲区变为一个处于准备取数据的状态 |
rewind() | 把 position 设为 0,mark 设为-1,不改变 limit 的值 |
remaining() | return limit - position;返回 limit 和 position 之间相对位置差 |
hasRemaining () | return position < limit 返回是否还有未读内容 |
compact() | 把从 position 到 limit 中的内容移到 0 到 limit-position 的区域内,position 和 li mit 的取值也分别变成 limit-position、capacity。如果先将 positon 设置到 limit,再 c ompact,那么相当于 clear() |
get() | 相对读,从 position 位置读取一个 byte,并将 position+1,为下次读写作准备 |
get(int index) | 绝对读,读取 byteBuffer 底层的 bytes 中下标为 index 的 byte,不改变 position |
get(byte[] dst, int offset, int len gth) | 从 position 位置开始相对读,读 length 个 byte,并写入 dst 下标从 offset 到 offs et+length 的区域 |
put(byte b) | 相对写,向 position 的位置写入一个 byte,并将 postion+1,为下次读写作准备 |
put(int index, byte b) | 绝对写,向 byteBuffer 底层的 bytes 中下标为 index 的位置插入 byte b,不改变 p osition |
put(ByteBuffer src) | 用相对写,把 src 中可读的部分(也就是 position 到 limit)写入此 byteBuffer |
put(byte[] src, int offset, int len gth) | 从 src 数组中的 offset 到 offset+length 区域读取数据并使用相对写写入此 byteBuffer |
wrap(byte[] array)、 wrap(byte [] array, int offset, int length) | 把一个 byte 数组或 byte 数组的一部分包装成 ByteBuffer |
NIO之Reactor模式
“反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有时间来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应
单线程 Reactor 模式流程
- 服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用 Selector(选 择器)来实现 IO 的多路复用。注册一个 Acceptor 事件处理器到 Reactor 中,Acceptor 事件处 理器所关注的事件是 ACCEPT 事件,这样 Reactor 会监听客户端向服务器端发起的连接请求 事件(ACCEPT 事件)
- 客户端向服务器端发起一个连接请求,Reactor 监听到了该 ACCEPT 事件的发生并将 该 ACCEPT 事件派发给相应的 Acceptor 处理器来进行处理。Acceptor 处理器通过 accept()方 法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的 READ 事件以及对 应的 READ 事件处理器注册到 Reactor 中,这样一来 Reactor 就会监听该连接的 READ 事件了
- 当 Reactor 监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行 处理。比如,读处理器会通过 SocketChannel 的 read()方法读取数据,此时 read()操作可以直 接读取到数据,而不会堵塞与等待可读的数据到来
- 每当处理完所有就绪的感兴趣的 I/O 事件后,Reactor 线程会再次执行 select()阻塞等 待新的事件就绪并将其分派给对应处理器进行处理
注意:Reactor 的单线程模式的单线程主要是针对于 I/O 操作而言,也就是所有的 I/O 的 accept()、read()、write()以及 connect()操作都在一个线程上完成的
但在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非 I/O 的业务 操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。所以我们应该将非 I/O 的业务逻辑操作从 Reactor 线程上卸载,以此来加速 Reactor 线程对 I/O 请求的响应
单线程Reactor,工作者线程池
与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor 线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因 为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理
使用线程池的优势
- 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和 销毁过程产生的巨大开销
- 另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待 创建线程而延迟任务的执行,从而提高了响应性
- 通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态。 同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败
改进的版本中,所有的 I/O 操作依旧由一个 Reactor 来完成,包括 I/O 的 accept()、read()、 write()以及 connect()操作
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量 的应用场景却不合适,主要原因如下:
- 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负 荷达到 100%,也无法满足海量消息的读取和发送
- 当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时 之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时, 成为系统的性能瓶颈
多Reactor线程模式
Reactor 线程池中的每一 Reactor