作者:三哥,j3code.cn
环境:JDK1.8
视频讲解:www.bilibili.com/video/BV1mj...
1、Java I/O 模型
Java 中一共存在三种 I/O 模型,分别是阻塞 I/O(BI/O) 、非阻塞 I/O(NI/O) 和异步 I/O(AI/O) 。
对阻塞的解读
操作系统将内存分为用户空间 (用户态)和内核空间(内核态),用于隔离用户进程和内核进程的内存访问权限,保障系统的稳定性和安全性。而我们运行的程序都是在用户空间,他无法访问只有内核空间才能执行的操作,如文件管理、进程通信、内存管理等。所以,当我们需要进行 I/O 操作时就必须依赖内核空间的能力,也就是我们常说的从用户态转为内核态。
因为我们不是讲解操作系统的知识,所以用户态转为内核态不做细致的分析,我们只要知道这其中存在两个阶段就行,即:调用阶段 和执行阶段。
就如 I/O 操作,应用程序进程向内核发起系统调用就是调用阶段,内核执行 I/O 操作并返回结果是执行阶段。其中内核执行 I/O 操作需要等待 I/O 设备准备好数据,及将数据从内核空间拷贝到用户空间。

最后再来 理解阻塞 ,就是应用程序向内核发出调用请求后,内核执行请求的这个阶段,应用程序的状态通常会被设置为阻塞状态,直到内核操作完成并返回结果给应用程序。
下面简单介绍这三种 I/O 模型。
1.1 阻塞 I/O(BIO)
应用程序中进程在发起 I/O 调用后至内核执行 I/O 操作返回结果之前,若发起系统调用的线程一直处于等待(阻塞)状态,则此次 I/O 操作为阻塞 I/O 。
阻塞 I/O 简称 BI/O(Blocking I/O)。

如上图,阻塞 I/O 模型中,当用户线程发出 I/O 调用后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而此时用户线程也就会处于阻塞状态,用户线程交出 CPU 执行权限。当数据就绪之后,内核会将数据拷贝到用户空间,并返回结果给用户线程,用户线程才会解除阻塞状态。
优点:
- 程序简单,在阻塞等待数据期间,用户线程挂起。用户线程基本不会占用 CPU 资源。
缺点:
- 一般情况下,一个线程维护一个连接成功的 IO 流的读写。在并发量小时是没有问题的,但在并发很大时,BIO 是非常消耗系统资源的,这是行不通的。
1.2 非阻塞 I/O(NIO)
应用程序中进程在发起 I/O 调用后至内核执行 I/O 操作返回结果之前,若发起系统调用的线程不会等待而是立即返回,则此次 I/O 操作为非阻塞 I/O 模型。
非阻塞 I/O 简称 NIO(Non-Blocking IO)。

如上图,非阻塞 I/O 模型中,当用户线程发起一个 read 操作后,并不需要等待,而是马上得到了一个结果。如果结果是一个调用失败的信息时,此时代表数据还没有准备好,他就可以再次发送 read 操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么他就马上将数据拷贝给了用户线程,然后返回。
在非阻塞 I/O 模型中,用户线程需要不断地询问内核数据是否就绪 ,也就是说非阻塞 I/O 模型不会交出 CPU ,而会一直占用 CPU。
即应用程序的线程需要不断的进行 I/O 系统调用,轮询数据是否已经准备好,如果没有准备好,直到完成系统调用为止。
优点:
- 每次发起的 IO 系统调用,在内核等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
缺点:
- 需要不断的重复发起 IO 系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。
在 Java 中,NIO 并不是直接使用这种方式进行 IO 操作,而是进行了优化。其使用了一个 Selector(选择器)达到一个线程管理多个客户端连接的效果,这种模型被称为 I/O 多路复用模型。
在 I/O 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。但因为 read 之前会有 select 调用,而只有 select 判断数据准备好之后才会执行 read 方法,所以看上去 read 方法没有阻塞。

IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
- select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用 :linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
1.3 异步 I/O(AIO)
应用程序中在发起 I/O 调用后,用户进程立即返回,内核等待数据准备完成,然后将数据拷贝到用户进程缓冲区,最后发送信号告诉用户进程 I/O 操作执行完毕,则此次操作为异步 I/O。
异步 I/O 简称 AIO(Asynchronous I/O)。

异步 I/O 真正实现了 I/O 全流程的非阻塞。用户线程完全不需要关心实际的整个 I/O 操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示 I/O 操作已经完成,可以直接去使用数据了。
优点:
- 用户线程真正的不阻塞,通过回调处理结果。
缺点:
- 需要底层内核提供支持。
理论上来说,异步 I/O 是真正的异步输入输出,它的吞吐量高于 IO 多路复用模型的吞吐量。但是这是需要条件的,即需要操作系统进行支持。在 Windows 系统中提供了一种异步 IO 技术:IOCP(I/O Completion Port),所以 Windows 下的异步 I/O 则是依赖于这种机制实现。不过在 Linux 系统中由于这种异步 I/O 技术引入较晚(2.6才有),还不太成熟,所以异步 I/O 在 Linux 环境中使用的还是 epoll 这种多路复用技术进行模拟实现的。
而因为 Linux 的异步 I/O 技术还不太成熟,所以异步 I/O 的实际应用并不是太多,比如大名鼎鼎的网络通信框架 Netty 就没有采用异步 I/O,而是使用 NIO,只不过是在代码层面,自行实现了异步而已。
2、NIO 概述
什么是 NIO,百度解释:

大致意思就是,NIO 相比普通的 I/O 提供了功能更加强大、处理数据更快的解决方案,它可以大大提高 I/O 吞吐量,常用在高性能服务器上。
NIO 是非阻塞 面向缓冲区的。
NIO 的相关代码都放在 java.nio 包及其子包下,并且对原 java.io 包中的很多类进行了改写。NIO 中有三个非常核心的组件:
- Buffer(缓冲区):缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer APl 更加容易操作和管理。
- Channel(通道):Java NIO 的通道类似流,但又有些不同,它既可以从通道中读取数据,又可以写数据到通道,是双向的 。但流的(input 或 output)读写通常是单向的。通道可以非阻塞读取和写入通道,通道可以支持读取或写入缓冲区,也支持异步地读写。
- Selector(选择器):Selector 是一个 Java NIO 组件,它能够检查一个或多个 NIO 通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个 channel,从而管理多个网络连接,提高效率。
这三个组件交互关系图如下:

