BIO

阻塞点1:接收(客户端)连接阻塞。服务器启动后,进入阻塞,等待客户端主动发起连接。(accept()方法阻塞)
解决阻塞点1,可使用多线程模式解决。同时处理的连接数,取决于线程数。
客户端与服务端建立链接后,会进入阻塞点2场景
阻塞点2:接收(客户端)数据阻塞。连接建立后,进入阻塞,等待接收客户端数据。
解决阻塞点2,就需要使用NIO了。

NIO

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

阻塞与非阻塞 IO

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

NIO三大核心组件

Selector 选择器、Channel 管道、buffer 缓冲区

Selector

Selector 可以称为为“轮询代理器”、“事件订阅器”、“channel 容器管理机”。
Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以向 一个选择器(Selectors)注册多个通道,然后使用一个单独的线程来操作这个选择器,进而“选择”通道。
这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个 单独的线程很容易来管理多个通道。
应用程序将向 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 对象注册感兴趣的事件时。
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 可以注册自己感兴趣的操作类型,当对应操作类 型的就绪条件满足时 OS 会通知 channel,下表描述各种 Channel 允许注册的操作类型,Y 表 示允许注册,N 表示不允许注册。
其中SocketChannel 指由ServerSocketChannel.accept()返回的对象。
ServerSocketChannel:负责处理客户端连接事件;
ScoketChannel:负责已经和服务端建立连接的客户端,进行读写操作。
Selector 监听到连接事件后,交给ServerSocketChannel处理;监听到读写事件后,交给SocketChannel处理

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 事件。

    Channels

    通道:一个应用程序操作系统建立的交互事件、传递内容的渠道(注意是连接到操作系统)。那么既然是和操作系统进行内容传递的通道,则应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。

  • 所有被 Selector(选择器)注册的通道,只能是继承了 SelectableChannel 类的子类。

  • ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用 IO”的端口监听。同时支持 UDP 协议和 TCP 协议。
  • ScoketChannel:TCP Socket 套接字的监听通道,一个 Socket 套接字对应了一个客户端 IP:端口 到 服务器 IP:端口的通信连接。

通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入。

buffer 缓冲区

JDK NIO 是面向缓冲的。Buffer 就是这个缓冲,用于和 NIO 通道进行交互。
数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入 缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用 程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。 这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。

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

直接内存(堆外内存)与堆内存比较

直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显