Java 原生网络编程-NIO

什么是 NIO?

  • NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO 翻译成 no-blocking io 或者 new io。

和 BIO 的主要区别

面向流与面向缓冲

  • Java NIO 和 IO 之间第一个最大的区别是,IO 是面向流的,NIO 是面向缓冲区的。 Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需要确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。

阻塞与非阻塞 IO

  • Java IO 的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
  • Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

NIO 三大核心组件

Selector

  • “选择器”,也称“轮询代理器”、“事件订阅器”、“channel 容器管理机”。
  • Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器(Selector),然后使用一个单独的线程来操作这个选择器,进而“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
  • 应用程序将向 Selector 对象注册要关注的 Channel,以及具体的某一个 Channel 会对哪些 IO 事件感兴趣。Selector 中也会维护一个“已经注册的 Channel”的容器。

Channel

  • 通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
  • 所有被 Selector(选择器)注册的通道,只能是继承了 SelectableChannel 类的子类。
  • ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用 IO”的端口监听。同时支持 UDP 协议和 TCP 协议。
  • ScoketChannel:TCP Socket 套接字的监听通道,一个 Socket 套接字对应了一个客户端到服务端的通信连接。
  • 通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入。

buffer 缓冲区

  • 一块可以读写数据的内存,后面具体介绍

在这里插入图片描述

重要概念 SelectionKey

什么是 SelectionKey

  • SelectionKey是一个抽象类,表示selectableChannel在Selector中注册的标识。每个Channel 向 Selector 注册时,都将会创建一个 SelectionKey。SelectionKey 将 Channel 与 Selector 建立了关系,并维护了 channel 事件。
  • 可以通过 cancel 方法取消键,取消的键不会立即从 selector 中移除,而是添加到 cancelledKeys 中,在下一次 select 操作时移除它,所以在调用某个 key 时,需要使用 isValid 进行校验。

SelectionKey 类型和就绪条件

  • 在向 Selector 对象注册感兴趣的事件时,JAVA 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 可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时,操作系统会通知 channel,下表描述各种 Channel 允许注册的操作类型,Y 表示允许注册,N 表示不允许注册,其中服务器 SocketChannel 指由服务器 ServerSocketChannel.accept()返回的对象。
项目 OP_READ OP_WRITE OP_CONNECT OP_ACCEPT
服务端 ServerSocketChannel Y
服务端 SocketChannel Y Y
客户端 SocketChannel 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 事件。

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); 
  • 分配一个可存储 1024 个字符的 CharBuffer
CharBuffer buf = CharBuffer.allocate(1024);
  • wrap 方法:把一个 byte 数组或 byte 数组的一部分包装成 ByteBuffer:
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)

直接内存

  • HeapByteBuffer 与 DirectByteBuffer,在原理上,前者可以看出分配的 buffer 是在 heap 区域的,其实真正 flush 到远程的时候会先拷贝到直接内存,再做下一步操作;在 NIO 的框架下,很多框架会采用 DirectByteBuffer 来操作,这样分配的内存不再是在 java heap 上,经过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比 HeapByteBuffer 要快速好几倍。
  • 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
  • NIO 可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能, 因为避免了在 Java 堆和 Native 堆中来回复制数据。

Buffer 的读写

向 Buffer 中写数据
1、读取 Channel 写到 Buffer。

int bytesRead = inChannel.read(buf); //read into buffer.

2、通过 Buffer 的 put()方法写到 Buffer 里。

buf.put(127);

flip()方法

  • flip 方法将 Buffer 从写模式切换到读模式。调用 flip()方法会将 position 设回 0,并将 limit 设置成之前 position 的值。
  • 换句话说,position 现在用于标记读的位置,limit 表示之前写进了多少个 byte、char 等,现在能读取多少个 byte、char 等。

从 Buffer 中读取数据
1、从 Buffer 读取数据写入到 Channel。

int bytesWritten = inChannel.write(buf);

2、使用 get()方法从 Buffer 中读取数据。

byte aByte = buf.get();

使用 Buffer 读写数据常见步骤

  1. 写入数据到 Buffer
  2. 调用 flip()方法
  3. 从 Buffer 中读取数据
  4. 调用 clear()方法或者 compact()方法,准备下一次的写入
  • 当向 buffer 写入数据时,buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 buffer 的所有数据。
  • 一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear()或 compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。

Reactor 模式

  • “反应”即“倒置”,“控制逆转”,具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对哪些事件感兴趣,有事件来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应;这种控制逆转又称为“好莱坞法则”(不要调用我, 让我来调用你)