结合上图我们可知:
- 每个 Channel 都会对应一个 Buffer。
- Selector 对应一个线程,一个 Selector 对应多个 Channel(连接)程序。
- 切换到哪个 Channel 是由事件决定的。
- Selector 会根据不同的事件,在各个通道上切换。
- Buffer 就是一个内存块,底层是一个数组。
- 数据的读取写入是通过 Buffer 完成的,BlO 中要么是输入流要么是输出流,不能双向,但是 NIO 的Buffer 是可以读也可以写。
Java NIO 系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到 lO 设备(例如:文件、套接字)的连接。若需要使用 NIO 系统,需要获取用于连接 IO 设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理。简而言之,Channel 负责传输,Buffer 负责存取数据。
3、NIO 之"Hello Word"
我们通过 NIO 来编写一个简单的 Socket 程序,代码如下:
1)服务的
scss
public class HelloWorldServer {
public static void main(String[] args) throws Exception {
// 打开一个服务器通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel
.socket()
.bind(new java.net.InetSocketAddress(8080));
// 设置非阻塞模式
serverSocketChannel.configureBlocking(Boolean.FALSE);
// 打开一个选择器
Selector selector = Selector.open();
// 注册通道到选择器,并设置监听事件为接受事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// while 循环,不停的处理事件
while (true) {
System.out.println("等待事件...");
// 【阻塞】轮询监听所有注册到 selector 的事件(Channel 上的事件)
selector.select();
System.out.println("事件发生了...");
// 获取发生事件的集合(可能存在多个事件,所以是一个集合)
java.util.Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
// 遍历感兴趣的事件
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
// 处理事件
if (selectionKey.isAcceptable()) {
// 接受连接事件(客户端过来连接服务器了)
// 获取 ServerSocketChannel(服务端通道) 执行接收连接方法,获取 SocketChannel(客户端通道) 并设置非阻塞
ServerSocketChannel server = (ServerSocketChannel) selectionKey.channel();
// accept 方法本质上是阻塞的,但因为执行到这里,表明了肯定有客户端过来连接,所以可以继续执行。给人感觉就是没有阻塞一样。
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(Boolean.FALSE);
// 注册事件
// 给客户端通道监听读事件,(客户端写,服务端角度就是读)
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
System.out.println("客户端连接完成!");
} else if (selectionKey.isReadable()) {
// 读取事件
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读数据,非阻塞的体现
int read = channel.read(buffer);
System.out.println("客户端发来数据:" + new String(buffer.array(), 0, read));
// 服务的已经读到数据了,那么服务的写一个响应给到客户端
channel.write(ByteBuffer.wrap("Server Hello Word!".getBytes()));
// 继续监听通道的读/写事件
selectionKey.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
// 移除已经处理的事件, 防止重复处理
iterator.remove();
}
}
}
}
2)客户端
scss
public class HelloWorldClient {
public static void main(String[] args) throws IOException {
// 客户端网络通道
SocketChannel channel = SocketChannel.open();
// 设置非阻塞方式
channel.configureBlocking(false);
// 提供服务器端的IP地址和端口号
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8080);
// 连接服务器端
channel.connect(address);
// 注册连接事件
Selector selector = Selector.open();
channel.register(selector, SelectionKey.OP_CONNECT);
while (true) {
// 【阻塞】等待事件发生
selector.select();
// 获取发生的事件集合,并遍历
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isConnectable()) {
// 连接事件
SocketChannel selectableChannel = (SocketChannel) key.channel();
// 如果是正在连接,则设置连接完成
if (selectableChannel.isConnectionPending()) {
// 连接成功
selectableChannel.finishConnect();
System.out.println("连接服务器成功!");
}
// 设置非阻塞方式
selectableChannel.configureBlocking(Boolean.FALSE);
// 给服务器发送数据
selectableChannel.write(ByteBuffer.wrap("Hello Word!".getBytes()));
// 注册读事件
selectableChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int read = socketChannel.read(buffer);
System.out.println("服务的返回数据:" + new String(buffer.array(), 0, read));
}
// 移除事件,防止重复处理
iterator.remove();
}
}
}
}
上述案例代码,先是编写了一个端口为 8080 的服务端程序,然后向 Selector 中注册了接受连接事件,接着开始循环遍历 Selector 中发生的事件,根据不同的事件做出对应的处理。客户端则是通过 IP 和端口连接到对应的服务端,并且向服务端发送连接事件让两者关联上,接着向服务端发送消息和接受服务端发来的消息。
仔细看代码注释,对于不懂的类,将会在下面一一说明。
通常我们不会直接使用 NIO 进行网络编程,因为使用原生的 NIO 类进行编程难度较高,灵活性差。通常都是借助 Netty 这种网络框架来进行编程(底层封装了 NIO)。
下面我们来详细分析 NIO 的三大组件。
4、Buffer 缓冲区
4.1 是啥
前面我们说了,Buffer 其实是内存中开辟的一块数组空间,用于存放数据,并且对这块内存空间提供了一些基本操作。
传统 IO 使用字节数组操作数据,而 NIO 则是使用 Buffer 缓冲区,操作更灵活。
类继承关系图:

由图我们可知,Buffer 是一个抽象类,并且他有 7 个直接子类,且子类也是抽象类。这表明缓存区可以存储这 7 种不同类型的数据(传统 IO 只能是 byte 或者 char),但又应为它也是个抽象类,我们无法直接使用,而是使用具体类型的子类 HeapXXXBuffer 或 DirectXXXBuffer。
以 byte 类型为例,我们来看看 HeapByteBuffer 和 DirectByteBuffer 的类继承关系图,如下:

从图中我们看到 ByteBuffer 下面分出了两个分支:
- HeapByteBuffer
- MappedByteBuffer
而因为 MappedByteBuffer 是一个抽象类无法直接使用,所以其下面又实现了 DirectByteBuffer 类,那为何要这样分。
其实要明白这点,就需要引入两个关键词:
- 堆内缓冲区:HeapByteBuffer 就表示数据存在堆内的字节缓冲区
- 堆外缓冲区:DirectByteBuffer 就表述数据存在堆外的字节缓冲区
我们都知道一个 Java 程序就是一个运行在操作系统上的进程,而这个进程会存在程序计数器、栈、堆、内存资源等几个部分。而其中 堆内缓冲区 就是指 Java 进程中的 堆 、堆外缓冲区 就是指 Java 进程中的 内存资源 所在的空间。
堆内缓冲区是指在 Java 堆内存中分配的缓冲区,用于存储对象实例和数据。由于堆内缓冲区位于 Java 堆内存中,因此可以通过 Java 的垃圾回收器来自动管理内存。
堆外缓冲区 是指在 Java 虚拟机的堆外内存中分配的缓冲区,也称为堆外内存中的直接缓冲区。堆外缓冲区使用 Native 代码手动分配和释放内存,并且不依赖于 Java 的垃圾回收器来管理内存。堆外缓冲区使用 Unsafe 类或者直接操作操作系统的内存 API 来进行读取和写入数据。相对于堆内缓冲区,堆外缓冲区的访问速度更快,但是需要手动管理内存,容易出现内存泄漏和野指针等问题。
总的来说就是堆内缓冲区的内存是由 Java 垃圾回收器管理;而堆外缓冲区由于是通过 Native 这种非 Java 代码分配的内存,所以需要自己手动来管理内存。那如果你对内存的管理要求较高,可以使用堆内缓冲区;如果对访问速度要求较高,可以使用堆外缓冲区。
4.2 怎么用
因为 Buffer 有 7 中不同类型的实现,我不会全部都介绍,所以我会以使用次数较多的 ByteBuffer 来进行介绍,后续大家就不要再问为啥通篇都是和 byte 相关的缓冲区了。
前面我们说了 ByteBuffer 是抽象类,那么我们如何创对应的缓冲区呢!
难道是直接使用其对应的子类吗?像这样:
ini
HeapByteBuffer heapByteBuffer = new HeapByteBuffer();
DirectByteBuffer directByteBuffer = new DirectByteBuffer();
很显然,这是不对的,大家可以在 IDEA 中试一下就知道了,会报错。原因就是这两个类的访问修饰符都是默认的,那默认的访问修饰符的权限范围大家是否还记得?
只允许同包下进行访问
所以,我们到底如何创建呢!
大家看如下图:

