前言
前两篇文章介绍了IO
和BIO
, 关于相关文章的链接如下:
本篇文章我们就重点介绍NIO
,下面进入正文,首先做个概述。
概述
NIO 中的 N 可以理解为 Non-blocking,一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入,对应的在java.nio
包下。
NIO 新增了 Channel、Selector、Buffer 等抽象概念,支持面向缓冲、基于通道的 I/O 操作方法。
NIO 提供了与传统 BIO 模型中的 Socket
和 ServerSocket
相对应的 SocketChannel
和 ServerSocketChannel
两种不同的套接字通道实现。
NIO 这两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。
对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发效率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。
正文
我们先看一下 NIO 涉及到的核心关联类图,如下:
上图中有三个关键类:Channel 、Selector 和 Buffer,它们是 NIO 中的核心概念。
- Channel:可以理解为通道;
- Selector:可以理解为选择器;
- Buffer:可以理解为数据缓冲流;
NIO 引入了 Channel、Buffer 和 Selector 就是想把 IO 传输过程中涉及到的信息具体化,让程序员有机会去控制它们。
当我们进行传统的网络 IO 操作时,比如调用 write() 往 Socket 中的 SendQ 队列写数据时,当一次写的数据超过 SendQ 长度时,操作系统会按照 SendQ 的长度进行分割的,这个过程中需要将用户空间数据和内核地址空间进行切换,而这个切换不是程序员可以控制的,由底层操作系统来帮我们处理。
而在 Buffer 中,我们可以控制 Buffer 的 capacity(容量),并且是否扩容以及如何扩容都可以控制。
代码示例
实例图:
客户端程序示例:
1 | java复制代码package org.example.nio.example; |
服务端示例:
1 | java复制代码package org.example.nio.example; |
通道channel
NIO 的核心就是通道和缓存区,所以它们的工作模式是这样的:
通道有点类似 IO 中的流,但不同的是,同一个通道既允许读也允许写,而任意一个流要么是读流要么是写流。
但是你要明白一点,通道和流一样都是需要基于物理文件的,而每个流或者通道都通过文件指针操作文件,这里说的通道是双向
的也是有前提的,那就是通道基于随机访问文件RandomAccessFile
的可读可写文件指针。
基本的通道类型有如下一些:
FileChannel
是基于文件的通道;
SocketChannel
和 ServerSocketChannel
用于网络 TCP 套接字数据报读写;
DatagramChannel
是用于网络 UDP 套接字数据报读写。
通道不能单独存在,它永远需要绑定一个缓存区,所有的数据只会存在于缓存区中,无论你是写或是读,必然是缓存区通过通道到达磁盘文件,或是磁盘文件通过通道到达缓存区。即缓存区是数据的起点
,也是终点
。
缓存区Buffer
缓冲区(Buffer):一个用于特定基本数据类型的容器。由
java.nio
包定义的,所有缓冲区都是Buffer抽象类的子类。
Java NIO 中的 Buffer 主要用于和 NIO 通道进行交互,数据是从通道读入到缓冲区的,然后从缓冲区中写入到通道中的。
Buffer 就像一个数组,可以保存多个相同类型的数据。根据数据类型的不同(boolean)除外,有以下 Buffer 常用子类:ByteBuffer
、CharBuffer
、ShortBuffer
、IntBuffer
、LongBuffer
、FloatBuffer
、DoubleBuffer
。上述 Buffer 类他们都是通过相似的方法进行管理数据的,只是各自管理的数据类型不同而已。都是通过如下的方法获取一个 Buffer 对象:
1 | arduino复制代码public static XxxBuffer allocate(int capacity) {} |
缓冲区的基本属性
容量(capacity)
:表示 Buffer 最大数据容量,缓冲区容量不能为负,并且一旦创建不能更改。
限制(limit)
:第一个不应该读取或写入的数据的索引,即位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于容量。
位置(position)
:下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制。
标记(mark)和重置(reset)
:标记是一个索引,通过Buffer
中的mark()
方法指定Buffer
中的一个特定的position
,之后可以通过调用reset()
方法恢复到这个position
。
简而言之:0 <= mark <= position <= limit <= capacity。
Selector(选择器)
Selector 被称为选择器 ,当然你也可以翻译为多路复用器 。它是Java NIO 核心组件中的一个,用于检查一个或多个 Channel(通道)的状态是否处于连接就绪、接受就绪、可读就绪、可写就绪。
如此可以实现单线程管理多个 channels,也就是可以管理多个网络连接。
使用 Selector 的好处在于: 相比传统方式使用多个线程来管理 IO,Selector 使用了更少的线程就可以处理通道了,并且实现网络高效传输!
创建一个选择器一般是通过 Selector 的工厂方法,Selector.open
:
1 | java复制代码Selector selector = Selector.open(); |
而一个通道想要注册到某个选择器中,必须调整模式为非阻塞模式,例如:
1 | java复制代码//创建一个 TCP 套接字通道 |
以上代码是注册一个通道到选择器中的最简单版本,支持注册选择器的通道都有一个 register
方法,该方法就是用于注册当前实例通道到指定选择器的。
该方法的第一个参数就是目标选择器,第二个参数其实是一个二进制掩码,它指明当前选择器感兴趣当前通道的哪些事件。以枚举类型提供了以下几种取值:
- int OP_READ = 1 << 0;
- int OP_WRITE = 1 << 2;
- int OP_CONNECT = 1 << 3;
- int OP_ACCEPT = 1 << 4;
这种用二进制掩码来表示某些状态的机制,我们在讲述虚拟机类类文件结构的时候也遇到过,它就是用一个二进制位来描述一种状态。
register
方法会返回一个 SelectionKey
实例,该实例代表的就是选择器与通道的一个关联关系。你可以调用它的 selector
方法返回当前相关联的选择器实例,也可以调用它的 channel
方法返回当前关联关系中的通道实例。
除此之外,SelectionKey
的 readyOps
方法将返回当前选择感兴趣当前通道中事件中准备就绪的事件集合,依然返回的一个整型数值,也就是一个二进制掩码。
例如:
1 | java复制代码int readySet = selectionKey.readyOps(); |
假如 readySet 的值为 13,二进制 「0000 1101」,从后向前数,第一位为 1,第三位为 1,第四位为 1,那么说明选择器关联的通道,读就绪、写就绪,连接就绪。
所以,当我们注册一个通道到选择器之后,就可以通过返回的 SelectionKey
实例监听该通道的各种事件。
当然,一旦某个选择器中注册了多个通道,我们不可能一个一个的记录它们注册时返回的 SelectionKey
实例来监听通道事件,选择器应当有方法返回所有注册成功的通道相关的 SelectionKey
实例。
1 | java复制代码Set<SelectionKey> keys = selector.selectedKeys(); |
selectedKeys
方法会返回选择器中注册成功的所有通道的 SelectionKey
实例集合。我们通过这个集合的 SelectionKey
实例,可以得到所有通道的事件就绪情况并进行相应的处理操作。
总结
优点
- 1个线程就行就能处理所有连接,这个线程不停循环遍历就行了。
缺点
- 单线程不停循环发起系统调用,一样会耗尽 CPU 资源。
NIO 的瓶颈
- 在于需要不停的调起系统调用,每个链接我们都要调系统调用询问是否有过来数据,我们要是明确的知道哪个连接有数据包过来呢,就不用挨个遍历寻找找答案了。
结尾
我是一个正在被打击还在努力前进的码农。如果文章对你有帮助,记得点赞、关注哟,谢谢!
本文转载自: 掘金