单线程 Reactor 模式流

  1. 服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用 Selector(选择器)来实现 IO 的多路复用。注册一个 Acceptor 事件处理器到 Reactor 中,Acceptor 事件处理器所关注的事件是 ACCEPT 事件,这样 Reactor 会监听客户端向服务器端发起的连接请求事件(ACCEPT事件)。
  2. 客户端向服务器端发起一个连接请求,Reactor 监听到了该 ACCEPT 事件的发生并将该 ACCEPT 事件派发给相应的 Acceptor 处理器来进行处理。Acceptor 处理器通过 accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的 READ 事件以及对应的 READ 事件处理器注册到 Reactor 中,这样一来 Reactor 就会监听该连接的 READ 事件了。
  3. 当 Reactor 监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过 SocketChannel 的 read()方法读取数据,此时 read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
  4. 每当处理完所有就绪的感兴趣的 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()操作。
  • 对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
    1、 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负荷达到 100%,也无法满足海量消息的读取和发送;
    2、当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时, 成为系统的性能瓶颈;

在这里插入图片描述

多 Reactor 线程模式

  • Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。
  • mainReactor 可以只有一个,但 subReactor 一般会有多个。mainReactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给 subReactor,由 subReactor 来完成和客户端的通信。

1、注册一个 Acceptor 事件处理器到 mainReactor 中,Acceptor 事件处理器所关注的事件是 ACCEPT 事件,这样 mainReactor 会监听客户端向服务器端发起的连接请求事件(ACCEPT 事件)。启动 mainReactor 的事件循环。

2、客户端向服务器端发起一个连接请求,mainReactor 监听到了该 ACCEPT 事件并将该 ACCEPT 事件派发给 Acceptor 处理器来进行处理。Acceptor 处理器通过 accept()方法得到与这个客户端对应的连接(SocketChannel),然后将这个 SocketChannel 传递给 subReactor 线程池。

3、subReactor 线程池分配一个 subReactor 线程给这个 SocketChannel,将 SocketChannel 关注的 READ 事件以及对应的 READ 事件处理器注册到 subReactor 线程中。当然也可以注册 WRITE 事件以及 WRITE 事件处理器到 subReactor 线程中以完成 I/O 写操作。 Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的循环逻辑。

4、当有 I/O 事件就绪时,相关的 subReactor 就将事件派发给响应的处理器处理。注意, 这里 subReactor 线程只负责完成 I/O 的 read()操作,在读取到数据后将业务逻辑的处理放入到线程池中完成,若完成业务逻辑后需要返回数据给客户端,则相关的 I/O 的 write 操作还是会被提交回 subReactor 线程来完成。

  • 注意,所有的 I/O 操作(包括I/O 的 accept()、read()、write()以及 connect()操作)依旧还是在 Reactor 线程(mainReactor 线程 或 subReactor 线程)中完成的。Thread Pool(线程池)仅用来处理非 I/O 操作的逻辑。
  • 多 Reactor 线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个 Reactor 线程来完成。mainReactor 完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给 subReactor 线程来完成与客户端的通信,这样一来就不会因为 read() 数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多 Reactor 线程模式在海量的客户端并发请求的情况下,还可以通过实现 subReactor 线程池来将海量的连接分发给多个 subReactor 线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
  • Netty 服务端使用了“多 Reactor 线程模式”

在这里插入图片描述

和观察者模式的区别

观察者模式:

  • 也可以称为 发布-订阅 模式,主要适用于多个对象依赖某一个对象的状态,并当某对象状态发生改变时,要通知其他依赖对象做出更新。是一种一对多的关系。当然,如果依赖的对象只有一个时,也是一种特殊的一对一关系。通常,观察者模式适用于消息事件处理, 监听者监听到事件时通知事件处理者对事件进行处理。

Reactor 模式:

  • reactor 模式,即反应器模式,是一种高效的异步 IO 模式,特征是回调,当 IO 完成时, 回调对应的函数进行处理。这种模式并非是真正的异步,而是运用了异步的思想,当 IO 事件触发时,通知应用程序作出 IO 处理。模式本身并不调用系统的异步 IO 函数。
  • reactor 模式与观察者模式有点像。不过,观察者模式与单个事件源关联,而反应器模式则与多个事件源关联 。当一个主体发生改变时,所有依属体都得到通知。

猜你喜欢

转载自blog.csdn.net/qq_40977118/article/details/109385309