ByteBuffer 为我们提供了四个静态的公共方法用来创建对象,其中对应的区别如下:
- allocate 创建指定大小的堆内缓冲区
- allocateDirect 创建指定大小的堆外缓冲区
- wrap 创建指定数组数据的堆内缓冲区
四大属性
上面我们知道如何创建缓冲区了,那接下来就有必要分析一下缓冲区中四个非常关键的属性了。
arduino
public abstract class Buffer {
// ...
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
// ...
}
在 Buffer 抽象类中定义了这四个重要的属性,缓冲区中的读写操作都需要依赖这四个属性。
1)capacity
这个好理解,表示的就是缓冲区的大小,即缓冲区可以存放元素的数量。capacity 是不能为负数的且定义之后不可更改。
案例:
typescript
public class BufferDemo {
public static void main(String[] args) {
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
}
}
以堆内缓冲区为例,创建了一个容量为 1024 大小的缓冲区,其底层的实现如下:
arduino
// java.nio.ByteBuffer#allocate
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
// 调用 HeapByteBuffer 构造器,传入容量参数
return new HeapByteBuffer(capacity, capacity);
}
// java.nio.HeapByteBuffer#HeapByteBuffer(int, int)
HeapByteBuffer(int cap, int lim) {
// 调用父类构造器,cap 和 lim 都等于传入的容量,即 1024
super(-1, 0, lim, cap, new byte[cap], 0);
}
// java.nio.ByteBuffer#ByteBuffer(int, int, int, int, byte[], int)
ByteBuffer(int mark, int pos, int lim, int cap, // package-private
byte[] hb, int offset)
{
// 调用 ByteBuffer 的父类构造器
super(mark, pos, lim, cap);
this.hb = hb;
this.offset = offset;
}
// java.nio.Buffer#Buffer
Buffer(int mark, int pos, int lim, int cap) { // package-private
// 判断容量是否小于零
if (cap < 0)
throw new IllegalArgumentException("Negative capacity: " + cap);
// 将传入的容量值赋给 capacity 属性
this.capacity = cap;
// 校验 limit 并赋值
limit(lim);
// 校验 position 并赋值
position(pos);
if (mark >= 0) {
if (mark > pos)
throw new IllegalArgumentException("mark > position: ("
+ mark + " > " + pos + ")");
this.mark = mark;
}
}
// java.nio.Buffer#limit(int)
public final Buffer limit(int newLimit) {
// 校验 newLimit, newLimit 需要比 容量 小 且 大于等于 0 ,否则报错
if ((newLimit > capacity) || (newLimit < 0))
throw new IllegalArgumentException();
// 给 limit 赋值
limit = newLimit;
// 如果 position 比 limit 大,那么让 position 等于 limit
if (position > limit) position = limit;
// 如果 mark 比 limit 大,那么让 mark 等于 -1
if (mark > limit) mark = -1;
return this;
}
// java.nio.Buffer#position(int)
public final Buffer position(int newPosition) {
// 校验 newPosition,newPosition 要比 limit 小 且 newPosition 大于等于 0,否则报错
if ((newPosition > limit) || (newPosition < 0))
throw new IllegalArgumentException();
// 赋值
position = newPosition;
// 如果 mark 大于 position ,那么让 mark 等于 -1
if (mark > position) mark = -1;
return this;
}
可以看到,当传入容量为 1024 大小的时候,缓冲区在经过一系列校验之后会将值赋给 capacity 属性,并且还会相应的初始化其余三个值。
最终的效果图,如下:

2)limit
限制缓冲区某一个位置不可读或写。说白一点就是写入数据或读取数据时,到达 limit 所指引的下标处就会停止后续的读与写操作。
根据上面的构造器源码可知,limit 不能大于 capacity ,不能为负数,并且 position 如果大于 limit 那么则会将 position 设为新的 limit。
下面看张图了解 limit 作用:

操作 limit 以后的数据会报 java.lang.IndexOutOfBoundsException(下标越界异常),所以正常情况下我们只能操作 position 与 limit 之间的数据。
3)position
这个属性表示下一个要读取的位置。同样 position 不能为负数且不能大于 limit,如果 mark 已经定义且大于 position 那么会丢弃 mark。
从上面几个属性图我们可以看出,position 指向的位置就是我们调用 get() 方法所获取的数据并且 position 值会累加。
注意:只有调用 get 无参的方法 position 才会累加,而其余方法 position 则不会变动。
4)mark
对 position 的某一位置做标记,当执行 reset 方法时,不论 position 指向何处,都会变原先mark标记的位置。
由此可知,position 和 limit 要大于等于 mark 反之 mark 会无效且不可为负数。
下面看张图了解 mark 作用:

mark 标记可以让程序重复的读取某一段数据,只需调用 mark() 方法先标记,然后到达某个地方调用 reset 方法即可。
讲解完了 Buffer 中的四个重要属性之后,下面就来看看 Buffer 为我们提供的一些重要方法吧!再看方法之前记得把这四个属性搞懂才方便理解后续的方法作用。
常用方法
1)remaining
作用:剩余空间大小,就是返回 position 与 limit 之间的元素个数。
源码:
arduino
public final int remaining() {
return limit - position;
}
案例:
c
public void remainingTests() {
ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
log.info("当前容量:{}", byteBuffer.capacity());
log.info("当前位置:{}", byteBuffer.position());
log.info("可操作空间:{}", byteBuffer.remaining());
log.info("读取两个数据");
log.info("{},{}", byteBuffer.get(), byteBuffer.get());
log.info("剩余可操作空间:{}", byteBuffer.remaining());
}
结果:
arduino
14:50:49.142 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前容量:8
14:50:49.168 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前位置:0
14:50:49.168 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 可操作空间:8
14:50:49.168 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取两个数据
14:50:49.168 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 1,2
14:50:49.168 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 剩余可操作空间:6
2)hasRemaining
作用:判断当前位置与限制位置是否有剩余元素。
源码:
arduino
public final boolean hasRemaining() {
return position < limit;
}
案例:
csharp
public void hasRemainingTests() {
ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
log.info("是否可操作:{}", byteBuffer.hasRemaining());
log.info("设置position = limit");
byteBuffer.position(byteBuffer.limit());
log.info("是否可操作:{}", byteBuffer.hasRemaining());
}
结果:
arduino
15:06:54.111 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 是否可操作:true
15:06:54.137 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 设置position = limit
15:06:54.137 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 是否可操作:false
3)flip
作用:反转缓冲区,可以理解为从写变成读。
源码:
ini
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
案例:
lua
public void flipTests() {
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
log.info("向缓冲区中写数据");
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
log.info("flip调用前:position = {},limit = {}", byteBuffer.position(), byteBuffer.limit());
log.info("反转缓冲区");
byteBuffer.flip();
log.info("flip调用后:position = {},limit = {}", byteBuffer.position(), byteBuffer.limit());
log.info("开始读取数据");
log.info("读取:{}", byteBuffer.get());
}
结果:
ini
15:09:54.221 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 向缓冲区中写数据
15:09:54.244 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - flip调用前:position = 4,limit = 10
15:09:54.252 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 反转缓冲区
15:09:54.252 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - flip调用后:position = 0,limit = 4
15:09:54.252 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 开始读取数据
15:09:54.252 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取:1
4)rewind
作用:重读缓冲区,就是丢弃 mark 标记位(赋为 -1),position 置 0。
源码:
ini
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
案例:
lua
public void rewindTests() {
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
log.info("向缓冲区中写数据");
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
log.info("mark标记当前位置");
byteBuffer.mark();
log.info("rewind调用前:position = {},mark = {}", byteBuffer.position(), byteBuffer.mark());
log.info("rewind方法指向");
byteBuffer.rewind();
log.info("rewind调用后:position = {},mark = {}", byteBuffer.position(), byteBuffer.mark());
log.info("开始读取数据");
log.info("读取:{}", byteBuffer.get());
}
结果:
ini
15:57:20.901 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 向缓冲区中写数据
15:57:20.924 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - mark标记当前位置
15:57:20.924 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - rewind调用前:position = 4,mark = java.nio.HeapByteBuffer[pos=4 lim=10 cap=10]
15:57:20.931 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - rewind方法指向
15:57:20.932 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - rewind调用后:position = 0,mark = java.nio.HeapByteBuffer[pos=0 lim=10 cap=10]
15:57:20.932 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 开始读取数据
15:57:20.932 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取:1
可以看到 rewind 方法就是将读取位置重新置为 0 ,相当于重复读取。
5)reset
作用:将 position 指向 mark 标记位
源码:
arduino
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
案例:
c
public void markTests() {
char[] c = new char[]{'a', 'b', 'c', 'd', 'e'};
CharBuffer charBuffer = CharBuffer.wrap(c);
log.info("容量capacity = {}", charBuffer.capacity());
log.info("起始限制limit = {}", charBuffer.limit());
log.info("起始读取位置position = {}", charBuffer.position());
log.info("设置 position 位置为:2");
charBuffer.position(2);
// 当前位置做标记
charBuffer.mark();
log.info("我标记了 mark 的位置 mark = {},position位置:{}", charBuffer.mark(), charBuffer.position());
charBuffer.position(4);
log.info("往前走几步position = {}", charBuffer.position());
// 回到标记位置
log.info("执行 reset 方法");
charBuffer.reset();
log.info("当前读取位置position = {}", charBuffer.position());
log.info("position 又回到当初 mark 的位置");
}
结果:
ini
16:03:22.718 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 容量capacity = 5
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 起始限制limit = 5
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 起始读取位置position = 0
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 设置 position 位置为:2
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 我标记了 mark 的位置 mark = cde,position位置:2
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 往前走几步position = 4
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 执行 reset 方法
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前读取位置position = 2
16:03:22.749 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - position 又回到当初 mark 的位置
6)clear
作用:清空缓冲区,但只是重置了 position、limit、mark 属性的值,数据还是存在,只是将指向数据的属性归位了而已(因为没有属性指向原来的数据位置,所以可以理解缓冲区数据清空)。
源码:
ini
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
案例:
csharp
public void clearsTests() {
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
log.info("向缓冲区中写数据");
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
log.info("当前缓冲区状态:{}", byteBuffer);
byteBuffer.clear();
log.info("调用clear方法后,当前缓冲区状态:{}", byteBuffer);
}
结果:
ini
16:05:20.474 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 向缓冲区中写数据
16:05:20.497 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=4 lim=10 cap=10]
16:05:20.504 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 调用clear方法后,当前缓冲区状态:java.nio.HeapByteBuffer[pos=0 lim=10 cap=10]
7)compact
作用:压缩缓冲区,将未读数据向前移动,将读过的数据覆盖。
源码:
scss
public ByteBuffer compact() {
// 移动数据
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
// 设置 position 为 limit - position
position(remaining());
// 设置 limit 为容量
limit(capacity());
// 重置 mark 为 -1
discardMark();
return this;
}
这里可能不太好理解,我画个执行效果图方便大家理解,如下:

上图画的应该很清晰,希望大家能够理解这个方法的作用。
案例:
csharp
public void compactTests() {
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
log.info("向缓冲区中写数据");
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
log.info("当前缓冲区状态:{}", byteBuffer);
byteBuffer.flip();
log.info("执行 flip 方法(要读数据了)...");
log.info("当前缓冲区状态:{}", byteBuffer);
log.info("读取第一个数据:{}", byteBuffer.get());
log.info("读取第二个数据:{}", byteBuffer.get());
log.info("当前缓冲区状态:{}", byteBuffer);
byteBuffer.compact();
log.info("执行压缩方法...");
log.info("当前缓冲区状态:{}", byteBuffer);
byteBuffer.flip();
log.info("执行 flip 方法(要读数据了)...");
log.info("当前缓冲区状态:{}", byteBuffer);
log.info("读取第三个数据:{}", byteBuffer.get());
log.info("当前缓冲区状态:{}", byteBuffer);
}
结果:
ini
17:27:33.791 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 向缓冲区中写数据
17:27:33.809 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=4 lim=10 cap=10]
17:27:33.816 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 执行 flip 方法(要读数据了)...
17:27:33.816 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=0 lim=4 cap=10]
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取第一个数据:1
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取第二个数据:2
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=2 lim=4 cap=10]
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 执行压缩方法...
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=2 lim=10 cap=10]
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 执行 flip 方法(要读数据了)...
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=0 lim=2 cap=10]
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 读取第三个数据:3
17:27:33.817 [main] INFO cn.j3code.example.nio.buffer.BufferDemo - 当前缓冲区状态:java.nio.HeapByteBuffer[pos=1 lim=2 cap=10]
Buffer 中还有很多方法就不一一带着大家分析了,大家感兴趣可以自行学习。
5、Channel 通道
5.1 是啥
先看百度解释:

从百度的解释来看:通道主要用来传输数据的一条道路。
而在 NIO 中,通道的作用也是如此:传输数据,将"原缓冲区"与"目标缓冲区"要交换的数据进行传输。
通道的作用图:

通道是用于 I/O 操作的连接,也即数据到硬件设备、文件、网络套接字的连接。通道的状态可以是打开或者关闭两种,当创建通道时,通道就处于打开状态,一旦将其关闭,则保持关闭状态(不可逆)。
如果试图操作关闭的通道来进行 I/O 操作就会抛出 ClosedChannelException 异常,不过通道向外提供了 isOpen() 方法来帮助我们确认通道是否为打开状态,避免出错。
现在我们知道了 Channel 是用来传输数据的通道,那我们想一下在 Java 的 IO 中已经存在了"流"来对数据进行操作了,为什么还需要在提供一个 Channel 出来呢!
虽然"流"和"Channel"都能用于读取和写入数据,但是一个是用于操作数据( 更高级的操作 ) ,而一个适用于传输数据( 更底层操作,直接对文件、网络资源操作 ) 。
Channel 的出现主要是为了后面更好的支持非阻塞式(流一般只能阻塞式) I/O 操作,提高程序并发性能(Channel 线程安全)。
所以如果需要进行高级别的操作可以使用流;但如果需要底层传输控制,支持非阻塞式 I/O 则可以使用 Channel。
5.2 怎么用
在 IDEA 中我观察 Channel 的类继承关系时,发现好复杂呀,我点进源码看它的继承接口和实现类,发现超复杂,最后还是放弃通过 IDEA 看了。
所以我去看了 JDK8 的 API 文档找出了 Channel 的相关信息,如下。
1)父接口
- AutoCloseable:自动关闭流,而不需要显式地调用 close ()方法。
- Closeable:关闭 IO 流,释放系统资源。
2)直接子接口
- AsynchronousByteChannel:支持异步 IO 操作,单位时字节。
- AsynchronousChannel:支持异步 IO 操作。
- ByteChannel:继承 ReadableByteChannel 和 WritableByteChannel 接口允许对baty进行读写操作。
- GatheringByteChannel:使接口可以将多个缓冲区中的数据写入通道。
- InterruptibleChannel:使通道能以异步的方式进行关闭与中断。
- MulticastChannel:使通道支持一个多播的功能,可以理解同时向多个主机发送数据。
- NetworkChannel:主要作用是使通道与 Socket 进行关联,是通道中的数据能在 Socket 技术上进行传输。
- ReadableByteChannel:是通道允许对字节进行读操作。
- ScatteringByteChannel:主要作用时可以从通道中读取字节到多个缓冲区中。
- SeekableByteChannel:主要作用是在字节通道中维护 position ,以及允许 position 发生改变。
- WritableByteChannel:使通道允许对字节进行写操作。
3)所有已知实现类
- AbstractInterruptibleChannel:提供一个可以被中断的通道基本实现。
- AbstractSelectableChannel:可选通道的基本实现,该类定义了处理通道注册、注销和关闭机制的方法。
- AsynchronousFileChannel:可以以异步的方式从文件读取或往文件写入数据。
- AsynchronousServerSocketChannel:用于面向流的服务端的异步通道。
- AsynchronousSocketChannel:用于面向流的客户端的异步通道。
- DatagramChannel:面向无连接的套接字的可选通道。
- FileChannel:继承 AbstractInterruptibleChannel 类,主要作用时读取、写入、映射和操作文件的通道。该通道永远是阻塞的操作。
- Pipe.SinkChannel:一个代表Pipe的可写端的通道。
- Pipe.SourceChannel:一个代表Pipe的可读端的通道。
- SelectableChannel:可通过Selector复用的通道。
- ServerSocketChannel:面向连接的服务端通道。
- SocketChannel:面向连接的客户端通道。
Channel 体系确实很庞大,所以我们不需要全部的去深入它们,只需要知道其中的几个就行,比如接下来我们要介绍的四个通道:FileChannel 、ServerSocketChannel 、SocketChannel 、DatagramChannel ****。
FileChannel 类使用
看名字我们也能知道他是作用于文件的通道,用来读取、写入、映射和操作文件的。
对比传统的文件 IO 流操作,FileChannel 具有以下几个优势:
- 在文件绝对位置中进行读写操作
- 将文件中的某个区域直接映射到内存中,对于较大的文件,通常比调用普通 read / write 方法效率高
- 强制对底层存储设备进行文件的更新,确保在系统崩溃时不丢失数据
- 可以锁定某个文件区域,阻止其他程序对其进行访问
- 以一种可被很多操作系统优化为直接向文件系统缓存发送或从中读取的高速传输方法,将字节从文件传输到某个其他通道中,反之亦然。
FileChannel 是一个抽象类,所以我们不能直接通过构造器创建该对象。可以通过流对象提供的 getChannel() 方法或者 FileChannel 提供的 open() 静态方法创建对象。
流对象:InputStream、OutputStream
本质上通过上述方法创建的 FileChannel 对象,其本质则是 FileChannelImpl 对象,大家可以看看这个类的类继承图:

1)创建通道
前面我们说了通道的创建通常就两种:
- 通过静态方法 open()
- 通过流对象
那么下面我们先来看看 open 方法创建通道的方式。
伪代码
ini
// 获得一个根据指定文件路径的读写权限文件通道
FileChannel fileChannel = FileChannel.open(Path对象, 读写模式);
可以看到 open 方法让我们传入两类参数:资源对象和读写模式。第一个好理解,那第二个又是什么,为什么通道还有读写模式。
通道是具有读写模式的,也即如果一个通道是只读的那么如果对通道进行写数据则会报 NonWritableChannelException 异常,反之则报 NonReadableChannelException 异常。当然你在创建通道的时候可以给通道附上既可以读也可以写,这样就可以避免报错了(也即:读写模式 参数是可以传入多个)。
但是通过流的方式创建的对象就要么是只读通道要么是只写通道,伪代码:
ini
// 获取输入流
FileInputStream fileInputStream = new FileInputStream("资源地址");
// 根据输入流获得一个 "读" 权限的通道
FileChannel channel = fileInputStream.getChannel();
// 获取输出流
FileOutputStream outputStream = new FileOutputStream("资源地址");
// 根据输出流获得一个 "写" 权限的通道
FileChannel outChannel = outputStream.getChannel();
这里解释一下通道具有读写模式的底层原理:
上面说了,读写模式是在创建的时候就需要指定,所以我们需要去看看构造器的源码,也即 FileChannelImpl 类的构造源码:

可以看到创建 FileChannelImpl 对象的时候就需要知道是否可读和可写,如果不指定则默认为 false,接着再来看看调用读写的方法源码:

现在是不是恍然大悟了,源码明确的写了,如果是读方法,那么 readable 必须为 true 否则报错,反之也是如此,这就是通道具体读写模式的原理。
2)通道写操作
下面来感受下通过 FileChannel 向文件中写入数据:
java
public void writeTests() throws Exception {
URI uri = FileChannelDemo.class.getClassLoader().getResource("a.txt").toURI();
// 获得一个根据指定文件路径的读写权限文件通道
FileChannel fileChannel = FileChannel
.open((new File(uri.toURL().getFile())).toPath(), StandardOpenOption.WRITE, StandardOpenOption.READ);
// 准备好缓冲区,并指定对应的内容
ByteBuffer buffer = ByteBuffer.wrap("Hello Word!".getBytes(StandardCharsets.UTF_8));
// 通过通道向文件中写入数据
fileChannel.write(buffer);
}
结果:

write 方法会将 Buffer 中 position 与 limit 之间的数据写入到文件中 ,并且在点进 write 的内部源码中我们可以发现,其内部的写流程被 synchronized 所包裹,也即他在多线程环境下对同一个文件进行写入数据时,是线程安全的。
3)通道读操作
下面来感受下通过 FileChannel 读取文件中的数据:
arduino
public void readTests() throws Exception {
URI uri = FileChannelDemo.class.getClassLoader().getResource("a.txt").toURI();
// 获得一个根据指定文件路径的读写权限文件通道
FileChannel fileChannel = FileChannel
.open((new File(uri.toURL().getFile())).toPath(), StandardOpenOption.WRITE, StandardOpenOption.READ);
// 准备好缓冲区,并指定对应的内容
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 通过通道向文件中写入数据(根据 buffer 属性位置添加数据)
int read = fileChannel.read(buffer);
// 切换以下读写模式
buffer.flip();
// 将 buffer 中的数据打印出来
System.out.println(new String(buffer.array(), 0, read));
}
结果:

read 方法的作用是将文件中的数据通过通道读出来,并且写入到 Buffer 中。方法返回的结果就是读取字节的个数,当然也可能返回 0(未读到数据)、-1(到末尾了)等这种情况。
read 方法是从通道的 position ****属性值位置开始读取,也即文件读取位置可随着 position ****的改变而发生变化。
同理,read 方法内部的读取操作也是被 synchronized 包裹,所以也是线程安全操作。
SocketChannel 与 ServerSocketChannel 类使用
看到这两个类,我希望大家能立马想到这两个类:ServerSocket 和 Socket。
Socket 和 SocketChannel 都是 Java 提供的用于 TCP 网络编程接口,最大的区别在于 Socket 是基于流的,而 SocketChannel 是基于缓冲区的。一般 SocketChannel 用来作为客户端套接字,ServerSocketChannel 用来作为服务端套接字。
Socket 主要是提供了 InputStream 和 OutputStream 两个流对象,当调用 read 方法时,程序会阻塞直到有数据读取。而 SocketChannel 提供了一个 Buffer 对象,可以将从客户端读取的数据存放到 Buffer 中,这个过程是 非阻塞 ****的。
SocketChannel 还支持多路复用 ,一个线程可以同时处理多个客户端连接请求 ,提高了程序的并发性能。
相关类的类继承结构图:

1)套接字通道的使用
这两个类都不能通过构造器创建,只能通过其提供的静态方法进行创建,具体使用案例如下:
打开
ini
// 服务端套接字通道打开
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 客户端套接字通道打开
SocketChannel socketChannel = SocketChannel.open();
关闭
go
// 服务端套接字通道关闭
serverSocketChannel.close();
// 客户端套接字通道关闭
socketChannel.close();
连接
scss
// 服务的套接字通道要绑定端口,接着调用 accept 监听客户端连接
serverSocketChannel
.socket()
.bind(new java.net.InetSocketAddress(8080));
serverSocketChannel.accept()
// 客户端指定服务的 ip 和 端口 进行连接
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8080);
socketChannel.connect(address);
阻塞模式
javascript
// 设置非阻塞模式
serverSocketChannel.configureBlocking(Boolean.FALSE);
// 设置非阻塞方式
socketChannel.configureBlocking(Boolean.FALSE);
// 设为非阻塞之后,accept、connect 及读写操作都不会发生阻塞
套接字通道的读写和文件通道的读写类似,就不过多介绍。
DatagramChannel 类使用
DatagramChannel 是 NIO 中对 UDP 协议通信的封装。通过 DatagramChannel 对象,我们可以实现发送和接收 UDP 数据包。它与 TCP 协议不同的是,UDP 协议没有连接的概念,所以无需像 SocketChannel 一样先建立连接再开始通信。
下面是一个使用 DatagramChannel 发送和接收 UDP 数据包的例子:
ini
public static void main(String[] args) throws IOException {
DatagramChannel channel = DatagramChannel.open();
channel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String message = scanner.next();
buffer.put(message.getBytes());
buffer.flip();
channel.send(buffer, new InetSocketAddress("127.0.0.1", 8888));
buffer.clear();
channel.receive(buffer);
buffer.flip();
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear();
}
channel.close();
}
在这个例子中,我们创建了一个 DatagramChannel 对象,并调用 configureBlocking(false) 方法将其设置为非阻塞模式。然后,通过 Scanner 类获取用户输入的消息,将消息存放到 ByteBuffer 缓冲区中,并使用 send() 方法将其发送出去。接着,我们调用 receive() 方法来接收对方发送回来的消息,并将其打印到控制台上。
线程安全、读写方式与文件通道一样。
6、Selector 选择器
选择器是管理一个或多个 SelectableChannel 对象,并能够识别通道是否为诸如读写事件是否做好准备的一个组件。
FileChannel 不是 SelectableChan 分支下,所以选择器不做用于该通道。
新创建的通道总是处于阻塞模式,所以想要结合选择器达到非阻塞的目的,就要在注册前将通道设置为非阻塞模式。
一个通道至多只能在任意特定选择器上注册一次( 多次注册会覆盖 ) ,并且通道在向选择器注册到的时候需要指定该通道上的那些操作是选择器感兴趣的。
选择器感兴趣的事件类型被定义在 SelectionKey 类中,具体有下面四种:
- 可读:SelectionKey.OP_READ
- 可写:SelectionKey.OP_WRITE
- 连接:SelectionKey.OP_CONNECT
- 接收:SelectionKey.OP_ACCEPT
如果 Selector 对通道的多个操作类型感兴趣,可以用"位或"操作符来实现:
ini
int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
注意这个选择器感兴趣的操作
选择器对通道中的某个操作感兴趣是指,这个操作达到了就绪状态。比如,某个 SocketChannel 通道可以连接到一个服务器,则处于"连接就绪"状态(OP_CONNECT); 再比如,一个ServerSocketChannel 服务器通道准备好接收新进入的连接,则处于"接收就绪"(OP_ACCEPT)状态。还比如,一个数据可读的通道,可以说是"读就绪"(OP_READ)。一个等待写数据的通道可以说是"写就绪"(OP_WRITE)。后续需要对这些就绪状态做对应的接收、读、写操作。
6.1 SelectionKey
介绍 Selector 就不得不介绍 SelectionKey,他是通道向选择器注册成功后,将通道和选择器封装成的一个新对象(当然属性不止这两个)。

- channel:关联的通道
- selector:关联的选择器
- interestOps:关联通道上的感兴趣事件
- readyOps:关联通道上的已经发生的 IO 事件
interestOps 的值在注册的时候会进行设置,之后不可更改,他表示 SelectionKey 关联的 Channel 上对那些操作感兴趣。readyOps 的值不是通过 SelectionKey 来设置的,而是 Selector 的 select( ) 系列方法来完成。
通过 select 系列方法,选择器会通过 JNI,去进行底层操作系统的系统调用(比如 select/epoll ),可以不断地查询通道中所发生操作的就绪状态(或者 IO 事件),并且把这些发生了底层 IO 事件,转换成Java NIO 中的 IO 事件,记录在的通道关联的 SelectionKey 的 readyOps 上。除此之外,发生了 IO 事件的选择键,还会记录在 Selector 内部 selectedKeys 集合中。
SelectionKey 创建好之后会存入 Selector 中,源码如下:
scala
public abstract class SelectorImpl extends AbstractSelector {
// 发生了 IO 事件的 Channel 的选择键
protected Set<SelectionKey> selectedKeys = new HashSet();
// Channel 注册之后的选择键,一个 channel 在一个 selector 上有一个唯一的 Key
protected HashSet<SelectionKey> keys = new HashSet();
......
}
Selector 与 SelectionKey 关联图:

结合上述源码和关系图,我们可知:
- 选择器可以获得所有发生 IO 事件的选择键
- 选择器可以获得所有注册到他上面的所有选择键(间接的可以获得对应通道)
- 选择键可以获得注册的选择器,同样也可以获得关联的通道
6.2 Selector 使用
其实选择器的使用在前面介绍"Hello Word"案例的时候就已经提前说过了,不过这里还是打算再说一边,算是复习了。
使用选择器,主要有以下三步:
- 获取选择器实例;
- 将通道注册到选择器中;
- 轮询感兴趣的 IO 就绪事件(选择键集合)。
第一步:获取选择器实例。选择器实例是通过调用静态工厂方法 open() 来获取的,具体如下:
ini
//调用静态工厂方法 open() 来获取 Selector 实例
Selector selector = Selector.open();
open 方法的内部原理是,向选择器 SPI(SelectorProvider)发出请求,通过默认的 SelectorProvider(选择器提供者)对象,获取一个新的选择器实例。Java 中 SPI 全称为(Service Provider Interface,服务提供者接口),是 JDK 的一种可以扩展的服务提供和发现机制。Java 通过 SPI 的方式,提供选择器的默认实现版本。也就是说,其他的服务提供商可以通过 SPI 的方式,提供定制化版本的选择器的动态替换或者扩展。
第二步:将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上,简单的示例代码如下:
scss
// 2.获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 3.设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 4.绑定连接
serverSocketChannel.bind(new InetSocketAddress(18899));
// 5.将通道注册到选择器上,并制定监听事件为:"接收连接"事件
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
上面通过调用通道的 register(...) 方法,将 ServerSocketChannel 通道注册到了一个选择器上。当然,在注册之前,首先需要准备好通道。
注:
- 注册到选择器的通道,必须处于非阻塞模式下,否则将抛出 IllegalBlockingModeException 异常。这意味着,FileChannel 文件通道不能与选择器一起使用,因为 FileChannel 文件通道只有阻塞模式,不能切换到非阻塞模式;而 Socket 套接字相关的所有通道都可以。
- 一个通道,并不一定要支持所有的四种 IO 事件。例如服务器监听通 ServerSocketChannel,仅仅支持 Accept(接收到新连接)IO 事件;而传输通道 SocketChannel 则不同,该类型通道不支持Accept 类型的 IO 事件。
- 在注册之前,可以通过通道的 validOps() 方法,来获取该通道所有支持的 IO 事件集合。
第三步:选出感兴趣的 IO 就绪事件(选择键集合)。通过 Selector 选择器的 select() 方法,选出已经注册的、已经就绪的 IO 事件,并且保存到 SelectionKey 选择键集合中。
用于选择就绪的 IO 事件的 select() 方法,有多个重载的实现版本,具体如下:
- select():阻塞调用,一直到至少有一个通道发生了注册的IO事件。
- select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。
- selectNow():非阻塞,不管有没有IO事件,都会立刻返回。
select() 方法的返回值的是整数类型(int),表示发生了 IO 事件的数量。更准确地说,是从上一次 select 到这一次 select 之间,有多少通道发生了 IO 事件,更加准确地说,是指发生了选择器感兴趣(注册过)的IO事件数。
SelectionKey 集合保存在选择器实例内部,其元素为 SelectionKey 类型实例。调用选择器的 selectedKeys() 方法,可以取得选择键集合。
接下来,需要迭代集合的每一个选择键,根据具体 IO 事件类型,执行对应的业务操作。大致的处理流程如下:
scss
//轮询,选择感兴趣的 IO 就绪事件(选择键集合)
while (selector.select() > 0) {
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
//根据具体的 IO 事件类型,执行对应的业务操作
if(key.isAcceptable()) {
// IO 事件:ServerSocketChannel 服务器监听通道有新连接
} else if (key.isConnectable()) {
// IO 事件:传输通道连接成功
} else if (key.isReadable()) {
// IO 事件:传输通道可读
} else if (key.isWritable()) {
// IO 事件:传输通道可写
}
//处理完成后,移除选择键
keyIterator.remove();
}
}
处理完成后,需要将选择键从这个 SelectionKey 集合中移除,防止下一次循环的时候,被重复的处理。 SelectionKey 集合不能添加元素,如果试图向 SelectionKey 选择键集合中添加元素,则将抛出java.lang.UnsupportedOperationException 异常。
7、网络聊天室案例
学习了 NIO 的主要组件功能,下面我以一个综合的案例来巩固上面学过的组件使用。
需求:编写一个用 NIO 实现的多客户端互相聊天的程序。
分析:多客户端互相聊天,那么我们应该需要编写一个服务端用来提供给客户端连接,并且服务端需要读取客户端发过来的消息并将消息发送给除发送方以外的所有客户端。而客户端这边就要读取服务端转发过来的其他客户端的消息,并且支持向服务端发送消息。
7.1 服务端实现
scss
public class ChatServer {
public static void main(String[] args) throws Exception {
// 创建服务端通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel
.socket().bind(new java.net.InetSocketAddress(9090));
// 设为非阻塞
serverSocketChannel.configureBlocking(Boolean.FALSE);
// 获取选择器
Selector selector = Selector.open();
// 将服务端套接字通道注册到选择器上,并关注接收连接操作
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("三哥聊天室服务端启动成功");
while (true) {
// 开始轮询事件
selector.select();
// 获取发生的事件key
Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
while (keyIterator.hasNext()) {
// 获取事件key
SelectionKey selectionKey = keyIterator.next();
if (selectionKey.isAcceptable()) {
// 接收连接事件
// 获取服务套接字通道
ServerSocketChannel server = (ServerSocketChannel) selectionKey.channel();
// 接收客户端套接字通道
SocketChannel socketChannel = server.accept();
// 非阻塞
socketChannel.configureBlocking(Boolean.FALSE);
// 注册客户端通道,对读事件感兴趣
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
// 连接成功,给客户端发个消息提示一下
socketChannel.write(ByteBuffer.wrap("欢迎进入三哥聊天室".getBytes(StandardCharsets.UTF_8)));
System.out.println(socketChannel.getLocalAddress() + ",连接服务端成功!");
} else if (selectionKey.isReadable()) {
// 读取事件
handleRead(selectionKey);
}
// 移除处理过的事件
keyIterator.remove();
}
}
}
/**
* 获取客户端发送的消息,并将消息分发给除服务端和自己外的其余客户端
*
* @param selectionKey
*/
private static void handleRead(SelectionKey selectionKey) throws IOException {
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 获取消息
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
String message = "";
int read = 0;
while ((read = socketChannel.read(byteBuffer)) > 0) {
byteBuffer.flip();
message += new String(byteBuffer.array(), 0, read, StandardCharsets.UTF_8);
byteBuffer.clear();
}
// 打印客户端发送的消息
System.out.println(message);
// 分发给其余客户端
Set<SelectionKey> selectionKeys = selectionKey.selector().keys();
for (SelectionKey key : selectionKeys) {
SelectableChannel channel = key.channel();
// 除服务端,和本身以外
if (channel instanceof SocketChannel && channel != selectionKey.channel()) {
// 写出数据
((SocketChannel) channel).write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)));
}
}
// 继续注册读取事件
socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
}
}
服务端代码主要是处理了接收连接和读取消息的事件,针对客户端连接,在连接成功的时候给客户端发送消息提示连接成功,服务端也打印那个客户端上线了。针对读取事件,服务端会将从客户端读取到的消息通过循环将消息分别写出到除服务器和发送消息客户端以外的客户端对象。
7.2 客户端实现
ini
public class ChatClient {
private String name;
public void start(String name) throws Exception {
this.name = name;
// 连接服务端
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9090));
// 设置成非阻塞
socketChannel.configureBlocking(Boolean.FALSE);
// 获取选择器
Selector selector = Selector.open();
// 注册读事件
socketChannel.register(selector, SelectionKey.OP_READ);
// 启动多线程,用于发消息
new messageThread(name, socketChannel).start();
while (true) {
// 轮询事件
selector.select();
Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
while (keyIterator.hasNext()) {
SelectionKey selectionKey = keyIterator.next();
if (selectionKey.isReadable()) {
// 读取其他客户端发来的消息
SocketChannel client = (SocketChannel) selectionKey.channel();
// 获取消息
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
String message = "";
int read = 0;
while ((read = client.read(byteBuffer)) > 0) {
byteBuffer.flip();
message += new String(byteBuffer.array(), 0, read, StandardCharsets.UTF_8);
byteBuffer.clear();
}
// 打印客户端发送的消息
System.out.println(message);
}
keyIterator.remove();
}
}
}
}
class messageThread extends Thread {
private SocketChannel socketChannel;
public messageThread(String name, SocketChannel socketChannel) {
// 设置名称
super.setName(name);
this.socketChannel = socketChannel;
}
@Override
public void run() {
// 输入消息,并发送给其他客户端
Scanner scanner = new Scanner(System.in);
while (true) {
String message = scanner.nextLine();
message = this.getName() + " : " + message;
try {
socketChannel.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
客户端程序主要分为两个部分,一启动一个向服务端发送消息的线程;二循环监听从服务端发送过来的消息并打印出来。
因为客户端可以是多个,所以我们需要另外创建几个类,通过构造器的方式实例化 client 出来,进行消息发送与接收。
1)用户 J3 客户端
java
public class ChatA {
public static void main(String[] args) throws Exception {
ChatClient chatClient = new ChatClient();
chatClient.start("J3");
}
}
2)用户 小王 客户端
java
public class ChatB {
public static void main(String[] args) throws Exception {
ChatClient chatClient = new ChatClient();
chatClient.start("xiaowang");
}
}
至此我们的网络聊天室案例代码就编写完了,下面是项目启动的 gif 动图,大家感受一下吧!

资料
《NIO与Socket编程技术指南》,作者:高洪岩
c.biancheng.net/view/9837.h...
www.bilibili.com/video/BV1uu...
Q&A
关于 Java 的 IO 读写,缓冲区是如何提高读写效率的?
当系统调用 IO 操作时,若不用缓冲区,CPU 会酌情考虑使用中断。因为此时 CPU 是主动地,每个周期中都要花去一部分去询问 I\O 设备是否读完数据,这段时间 CPU 不能做任何其他的事情(至少负责执行这段模块的核不能)。所以,调用一次读了一个字,通报一次,CPU 腾出时间处理一次。
而设置缓冲区,CPU 通常会使用 DMA 方式去执行 I\O 操作。CPU 将这个工作交给 DMA 控制器来做,自己腾出时间做其他的事,当 DMA 完成工作时,DMA 会主动告诉 CPU"操作完成"。这时,CPU 接管后续工作。在此,CPU 是被动的。DMA 是专门做 I\O 与 内存 数据交换的,不仅自身效率高,也节约了 CPU 时间,CPU 只是在 DMA 开始和结束时做了一些设置罢了。
所以,调用一次,不必通报 CPU,等缓冲区满了,DMA 会对 CPU 说 "嘿,伙计!快过来看看,把他们都搬走吧"。
综上,设置缓冲区,就建立了数据块,使得 DMA 执行更方便,CPU 也有空闲,而不是呆呆地候着 I\O 数据读来,所以,设置缓冲区效率要高很多(读写数据量越大,越明显)。
一文详解 DMA 原理:blog.csdn.net/as480133937...