二、Netty-NIO核心原理详解(NIO核心组件:Buffer、Channel、Selector)
目录
- 1.NIO的起源
- 2.Java NIO简介
- 2.1 NIO和OIO的对比
- 2.2 通道(Channel)
- 2.3 选择器(Selector)
- 2.4 缓冲区(Buffer)
- 3.详解NIO Buffer类及其属性
- 3.1 Buffer类
- 3.2 Buffer类的重要属性
- 1、capacity属性
- 2、position属性
- 3、limit属性
- 4、mark属性
- 4.详解NIO Buffer类的重要方法
- 4.1 allocate()创建缓冲区
- 4.2 put()写入到缓冲区
- 4.3 flip()翻转
- 4.4 get()从缓冲区读取
- 4.5 rewind()倒带
- 4.6 mark()和reset()
- 4.7 clear()清空缓冲区
- 4.8 使用Buffer类的基本步骤
- 5.详解NIO Channel(通道)类
- 5.1 Channel(通道)的主要类型
- 5.2 FileChannel文件通道
- 5.3 使用FileChannel完成文件复制的实践案例
- 5.4 SocketChannel套接字通道
- 5.5 使用SocketChannel发送文件的实践案例
- 5.6 DatagramChannel数据报通道
- 5.7 使用DatagramChannel数据包通道发送数据的实践案例
- 6.详解NIO Selector选择器
- 6.1 选择器以及注册
- 6.2 SelectableChannel可选择通道
- 6.3 SelectionKey选择键
- 6.4 选择器使用流程
- 6.5 使用NIO实现Discard服务器的实践案例
- 6.6 使用SocketChannel在服务器端接收文件的实践案例
- 7.小结
1.NIO的起源
略
2.Java NIO简介
在1.4版本之前,Java IO类库是阻塞式IO;从1.4版本开始,引进了新的异步IO库,被称 为Java New IO类库,简称为Java NIO。
Java NIO类库的目标,就是要让Java支持非阻塞IO,基于这个原因,更多的人喜欢称Java NIO为非阻塞IO(Non-Block IO),称“老的”阻塞式Java IO为OIO(Old IO)。总体上说,NIO弥补了原来面向流的OIO同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲 区的IO。
Java NIO类库包含以下三个核心组件:

Java NIO,属于IO 多路复用模型。Java NIO组件提供了统一的应用开发API,屏蔽了底层的操作系统的差异。
2.1 NIO和OIO的对比
在Java中,NIO和OIO的区别,主要体现在三个方面:
| OIO | NIO |
|---|---|
| 面向流(Stream Oriented) | 面向缓冲区(Buffer Oriented) |
| 阻塞 | 非阻塞 |
| 没有选择器(Selector)概念 | 有选择器的概念 |
什么是面向流,什么是面向缓冲区呢?
- 在面向流的OIO操作中,IO的 read() 操作总是以流式的方式顺序地从一个流(Stream)中读取一个或多个字节,因此,我们不能随意地改变读取指针的位置,也不能前后移动流中的数据。
- 而NIO中引入了Channel(通道)和Buffer(缓冲区)的概念。面向缓冲区的读取和写入,都是与Buffer进行交互。用户程序只需要从通道中读取数据到缓冲区中,或将数据从缓冲区中写入到通道中。NIO不像OIO那样是顺序操作,可以随意地读取Buffer中任意位置的数据,可以随意修改Buffer中任意位置的数据。
NIO的非阻塞是如何做到的呢?
- OIO的操作是阻塞的,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。例如,我们调用一个read方法读取一个文件的内容,那么调用read的线程会被阻塞住,直到read操作完成。
- 在NIO中,当我们调用read方法时,系统底层已经把数据准备好了,应用程序只需要从通道把数据复制到Buffer(缓冲区)就行;如果没有数据,当前线程可以去干别的事情,不需要进行阻塞等待。
- NIO使用了通道和通道的IO多路复用技术。
NIO技术的实现?
- NIO技术的实现,是基于底层的IO多路复用技术实现的,比如在Windows中需要select多路复用组件的支持,在Linux系统中需要select/poll/epoll多路复用组件的支持。所以NIO的需要底层操作系统提供支持。而OIO不需要用到选择器。
2.2 通道(Channel)
- 在OIO中,同一个网络连接会关联到两个流:一个输入流(Input Stream),另一个输出流(Output Stream),Java应用程序通过这两个流,不断地进行输入和输出的操作。
- 在NIO中,一个网络连接使用一个Channel(通道)表示,所有的NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中的两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。
- Channel和Stream的一个显著的不同是:Stream是单向的,譬如InputStream是单向的只读流,OutputStream是单向的只写流;而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。

NIO中的Channel的主要实现有:
- FileChannel 用于文件IO操作
- DatagramChannel 用于UDP的IO操作
- SocketChannel 用于TCP的传输操作
- ServerSocketChannel 用于TCP连接监听操作

问题:什么是Channel的本质,该如何理解这个抽象概念?
TCP/IP协议的四层模型如下:

在TCP/IP协议四层模型的最底层为链路层。在最原始的物理链路时代,咱们数据传输的两头(发送方和接收方)会通过拉同轴电缆的方式,拉一条物理电缆(类似于后来更加高级的网线),这条网线就代表一个双向的连接(connection),通过这条电缆,双方可以完成数据的传输。
在操作系统的维度,该怎么标识这种底层的物理链路呢?或者,操作系统该怎么标识这种底层的虚拟链路呢?
-
操作系统一切都是文件描述符(file descriptor)。这种底层的物理链路,在操作系统层面,就会为应用创建一个文件描述符(file descriptor)。
-
这点和Java里边的对象类似,一个Java对象有内存的数据结构和内存地址,那么,一个文件描述符(file descriptor)也有一个内核的数据结构和一个进程内的唯一编号来表示。然后,操作系统会把这个文件描述提供给应用层,应用层通过对这个文件描述符(file descriptor)去对传输链路进行数据的读取和写入。
-
NIO中的TCP传输通道,实际上就是对底层的传输链路所对应的文件描述符(filedescriptor)的一种封装,具体的代码如下:
class SocketChannelImpl extends SocketChannel implements SelChImpl {//......// 文件描述符 对象private final FileDescriptor fd;}public final class FileDescriptor {// 文件描述符的进程内的唯一编号private int fd;//.... } -
如果两个Java应用通过NIO建立双向的连接(传输链路),它们各自都会有一个自己内部的文件描述符(file descriptor),代表这条连接的自己一方,如下图所示:

2.3 选择器(Selector)
多路复用模型: IO多路复用指的是一个进程/线程可以同时监视多个文件描述符(含socket连接),一旦其中的一个或者多个文件描述符可读或者可写,该监听进程/线程能够进行IO事件的查询。
在Java应用层面,如何实现对多个文件描述符的监视呢? 需要用到一个非常重要的Java NIO组件——Selector 选择器。Selector 选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
什么是IO事件呢? 表示通道某种IO操作已经就绪、或者说已经做好了准备。例如,如果一个新Channel链接建立成功了,就会在Server Socket Channel上发生一个IO事件,代表一个新连接一个准备好,这个IO事件叫做“接收就绪”事件。再例如,一个Channel通道如果有数据可读,就会发生一个IO事件,代表该连接数据已经准备好,这个IO事件叫做 “读就绪” 事件。
Java NIO将NIO事件进行了简化,只定义了四个事件,这四种事件用SelectionKey的四个常量来表示:
- SelectionKey.OP_CONNECT:连接就绪
- 表示通道已经连接就绪。当客户端通道尝试连接到服务器时,当连接操作完成(即连接成功建立)时,该事件就绪。通常用于SocketChannel。
- 注意:对于连接操作,通常是在通道上调用
connect方法后,然后注册该事件到选择器。当连接建立后,该事件就会就绪。- SelectionKey.OP_ACCEPT:接受就绪
- 表示服务器套接字通道已经准备好接受新的客户端连接。当服务器套接字通道(ServerSocketChannel)检测到有新的连接请求时,该事件就绪。
- 通常用于服务器端,当有新的客户端连接时,可以通过该事件来接受连接。
- SelectionKey.OP_READ:读就绪
- 表示通道已经准备好进行读取操作。当通道中有数据可读时(即数据已经到达,可以读取),该事件就绪。
- 对于TCP套接字,当接收缓冲区有数据可读时,该事件就绪。注意,如果通道到达流末尾(即对端关闭连接),也会触发读事件,但此时读取可能会返回-1。
- SelectionKey.OP_WRITE:写就绪
- 表示通道已经准备好进行写入操作。当通道的发送缓冲区有足够的空间可以写入数据时,该事件就绪。
- 注意:写事件通常是在缓冲区满后变为不就绪,当缓冲区有空闲空间时变为就绪。因此,一般情况下,我们不会一直注册写事件,而是在需要写数据时注册,当写不完时(即发送缓冲区满),注册写事件,当缓冲区可写时再继续写。
这些事件类型可以组合使用,例如,一个通道可以同时注册读和写事件,使用位或操作符:
SelectionKey.OP_READ | SelectionKey.OP_WRITE。

Selector的本质,就是去查询这些IO就绪事件,所以,它的名称就叫做 Selector查询者。
从编程实现维度来说,IO多路复用编程的第一步,是把通道注册到选择器中,第二步则是通过选择器所提供的事件查询(select)方法,这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。
由于一个选择器只需要一个线程进行监控,所以,我们可以很简单地使用一个线程,通过选择器去管理多个连接通道。

与OIO相比,NIO使用选择器的最大优势: 系统开销小,系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减小了系统的开销。
2.4 缓冲区(Buffer)
Buffer顾名思义:缓冲区,实际上是一个容器,一个连续数组。Channel提供从文件、网络读取数据的渠道,但是读写的数据都必须经过Buffer。

所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区中写入到通道中。缓冲区的使用,是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提和基础之一。
3.详解NIO Buffer类及其属性
- NIO的Buffer(缓冲区)本质上是一个内存块,既可以写入数据,也可以从中读取数据
- Java NIO中代表缓冲区的Buffer类是一个抽象类,位于java.nio包中
- NIO的Buffer的内部是一个内存块(数组),此类与普通的内存块(Java数组)不同的是:NIO Buffer对象,提供了一组比较有效的方法,用来进行写入和读取的交替访问。
- Buffer 类是一个非线程安全类
3.1 Buffer类
Buffer类是一个抽象类,对应于Java的主要数据类型,在NIO中有8种缓冲区类,分别如下:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
前7种Buffer类型,覆盖了能在IO中传输的所有的Java基本数据类型。第8种类型MappedByteBuffer是专门用于内存映射的一种ByteBuffer类型。不同的Buffer子类,其能操作的数据类型能够通过名称进行判断,比如IntBuffer只能操作Integer类型的对象。

3.2 Buffer类的重要属性
Buffer的子类会拥有一块内存,作为数据的读写缓冲区,但是读写缓冲区并没有定义在Buffer基类,而是定义在具体的子类中。如ByteBuffer子类就拥有一个byte[]类型的数组成员final byte[] hb,作为自己的读写缓冲区,数组的元素类型与Buffer子类的操作类型相互对应。

为了记录读写的状态和位置,Buffer类额外提供了一些重要的属性,其中有以下四个重要的成员属性:
- capacity(容量)
- position(读写位置)
- limit(读写的限制)
- mark (读写位置的临时备份)

各个属性示意图如下:

1、capacity属性
- Buffer类的capacity属性,表示内部容量的大小。一旦写入的对象数量超过了capacity容量,缓冲区就满了,不能再写入了。
- Buffer类的capacity属性一旦初始化,就不能再改变。原因是什么呢?Buffer类的对象在初始化时,会按照capacity分配内部数组的内存,在数组内存分配好之后,它的大小当然就不能改变了。
- Buffer类是一个抽象类,Java不能直接用来新建对象。在具体使用的时候,必须使用Buffer的某个子类,例如DoubleBuffer子类,该子类能写入的数据类型是double类型,如果在创建实例时其capacity是100,那么我们最多可以写入100个double类型的数据。
2、position属性
Buffer类的position属性,表示当前的位置。position属性的值与缓冲区的读写模式有关。在不同的模式下,position属性值的含义是不同的,在缓冲区进行读写的模式改变时,position值会进行相应的调整。
在写入模式下,position的值变化规则如下:
- 在刚进入到写入模式时,position值为0,表示当前的写入位置为从头开始。
- 每当一个数据写到缓冲区之后,position会向后移动到下一个可写的位置。
- 初始的position值为0,最大可写值为limit–1。当position值达到limit时,缓冲区就已经无空间可写了。
在读模式下,position的值变化规则如下:
- 当缓冲区刚开始进入到读取模式时,position会被重置为0。
- 当从缓冲区读取时,也是从position位置开始读。读取数据后,position向前移动到下一个可读的位置。
- 在读模式下,limit表示可以读上限。position的最大值,为最大可读上限limit,当position达到limit时,表明缓冲区已经无数据可读。
Buffer的读写模式具体如何切换呢?
- 当新建了一个缓冲区实例时,缓冲区处于写入模式,这时是可以写数据的
- 在数据写入完成后,如果要从缓冲区读取数据,这就要进行模式的切换,可以使用(即调用)flip翻转方法,将缓冲区变成读取模式。
在从写入模式到读取模式的flip翻转过程中,position和limit属性值会进行调整,具体的规则是:
- limit属性被设置成写入模式时的position值,表示可以读取的最大数据位置;
- position由原来的写入位置,变成新的可读位置,也就是0,表示可以从头开始读。
3、limit属性
Buffer类的limit属性,表示可以写入或者读取的最大上限,其属性值的具体含义,也与缓冲区的读写模式有关,在不同的模式下,limit的值的含义是不同的
在写入模式下:
- limit属性值的含义为可以写入的数据最大上限。
- 在刚进入到写入模式时,limit的值会被设置成缓冲区的capacity容量值,表示可以一直将缓冲区的容量写满。
在读取模式下:
- limit的值含义为最多能从缓冲区中读取到多少数据。
- 使用flip方法反转从写模式进入读模式时,会将写入模式下的position值,设置成读取模式下的limit值,也就是说,将之前写入的最大数量,作为可以读取的上限值。
Buffer在flip翻转时的属性值调整,主要涉及position、limit两个属性:
- 首先,创建缓冲区。新创建的缓冲区处于写入模式,其position值为0,limit值为最大容量capacity。
- 然后,向缓冲区写数据。每写入一个数据,position向后面移动一个位置,也就是position的值加1。这里假定写入了5个数,当写入完成后,position的值为5。
- 最后,使用flip方法将缓冲区切换到读模式。limit的值,先会被设置成写入模式时的position值,所以新的limit值是5,表示可以读取的最大上限是5。
- 之后调整position值,新的position会被重置为0,表示可以从0开始读。
缓冲区切换到读模式后,就可以从缓冲区读取数据了,一直到缓冲区的数据读取完毕。
4、mark属性
mark属性的大致作用为:
- 读位置或者写位置的一个备份,供后续恢复时使用。
- 在缓冲区操作(读或者写)的过程当中,可以将当前的position的值,临时存入mark属性中;
- 需要恢复的时候,可以再从mark中取出之前的值,恢复到position属性中,
- 然后,后续可以重新从position位置开始处理(读取或者写入)

4.详解NIO Buffer类的重要方法
| 方法名 | 作用 |
|---|---|
| allocate() | 创建缓冲区 |
| put() | 将数据写入到缓冲区,写入的数据类型要求与缓冲区的类型保持一致。 |
| flip() | 向缓冲区写入数据之后,不能直接从缓冲区读取数据,这时缓冲区还处于写模式,如果需要读取数据,还需要将缓冲区转换成读模式。flip()翻转方法是Buffer类提供的一个模式转变的重要方法,它的作用就是将写入模式翻转成读取模式。在读取完成后,如何再一次将缓冲区切换成写入模式呢?答案是:可以调用Buffer.clear() 清空或者Buffer.compact()压缩方法,它们可以将缓冲区转换为写模式。 |
| get() | 可以调用get方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。在读完之后是否可以立即对缓冲区进行数据写入呢?答案是不能。现在还处于读取模式,我们必须调用Buffer.clear()或Buffer.compact()方法,即清空或者压缩缓冲区,将缓冲区切换成写入模式,让其重新可写。 |
| rewind() | 倒带。已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。 |
| mark( )和reset() | mark( )和reset( )两个方法是成套使用的:Buffer.mark()方法将当前position的值保存起来,放在mark属性中,让mark属性记住这个临时位置;之后,可以调用Buffer.reset()方法将mark的值恢复到position中。 |
| clear( ) | 清空缓冲区,在读取模式下,调用clear()方法将缓冲区切换为写入模式。此方法的作用: (1)会将position清零; (2) limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。 |
4.1 allocate()创建缓冲区
在使用Buffer(缓冲区)实例之前,我们首先需要获取Buffer子类的实例对象,并且分配内存空间。如果需要获取一个Buffer实例对象,并不是使用子类的构造器来创建一个实例对象,而是调用子类的allocate()方法。
ublic class UseBuffer {public static Logger logger = LoggerFactory.getLogger(UseBuffer.class);//Buffer 静态变量static IntBuffer intBuffer = null;static ByteBuffer byteBuffer = null;static CharBuffer charBuffer = null;static ShortBuffer shortBuffer = null;static FloatBuffer floatBuffer = null;static DoubleBuffer doubleBuffer = null;static LongBuffer longBuffer = null;static MappedByteBuffer mappedByteBuffer = null;/*** 测试创建Buffer实例*/public static void allocateTest(){//创建buffer 实例对象intBuffer = IntBuffer.allocate(20);logger.debug("------------after allocate------------------");logger.debug("position=" + intBuffer.position());logger.debug("limit=" + intBuffer.limit());logger.debug("capacity=" + intBuffer.capacity());// 创建其他类型的buffer实例byteBuffer = ByteBuffer.allocate(20);charBuffer = CharBuffer.allocate(20);shortBuffer = ShortBuffer.allocate(20);floatBuffer = FloatBuffer.allocate(20);doubleBuffer = DoubleBuffer.allocate(20);longBuffer = LongBuffer.allocate(20);mappedByteBuffer = (MappedByteBuffer) MappedByteBuffer.allocate(20);}
}
IntBuffer是具体的Buffer子类,通过调用IntBuffer.allocate(20),创建了一个Intbuffer实例对象,并且分配了20 * 4个字节的内存空间。缓冲区的内部结构如下:

一个缓冲区在新建后,处于写入的模式,position属性(代表写入位置)的值为0,缓冲区的capacity容量值也是初始化时allocate方法的参数值(这里是20),而limit最大可写上限值也为的allocate方法的初始化参数值。
4.2 put()写入到缓冲区
在调用allocate方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象,而如果要写入对象到缓冲区,需要调用put方法。put方法很简单,只有一个参数,即为所需要写入的对象。只不过,写入的数据类型要求与缓冲区的类型保持一致。
/*** 测试想buffer写入数据*/public static void putTest() {for (int i = 0; i < 5; i++){//写入一个整数到缓冲区intBuffer.put(i);}//输出缓冲区的主要属性值logger.debug("------------after putTest------------------");logger.debug("position=" + intBuffer.position());logger.debug("limit=" + intBuffer.limit());logger.debug("capacity=" + intBuffer.capacity());}
写入5个元素后,缓冲区的内部结构如下:

4.3 flip()翻转
向缓冲区写入数据之后,是否可以直接从缓冲区中读取数据呢?
不能,这时缓冲区还处于写模式,如果需要读取数据,还需要将缓冲区转换成读模式.
flip()翻转方法是Buffer类提供的一个模式转变的重要方法,它的作用就是将写入模式翻转成读取模式。
/*** 测试 buffer 读写模式翻转*/public static void flipTest() {//翻转缓冲区,从写入模式翻转成读取模式intBuffer.flip();//输出缓冲区的主要属性值logger.info("------------after flip ------------------");logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());}
在调用flip方法进行缓冲区的模式翻转之后,缓冲区的内部结构如下:

调用flip方法后,新模式下可读上限limit的值,变成了之前写入模式下的position属性值,也就是5;而新的读取模式下的position值,简单粗暴地变成了0,表示从头开始读取。
对flip()方法的从写入到读取转换的规则如下:
- 首先,设置可读上限limit的属性值。将写入模式下的缓冲区中内容的最后写入位置position值,作为读取模式下的limit上限值。
- 其次,把读的起始位置position的值设为0,表示从头开始读。
- 最后,清除之前的mark标记,因为mark保存的是写入模式下的临时位置,发生模式翻转后,如果继续使用旧的mark标记,会造成位置混乱。
Buffer.flip()方法源码如下:
public final Buffer flip() {limit = position; // 1.将limit值设置为position值position = 0; // 2.将position值设置为0mark = -1; // 3.清空mark保存的position值return this;}
在读取完成后,如何再一次将缓冲区切换成写入模式呢?
可以调用Buffer.clear() 清空或者Buffer.compact()压缩方法,它们可以将缓冲区转换为写模式。

4.4 get()从缓冲区读取
使用调用flip方法将缓冲区切换成读取模式之后,就可以开始从缓冲区中进行数据读取了。读取数据的方法很简单,可以调用get方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。
/*** 测试从buffer读取数据*/public static void getTest(){//先读 2 个数据for (int i = 0; i< 2; i++){int j = intBuffer.get();logger.info("j = " + j);}//输出缓冲区的主要属性值logger.info("---------after get 2 int --------------");logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());//再读 3 个数据for (int i = 0; i< 3; i++){int j = intBuffer.get();logger.info("j = " + j);}//输出缓冲区的主要属性值logger.info("---------after get 3 int ---------------");logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());}
执行结果如下:

- 读取操作会改变可读位置position的属性值,而limit可读上限值并不会改变。
- 在position值和limit的值相等时,表示所有数据读取完成,position指向了一个没有数据的元素位置,已经不能再读了。此时再读,会抛出BufferUnderflowException异常。
在读完之后是否可以立即对缓冲区进行数据写入呢?
不能。现在还处于读取模式,我们必须调用Buffer.clear()或Buffer.compact()方法,即清空或者压缩缓冲区,将缓冲区切换成写入模式,让其重新可写。
4.5 rewind()倒带
已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
/*** 测试 buffer 重新读取数据*/public static void rewindTest() {//倒带intBuffer.rewind();//输出缓冲区属性logger.info("------------after rewind ------------------");logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());}
执行结果如下:

rewind ()方法,主要是调整了缓冲区的position属性与mark标记属性,具体的调整规则如下:
- position重置为0,所以可以重读缓冲区中的所有数据
- limit保持不变,数据量还是一样的,仍然表示能从缓冲区中读取的元素数量;
- mark标记被清理,表示之前的临时位置不能再用了。
Buffer.rewind()方法的源代码如下:
public final Buffer rewind() {position = 0; // 1.重置position位置为0mark = -1; // 2.清除mark 中保存的position信息return this;}
4.6 mark()和reset()
mark( )和reset( )两个方法是成套使用的:Buffer.mark()方法将当前position的值保存起来,放在mark属性中,让mark属性记住这个临时位置;之后,可以调用Buffer.reset()方法将mark的值恢复到position中。
例如,可以在前面重复读取的示例代码中,在读到第3个元素(i为2时)时,可以调用mark()方法,把当前位置position的值保存到mark属性中,这时mark属性的值为2。然后,就可以调用reset( )方法,将mark属性的值恢复到position中,这样就可以从位置2(第三个元素)开始重复读取。
/*** 标记下中途读取到的位置*/public static void reRead() {for (int i = 0; i< 5; i++) {if (i == 2) {//临时保存,标记一下第 3 个位置intBuffer.mark();}//读取元素int j = intBuffer.get();logger.info("j = " + j);}//输出缓冲区的属性值logger.info("------------after reRead------------------");logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());}/*** 重复回到中途位置重复读数据*/public static void afterReset() {logger.info("------------after reset------------------");//把前面保存在 mark 中的值恢复到 positionintBuffer.reset();//输出缓冲区的属性值logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());//读取并且输出元素for (int i =2; i< 5; i++) {int j = intBuffer.get();logger.info("j = " + j);}}
4.7 clear()清空缓冲区
在读取模式下,调用clear()方法将缓冲区切换为写入模式。此方法的作用:
(1)会将position清零;
(2)limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
/*** 测试clear清除*/public static void clearDemo() {logger.info("------------after clear------------------");//清空缓冲区,进入写入模式intBuffer.clear();//输出缓冲区的属性值logger.info("position=" + intBuffer.position());logger.info("limit=" + intBuffer.limit());logger.info("capacity=" + intBuffer.capacity());}
执行结果如下:

在缓冲区处于读取模式时,调用clear(),缓冲区会被切换成写入模式。调用clear()之后,清空了position(写入的起始位置)的值,其值被设置为0,并且limit值(写入的上限)为最大容量。
Buffer.clear()方法源码如下:
public final Buffer clear() {position = 0; // 1.position 值置为0limit = capacity; // 2.limit 值为最大容量值mark = -1; // 3.清除mark中保存的position值信息return this;}
4.8 使用Buffer类的基本步骤
总体来说,使用Java NIO Buffer类的基本步骤如下:
- 使用创建子类实例对象的allocate( )方法,创建一个Buffer类的实例对象。
- 调用put( )方法,将数据写入到缓冲区中。
- 写入完成后,在开始读取数据前,调用Buffer.flip( )方法,将缓冲区转换为读模式。
- 调用get( )方法,可以从缓冲区中读取数据。
- 读取完成后,调用Buffer.clear( )方法或Buffer.compact()方法,将缓冲区转换为写入模式,可以继续写入。

5.详解NIO Channel(通道)类
Java NIO中,一个socket连接使用一个Channel(通道)来表示。然而,从更广泛的层面来说,一个通道封装了一个底层的文件描述符,例如硬件设备、文件、网络连接等。所以,与文件描述符相对应,Java NIO的通道分为很多类型。但是Java的通道更加的细化,例如,对应到不同的网络传输协议类型,在Java中都有不同的NIO Channel(通道)相对应。
5.1 Channel(通道)的主要类型
Java NIO Channel 最为重要的四种Channel(通道)实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。

5.2 FileChannel文件通道
FileChannel是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入到文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。
下面代码展示FileChannel的获取、读取、写入、关闭四个操作:
public class TestFileChannel {// 定义Buffer大小private static final int CAPACITY = 1024;static FileChannel channel = null;public static void main(String[] args) throws IOException {/**1. 获取FileChannel 通道*/String srcFile = "classpath:demo.txt";// 1.1 创建一个文件输入流,获取文件流的通道FileInputStream fis = new FileInputStream(srcFile);channel = fis.getChannel();// 1.2 创建一个输出流,获取输出流的通道FileOutputStream fos = new FileOutputStream(srcFile);channel = fos.getChannel();// 1.3 也可以通过RandomAccessFile文件随机访问类,获取FileChannel文件通道实例RandomAccessFile rw = new RandomAccessFile(srcFile, "rw");channel = rw.getChannel(); // 获取文件流的通道,可读可写/**2.读取FileChannel 通道在大部分应用场景,从通道读取数据都会调用通道的int read(ByteBufferbuf)方法,它从通道读取到数据写入到ByteBuffer缓冲区,并且返回读取到的数据量。*/// 2.1 创建缓冲区ByteBuffer buf = ByteBuffer.allocate(CAPACITY);int len = -1;// 2.2 调用通道的read()方法,读取数据并写入字节类型的缓冲区,// 此时,对于channel属于读模式,对于buffer来说属于写模式while ((len = channel.read(buf)) != -1){// 处理buffer中的数据}/*** 3.写入FileChannel通道* 写入数据到通道,在大部分应用场景,都会调用通道的write(ByteBuffer)方法,此方* 法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。* write(ByteBuffer)方法的作用,是从ByteBuffer缓冲区中读取数据,然后写入到通道自身,* 而返回值是写入成功的字节数。*/// 3.1 翻转Buffer为读模式buf.flip();int outLength = 0;// 3.2 调用write方法,将buffer中的数据写入通道中while ((outLength = channel.write(buf)) != 0){System.out.println("写入的字节数" + outLength);}/*** 4.关闭通道* 当通道使用完成后,必须将其关闭。关闭非常简单,调用close( )方法即可*/channel.close();}
}
5.3 使用FileChannel完成文件复制的实践案例
需求:使用文件通道复制文件,具体:使用FileChannel文件通道,将源文件复制一份,把源文件中的数据都复制到目标文件中去
源代码如下:
public class FileNIOCopyDemo {/*** 演示程序的入口函数** @param args*/public static void main(String[] args) {//演示复制资源文件nioCopyResouceFile();}/*** 复制两个资源目录下的文件*/public static void nioCopyResouceFile() {String sourcePath = NioDemoConfig.FILE_RESOURCE_SRC_PATH;String srcPath = IOUtil.getResourcePath(sourcePath);Logger.debug("srcPath=" + srcPath);String destShortPath = NioDemoConfig.FILE_RESOURCE_DEST_PATH;String destdePath = IOUtil.builderResourcePath(destShortPath);Logger.debug("destdePath=" + destdePath);nioCopyFile(srcPath, destdePath);}/*** 复制文件** @param srcPath* @param destPath*/public static void nioCopyFile(String srcPath, String destPath) {File srcFile = new File(srcPath);File destFile = new File(destPath);try {//如果目标文件不存在,则新建if (!destFile.exists()) {destFile.createNewFile();}long startTime = System.currentTimeMillis();FileInputStream fis = null;FileOutputStream fos = null;FileChannel inChannel = null;FileChannel outchannel = null;try {fis = new FileInputStream(srcFile);fos = new FileOutputStream(destFile);inChannel = fis.getChannel();outchannel = fos.getChannel();int length = -1;ByteBuffer buf = ByteBuffer.allocateDirect(1024);//从输入通道读取到bufwhile ((length = inChannel.read(buf)) != -1) {//翻转buf,变成成读模式buf.flip();int outlength = 0;//将buf写入到输出的通道while ((outlength = outchannel.write(buf)) != 0) {System.out.println("写入字节数:" + outlength);}//清除buf,变成写入模式buf.clear();}//强制刷新磁盘outchannel.force(true);} finally {IOUtil.closeQuietly(outchannel);IOUtil.closeQuietly(fos);IOUtil.closeQuietly(inChannel);IOUtil.closeQuietly(fis);}long endTime = System.currentTimeMillis();Logger.debug(" 复制毫秒数:" + (endTime - startTime));} catch (IOException e) {e.printStackTrace();}}}
5.4 SocketChannel套接字通道
在NIO中,涉及网络连接的通道有两个:
- 一个是SocketChannel负责连接的数据传输,NIO中的SocketChannel传输通道,与OIO中的Socket类对应.
- 一个是ServerSocketChannel负责连接的监听。NIO中的ServerSocketChannel监听通道,对应于OIO中的ServerSocket类。
ServerSocketChannel仅仅应用于服务器端,而SocketChannel则同时处于服务器端和客户端,所以,对应于一个连接,两端都有一个负责传输的SocketChannel传输通道。
无论是ServerSocketChannel,还是SocketChannel,都支持阻塞和非阻塞两种模式。
下面展示SocketChannel的方法:
/*** @author lxg* @Date 2025/10/30* @apiNote*/
public class TestSocketChannel {public static void main(String[] args) throws IOException {/*** 1.获取SocketChannel传输通道* 在客户端,* - 先通过SocketChannel静态方法open()获得一个套接字传输通道;* - 然后,将socket套接字设置为非阻塞模式;* - 最后,通过connect()实例方法,对服务器的IP和端口发起连接。*/// 获得一个套接字传输通道SocketChannel socketChannel = SocketChannel.open();// 设置为非阻塞socketChannel.configureBlocking(false);// 对服务器的IP和端口发起连接socketChannel.connect(new InetSocketAddress("127.0.0.1",80));// 非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect方法就返// 回了,因此需要不断地自旋,检查当前是否是连接到了主机while (!socketChannel.finishConnect()) {// 不断地自旋,等待,或者做一些其他事}/*** 2.在服务器端,如何获取与客户端对应的传输套接字呢?* 在连接建立的事件到来时,服务器端的ServerSocketChannel能成功地查询出这个新连接* 事件,并且通过调用服务器端ServerSocketChannel监听套接字的accept()方法,来获取新连接* 的套接字通道:*/// 新连接事件到来,首先通过事件,获取服务监听通道ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();// 获取新连接的套接字通道SocketChannel socketChannel1 = serverSocketChannel.accept();// 设置为非阻塞socketChannel1.configureBlocking(false);/*** 3.读取socketChannel传输通道* 当SocketChannel传输通道可读时,可以从SocketChannel读取数据,*/ByteBuffer byteBuffer = ByteBuffer.allocate(1024);// read()方法的返回值是读取的字节数,如果返回-1,那么表示读取到对方的输出结// 束标志,对方已经输出结束,准备关闭连接。int byteReade = socketChannel.read(byteBuffer);/*** 4.写入到SocketChannel传输通道* 和把数据写入到FileChannel文件通道一样,大部分应用场景都会调用通道的int* write(ByteBufferbuf)方法。*///写入前需要读取缓冲区,要求 ByteBuffer 是读取模式byteBuffer.flip();socketChannel.write(byteBuffer);/*** 5.关闭SocketChannel传输通道* 在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次* shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用* socketChannel.close()方法,关闭套接字连接。*///调用终止输出方法,向对方发送一个输出的结束标志socketChannel.shutdownOutput();//关闭套接字连接socketChannel.close();socketChannel1.close();}
}
5.5 使用SocketChannel发送文件的实践案例
需求:使用FileChannel文件通道读取本地文件内容,然后在客户端使用SocketChannel套接字通道,把文件内容发送到服务器
客户端代码实现如下:
/*** @author lxg* @Date 2025/10/30* @apiNote 客户端发送文件到服务端*/
public class NioSendClient {/*** 构造函数* 与服务器建立连接** @throws Exception*/public NioSendClient() {}private Charset charset = Charset.forName("UTF-8");/*** 向服务端传输文件** @throws Exception*/public void sendFile() {try {//发送小文件String srcPath = NioDemoConfig.SOCKET_SEND_FILE;//发送一个大的
// String srcPath = NioDemoConfig.SOCKET_SEND_BIG_FILE;File file = new File(srcPath);if (!file.exists()) {srcPath = IOUtil.getResourcePath(srcPath);Logger.debug("srcPath=" + srcPath);file = new File(srcPath);if (!file.exists()) {Logger.debug("文件不存在");return;}}// 通过文件输入流获取FileChannel传输通道,用以读取本地文件FileChannel fileChannel = new FileInputStream(file).getChannel();//获取SocketChannel套接字传输通道,用以向服务器传输文件SocketChannel socketChannel = SocketChannel.open();socketChannel.setOption(StandardSocketOptions.TCP_NODELAY, true);// 配置服务端IP和端口并发起连接socketChannel.socket().connect(new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP, NioDemoConfig.SOCKET_SERVER_PORT));// 设置为异步socketChannel.configureBlocking(false);Logger.debug("Client 成功连接服务端");// 不断自旋等待连接成功while (!socketChannel.finishConnect()) {//不断的自旋、等待,或者做一些其他的事情}//发送文件名称ByteBuffer fileNameByteBuffer = charset.encode(file.getName());ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
// ByteBuffer buffer = ByteBuffer.allocateDirect(NioDemoConfig.SEND_BUFFER_SIZE);//发送文件名称长度
// int fileNameLen = fileNameByteBuffer.capacity();//int fileNameLen = fileNameByteBuffer.remaining();buffer.clear();buffer.putInt(fileNameLen);//切换到读模式buffer.flip();socketChannel.write(buffer);Logger.info("Client 文件名称长度发送完成:", fileNameLen);// 发送文件名称socketChannel.write(fileNameByteBuffer);Logger.info("Client 文件名称发送完成:", file.getName());//发送文件长度//清空buffer.clear();buffer.putInt((int) file.length());//切换到读模式buffer.flip();//写入文件长度socketChannel.write(buffer);Logger.info("Client 文件长度发送完成:", file.length());//发送文件内容Logger.debug("开始传输文件");int length = 0;long offset = 0;buffer.clear();while ((length = fileChannel.read(buffer)) > 0) {buffer.flip();socketChannel.write(buffer);offset += length;Logger.debug("| " + (100 * offset / file.length()) + "% |");buffer.clear();}//等待一分钟关闭连接ThreadUtil.sleepSeconds(60);if (length == -1) {IOUtil.closeQuietly(fileChannel);socketChannel.shutdownOutput();IOUtil.closeQuietly(socketChannel);}Logger.debug("======== 文件传输成功 ========");} catch (Exception e) {e.printStackTrace();}}/*** 入口** @param args*/public static void main(String[] args) {NioSendClient client = new NioSendClient(); // 启动客户端连接client.sendFile(); // 传输文件}}
5.6 DatagramChannel数据报通道
在Java中使用UDP协议传输数据,比TCP协议更加简单。和Socket套接字的TCP传输协议不同,UDP协议不是面向连接的协议。使用UDP协议时,只要知道服务器的IP和端口,就可以直接向对方发送数据。在Java NIO中,使用DatagramChannel数据报通道来处理UDP协议的数据传输。
public class TestDatagramChannel {public static void main(String[] args) throws IOException {/*** 1.获取DatagramChannel数据报通道* 调用DatagramChannel类的open静态方法即可。然后调* 用configureBlocking(false)方法,设置成非阻塞模式。*///获取 DatagramChannel 数据报通道DatagramChannel datagramChannel = DatagramChannel.open();//设置为非阻塞模式datagramChannel.configureBlocking(false);// 如果需要接收数据,还需要调用bind方法绑定一个数据报的监听端口,具体如下:datagramChannel.bind(new InetSocketAddress(18080));/*** 2.读取DatagramChannel数据报通道数据* 当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的* SocketChannel读取方式不同,这里不调用read方法,而是调用receive(ByteBufferbuf)方法* 将数据从DatagramChannel读入,再写入到ByteBuffer缓冲区中。*/// 创建缓冲区ByteBuffer buffer = ByteBuffer.allocate(1024);//从 DatagramChannel 读入,再写入到 ByteBuffer 缓冲区// 返回值是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口)SocketAddress clientAddr = datagramChannel.receive(buffer);/*** 3.写入DatagramChannel数据报通道* 向DatagramChannel发送数据,和向SocketChannel通道发送数据的方法也是不同的。这* 里不是调用write方法,而是调用send方法。*///把缓冲区翻转到读取模式buffer.flip();//调用 send 方法,把数据发送到目标 IP+端口// 由于UDP是面向非连接的协议,因此,在调用send方法发送数据的时候,需要指定接收// 方的地址(IP和端口)datagramChannel.send(buffer, new InetSocketAddress("127.0.0.1",18899));//清空缓冲区,切换到写入模式buffer.clear();/*** 4.关闭DatagramChannel数据报通道*/datagramChannel.close();}
}
5.7 使用DatagramChannel数据包通道发送数据的实践案例
需求:个使用DatagramChannel数据包通到发送数据的客户端示例程序代码。其功能是:获取用户的输入数据,通过DatagramChannel数据报通道,将数据发送到远程的服务器。
客户端代码实现如下:
public class UDPClient {public void send() throws IOException {//操作一:获取DatagramChannel数据报通道DatagramChannel dChannel = DatagramChannel.open();//设置异步dChannel.configureBlocking(false);//创建一个bufferByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);//获取输入数据Scanner scanner = new Scanner(System.in);Logger.tcfo("UDP 客户端启动成功!");Logger.tcfo("请输入发送内容:");while (scanner.hasNext()) {String next = scanner.next();// 向buffer写入数据buffer.put((Dateutil.getNow() + " >>" + next).getBytes());// 翻转buffer为读模式buffer.flip();// 操作三:通过DatagramChannel数据报通道发送数据dChannel.send(buffer,new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP , NioDemoConfig.SOCKET_SERVER_PORT));// 清除buffer,转为写模式buffer.clear();}//操作四:关闭DatagramChannel数据报通道dChannel.close();}public static void main(String[] args) throws IOException {new UDPClient().send();}
}
服务端代码实现如下:
public class UDPServer {public void receive() throws IOException {//操作一:获取DatagramChannel数据报通道DatagramChannel datagramChannel = DatagramChannel.open();// 设置为非阻塞模式datagramChannel.configureBlocking(false);//绑定监听地址datagramChannel.bind(new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP, NioDemoConfig.SOCKET_SERVER_PORT));Logger.tcfo("UDP 服务器启动成功!");// 开启一个通道选择器Selector selector = Selector.open();// 将通道注册到选择器datagramChannel.register(selector, SelectionKey.OP_READ);// 通过选择器,查询IO事件while (selector.select() > 0) {Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);//迭代IO事件while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();// 可读事件,有数据到来if (selectionKey.isReadable()) {//操作二:读取DatagramChannel数据报通道数据SocketAddress client = datagramChannel.receive(buffer);buffer.flip();Logger.tcfo(new String(buffer.array(), 0, buffer.limit()));buffer.clear();}// 如果不删除,下一次又会被select函数选中iterator.remove();}}//关闭选择器和通道selector.close();datagramChannel.close();}public static void main(String[] args) throws IOException {new UDPServer().receive();}
}
6.详解NIO Selector选择器
Java NIO三大核心组件:
数据总是从通道读到缓冲区内,或者从缓冲区写入到通道中

6.1 选择器以及注册
选择器(Selector)是什么呢?选择器和通道的关系又是什么?
- 选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。
- 一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。
- 选择器和通道的关系,是监控和被监控的关系。
选择器提供了独特的API方法,能够选出(select)所监控的通道已经发生了哪些IO事件,包括读写就绪的IO操作事件。
在NIO编程中,一般是一个单线程处理一个选择器,一个选择器可以监控很多通道。所以,通过选择器,一个单线程可以处理数百、数千、数万、甚至更多的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。
通道和选择器之间的关联,通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:
- 第一个参数,指定通道注册到的选择器实例;
- 第二个参数,指定选择器要监控的IO事件类型。
可供选择器监控的通道IO事件类型,包括以下四种:
- 可读:SelectionKey.OP_READ
- 可写:SelectionKey.OP_WRITE
- 连接:SelectionKey.OP_CONNECT
- 接收:SelectionKey.OP_ACCEPT

以上的事件类型常量定义在SelectionKey类中:
如果选择器要监控通道的多种事件,可以用“按位或”运算符来实现。例如,同时监控可读和可写IO事件:
//监控通道的多种事件,用“按位或”运算符来实现
int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;
什么是IO事件呢?
这里的IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。
- 某个SocketChannel传输通道,如果完成了和对端的三次握手过程,则会发生“连接就绪”(OP_CONNECT)的事件。
- 某个ServerSocketChannel服务器连接监听通道,在监听到一个新连接的到来时,则会发生“接收就绪”(OP_ACCEPT)的事件。
- 一个SocketChannel通道有数据可读,则会发生“读就绪”(OP_READ)事件;
- 一个等待写入数据的SocketChannel通道,会发生写就绪(OP_WRITE)事件。

6.2 SelectableChannel可选择通道
并不是所有的通道,都是可以被选择器监控或选择的。比方说,FileChannel文件通道就不能被选择器复用。判断一个通道能否被选择器监控或选择,有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果是则可以被选择,否则不能。
简单地说,一条通道若能被选择,必须继承SelectableChannel类。
SelectableChannel类,提供了实现通道的可选择性所需要的公共方法。
Java NIO中所有网络链接Socket套接字通道,都继承了SelectableChannel类,都是可选择的。而FileChannel文件通道,并没有继承SelectableChannel,因此不是可选择通道。

6.3 SelectionKey选择键
通道和选择器的监控关系,本质是一种多对一的关联关系。即一个选择器监控多个通道

- Selector并不直接去管理Channel,而是直接管理SelectionKey,通过SelectionKey与Channel发生关系。
- Java NIO源码中规定了,一个Channel最多能向Selector注册一次,注册之后就形成了唯一的SelectionKey,然后被Selector管理起来
- Selector有一个核心成员keys,专门用于管理注册上来的SelectionKey,Channel注册到Selector后所创建的那一个唯一的SelectionKey,添加在这个keys成员中,这是一个HashSet类型的集合。除了成员keys之外,Selector还有一个核心成员selectedKeys,用于存放已经发生了IO事件的SelectionKey。
- 两核心成员keys、selectedKeys定义在Selector的抽象实现类SelectorImpl中,代码如下:

SelectionKey是IO事件的记录者(或存储者),SelectionKey 有两个核心成员,存储着自己关联的Channel上的感兴趣IO事件和已经发生的IO事件。这两个核心成员定义在实现类SelectionKeyImpl中,代码如下:

Channel通道上可以发生多种IO事件,比如说读就绪事件、写就绪事件、新连接就绪事件,但是SelectionKey记录事件的成员却是一个整数类型。
一个整数如何记录多个事件呢?答案是,通过比特位来完成的。具体的IO事件所占用的哪一个比特位,通过常量的方式定义在SelectionKey中,如下:
public abstract class SelectionKey {//......// 读取就绪事件 第0位public static final int OP_READ = 1 << 0;// 写入就绪事件,第 2 位public static final int OP_WRITE = 1 << 2;// 传输通道建立成功的 IO 事件,第 3 位public static final int OP_CONNECT = 1 << 3;// 新连接就绪事件,第 4 位public static final int OP_ACCEPT = 1 << 4;
}
- 通过SelectionKey的interestOps成员上相应的比特位,可以设置、查询关联的Channel所感兴趣的IO事件;
- 通过SelectionKey的readyOps上相应的比特位,可以查询关联Channel所已经发生的IO事件
- 对于interestOps成员上的比特位,应用程序是可以设置的;
- 对于readyOps上的比特位,应用程序只能查询,不能设置。为啥呢?readyOps上的比特位代表了已经发生的IO事件,是由选择器Selector去设置的,应用程序只能获取。
通道和选择器的监控关系注册成功后,Selector就可以查询就绪事件。具体的查询操作,是通过调用选择器Selector的select( )系列方法来完成。通过select系列方法,选择器会通过JNI,去进行底层操作系统的系统调用(比如select/epoll),可以不断地查询通道中所发生操作的就绪状态(或者IO事件),并且把这些发生了底层IO事件,转换成Java NIO中的IO事件,记录在的通道关联的SelectionKey的readyOps上。除此之外,发生了IO事件的选择键,还会记录在Selector内部selectedKeys集合中。
6.4 选择器使用流程
使用选择器,主要有以下三步
- 获取选择器实例
- 将通道Channel注册到选择器中
- 轮询感兴趣的IO就绪事件(选择键集合)

public class TestSelector {public static void main(String[] args) throws IOException {/*** 1.第一步:获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的* Selector选择器的类方法open( )的内部,是向选择器SPI(SelectorProvider)发出请求,* 通过默认的SelectorProvider(选择器提供者)对象,获取一个新的选择器实例。* Java中SPI全称为(Service Provider Interface,服务提供者接口),是JDK的一种可以扩展的服务提供* 和发现机制。Java通过SPI的方式,提供选择器的默认实现版本。也就是说,其他的服务提* 供商可以通过SPI的方式,提供定制化版本的选择器的动态替换或者扩展。**/Selector selector = Selector.open();/*** 2.第二步:将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上,** 注意:一个通道,并不一定要支持所有的四种IO事件。例如服务器监听* 通道ServerSocketChannel,仅仅支持Accept(接收到新连接)IO事件;而传输通道* SocketChannel则不同,该类型通道不支持Accept类型的IO事件。*/// 获取通道ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();// 设置为非阻塞// 注册到选择器的通道,必须处于非阻塞模式下,否则将抛出IllegalBlockingModeException异常。// FileChannel文件通道不能与选择器一起使用,// 因为FileChannel文件通道只有阻塞模式,不能切换到非阻塞模式;而Socket套接字相关的所有通道都可以。serverSocketChannel.configureBlocking(false);// 绑定连接serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8080));// 将通道注册到选择器上,并注册监听事件为“接收连接”事件serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);/*** 3.第三步:选出感兴趣的IO就绪事件(选择键集合)。通过Selector选择器的select()方法,* 选出已经注册的、已经就绪的IO事件,并且保存到SelectionKey选择键集合中。SelectionKey* 集合保存在选择器实例内部,其元素为SelectionKey类型实例。调用选择器的selectedKeys()* 方法,可以取得选择键集合。* 接下来,需要迭代集合的每一个选择键,根据具体IO事件类型,执行对应的业务操作。*///轮询,选择感兴趣的 IO 就绪事件(选择键集合)//while (selector.select() > 0) {Set<SelectionKey> selectionKeys = selector.selectedKeys();Iterator<SelectionKey> keyIterator = selectionKeys.iterator();while (keyIterator.hasNext()) {SelectionKey key = keyIterator.next();// 根据具体的 IO 事件类型,执行对应的业务操作if (key.isAcceptable()) {// IO 事件:ServerSocketChannel 服务器监听通道有新连接} else if (key.isConnectable()) {// IO 事件:传输通道连接成功} else if (key.isWritable()) {// IO 事件:传输通道可读}else if (key.isReadable()) {// IO 事件:传输通道可写}//处理完成后,移除选择键,SelectionKey集合不能添加元素,如果试图向SelectionKey选择键集合中添加// 元素,则将抛出java.lang.UnsupportedOperationException异常。keyIterator.remove();}}// 用于选择就绪的IO事件的select()方法,有多个重载的实现版本,具体如下:// 1.select():阻塞调用,一直到至少有一个通道发生了注册的IO事件。// 2.select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。// 3.selectNow():非阻塞,不管有没有IO事件,都会立刻返回。// select()方法的返回值的是整数类型(int),表示发生了IO事件的数量。更准确地说,是// 从上一次select到这一次select之间,有多少通道发生了IO事件,更加准确地说,是指发生了// 选择器感兴趣(注册过)的IO事件数。}
}
6.5 使用NIO实现Discard服务器的实践案例
需求:Discard服务器的功能很简单:仅仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道;并且读取到的数据直接抛弃掉(Discard)
服务端代码实现如下:
public class NioDiscardServer {public static void startServer() throws IOException {// 获取选择器Selector selector = Selector.open();// 获取通道ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();// 设置为非阻塞serverSocketChannel.configureBlocking(false);// 绑定连接serverSocketChannel.bind(new InetSocketAddress(18899));// 通道注册 接收新连接 IO事件到选择器上serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);// 轮询IO就绪事件while (selector.select() > 0) {// 获取选择键集合Set<SelectionKey> selectionKeys = selector.selectedKeys();Iterator<SelectionKey> iterator = selectionKeys.iterator();// 获取单个的选择键,并处理while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();// 判断key具体事件if (selectionKey.isAcceptable()) {// 若是连接就绪事件,则获取客户端连接SocketChannel socketChannel = serverSocketChannel.accept();// 将新连接设置为非阻塞socketChannel.configureBlocking(false);// 将新连接的可读事件注册到选择器上socketChannel.register(selector, SelectionKey.OP_READ);}else if(selectionKey.isReadable()){// 若选择键的事件是 可读 事件,读取数据// 获取选择键对应的通道SocketChannel socketChannel = (SocketChannel) selectionKey.channel();// 获取bufferByteBuffer buffer = ByteBuffer.allocate(1024);int length = 0;// 循环从通道读取数据到buffer中while ((length = socketChannel.read(buffer)) > 0) {// 翻转buffer为读模式buffer.flip();// 读取buffer中的数据System.out.println(new String(buffer.array(), 0, length));// 将buffer设置为写模式,继续下一次从channel读取数据写入到buffer中buffer.clear();}// 关闭读时事件连接socketChannel.close();}// 移除刚处理过的选择键,避免重复处理selectionKeys.remove(selectionKey);}}// 关闭监听通道serverSocketChannel.close();}public static void main(String[] args) throws IOException {startServer();}
}
客户端代码实现如下:
public class NioDiscardClient {public static void startClient() throws IOException {InetSocketAddress address =new InetSocketAddress("127.0.0.1", 18899);// 1.获取通道SocketChannel socketChannel = SocketChannel.open(address);// 2.切换成非阻塞模式socketChannel.configureBlocking(false);//不断地自旋、等待连接完成,或者做一些其他的事情while (!socketChannel.finishConnect()) {}// 3.分配指定大小的缓冲区ByteBuffer byteBuffer = ByteBuffer.allocate(1024);byteBuffer.put("hello world".getBytes());byteBuffer.flip();//发送到服务器socketChannel.write(byteBuffer);socketChannel.shutdownOutput();socketChannel.close();}public static void main(String[] args) throws IOException {startClient();}
}
6.6 使用SocketChannel在服务器端接收文件的实践案例
public class NioReceiveServer {//接受文件路径private static final String RECEIVE_PATH = NioDemoConfig.SOCKET_RECEIVE_PATH;private Charset charset = Charset.forName("UTF-8");/*** 服务器端保存的客户端对象,对应一个客户端文件*/static class Session {int step = 1; //1 读取文件名称的长度,2 读取文件名称 ,3 ,读取文件内容的长度, 4 读取文件内容//文件名称String fileName = null;//长度long fileLength;int fileNameLength;//开始传输的时间long startTime;//客户端的地址InetSocketAddress remoteAddress;//输出的文件通道FileChannel fileChannel;//接收长度long receiveLength;public boolean isFinished() {return receiveLength >= fileLength;}}private ByteBuffer buffer= ByteBuffer.allocate(NioDemoConfig.SERVER_BUFFER_SIZE);//使用Map保存每个客户端传输,当OP_READ通道可读时,根据channel找到对应的对象Map<SelectableChannel,NioReceiveServer.Session> clientMap = new HashMap<SelectableChannel, NioReceiveServer.Session>();public void startServer() throws IOException {// 1、获取Selector选择器Selector selector = Selector.open();// 2、获取通道ServerSocketChannel serverChannel = ServerSocketChannel.open();ServerSocket serverSocket = serverChannel.socket();// 3.设置为非阻塞serverChannel.configureBlocking(false);// 4、绑定连接InetSocketAddress address= new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_PORT);serverSocket.bind(address);// 5、将通道注册到选择器上,并注册的IO事件为:“接收新连接”serverChannel.register(selector, SelectionKey.OP_ACCEPT);Logger.tcfo("serverChannel is linstening...");// 6、轮询感兴趣的I/O就绪事件(选择键集合)while (selector.select() > 0) {if (null == selector.selectedKeys()) {continue;}// 7、获取选择键集合Iterator<SelectionKey> it = selector.selectedKeys().iterator();while (it.hasNext()) {// 8、获取单个的选择键,并处理SelectionKey key = it.next();if (null == key) {continue;}// 9、判断key是具体的什么事件,是否为新连接事件if (key.isAcceptable()) {// 10、若接受的事件是“新连接”事件,就获取客户端新连接ServerSocketChannel server = (ServerSocketChannel) key.channel();SocketChannel socketChannel = server.accept();if (socketChannel == null) {continue;}// 11、客户端新连接,切换为非阻塞模式socketChannel.configureBlocking(false);socketChannel.setOption(StandardSocketOptions.TCP_NODELAY, true);// 12、将客户端新连接通道注册到selector选择器上SelectionKey selectionKey =socketChannel.register(selector, SelectionKey.OP_READ);// 余下为业务处理NioReceiveServer.Session session = new Session();session.remoteAddress= (InetSocketAddress) socketChannel.getRemoteAddress();clientMap.put(socketChannel, session);Logger.debug(socketChannel.getRemoteAddress() + "连接成功...");} else if (key.isReadable()) {handleData(key);}// NIO的特点只会累加,已选择的键的集合不会删除// 如果不删除,下一次又会被select函数选中it.remove();}}}/*** 处理客户端传输过来的数据*/private void handleData(SelectionKey key) throws IOException {SocketChannel socketChannel = (SocketChannel) key.channel();int num = 0;NioReceiveServer.Session session = clientMap.get(key.channel());buffer.clear();while ((num = socketChannel.read(buffer)) > 0) {Logger.cfo("收到的字节数 = " + num);//切换到读模式buffer.flip();process(session, buffer);buffer.clear();
// key.cancel();}}private void process(Session session, ByteBuffer buffer) {while (len(buffer) > 0) { //客户端发送过来的,首先处理文件名长度if (1 == session.step) {int fileNameLengthByteLen = len(buffer);System.out.println("读取文件名称长度之前,可读取的字节数 = " + fileNameLengthByteLen);System.out.println("读取文件名称长度之前,buffer.remaining() = " + buffer.remaining());System.out.println("读取文件名称长度之前,buffer.capacity() = " + buffer.capacity());System.out.println("读取文件名称长度之前,buffer.limit() = " + buffer.limit());System.out.println("读取文件名称长度之前,buffer.position() = " + buffer.position());if (len(buffer) < 4) {Logger.cfo("出现半包问题,需要更加复制的拆包方案");throw new RuntimeException("出现半包问题,需要更加复制的拆包方案");}//获取文件名称长度session.fileNameLength = buffer.getInt();System.out.println("读取文件名称长度之后,buffer.remaining() = " + buffer.remaining());System.out.println("读取文件名称长度 = " + session.fileNameLength);session.step = 2;} else if (2 == session.step) {Logger.cfo("step 2");if (len(buffer) < session.fileNameLength) {Logger.cfo("出现半包问题,需要更加复制的拆包方案");throw new RuntimeException("出现半包问题,需要更加复制的拆包方案");}byte[] fileNameBytes = new byte[session.fileNameLength];//读取文件名称buffer.get(fileNameBytes);// 文件名String fileName = new String(fileNameBytes, charset);System.out.println("读取文件名称 = " + fileName);File directory = new File(RECEIVE_PATH);if (!directory.exists()) {directory.mkdir();}Logger.info("NIO 传输目标dir:", directory);session.fileName = fileName;String fullName = directory.getAbsolutePath() + File.separatorChar + fileName;Logger.info("NIO 传输目标文件:", fullName);File file = new File(fullName.trim());try {if (!file.exists()) {file.createNewFile();}FileChannel fileChannel = new FileOutputStream(file).getChannel();session.fileChannel = fileChannel;} catch (IOException e) {e.printStackTrace();}session.step = 3;} else if (3 == session.step) {Logger.cfo("step 3");//客户端发送过来的,首先处理文件内容长度if (len(buffer) < 4) {Logger.cfo("出现半包问题,需要更加复制的拆包方案");throw new RuntimeException("出现半包问题,需要更加复制的拆包方案");}//获取文件内容长度session.fileLength = buffer.getInt();System.out.println("读取文件内容长度之后,buffer.remaining() = " + buffer.remaining());System.out.println("读取文件内容长度 = " + session.fileLength);session.step = 4;session.startTime = System.currentTimeMillis();} else if (4 == session.step) {Logger.cfo("step 4");//客户端发送过来的,最后是文件内容session.receiveLength += len(buffer);// 写入文件try {session.fileChannel.write(buffer);} catch (IOException e) {e.printStackTrace();}if (session.isFinished()) {finished(session);}}}}private void finished(Session session) {IOUtil.closeQuietly(session.fileChannel);Logger.info("上传完毕");Logger.debug("文件接收成功,File Name:" + session.fileName);Logger.debug(" Size:" + IOUtil.getFormatFileSize(session.fileLength));long endTime = System.currentTimeMillis();Logger.debug("NIO IO 传输毫秒数:" + (endTime - session.startTime));}/*** 入口** @param args*/public static void main(String[] args) throws Exception {com.crazymakercircle.iodemo.socketDemos.NioReceiveServer server = new com.crazymakercircle.iodemo.socketDemos.NioReceiveServer();server.startServer();}private static int len(ByteBuffer buffer) {Logger.cfo(" >>> buffer left:" + buffer.remaining());return buffer.remaining();}}
7.小结
与Java OIO相比,Java NIO编程大致的特点如下:
- 在NIO中,服务器接收新连接的工作,是异步进行的。不像Java的OIO那样,服务器监听连接,是同步的、阻塞的。NIO可以通过选择器(也可以说成:多路复用器),后续不断地轮询选择器的选择键集合,选择新到来的连接。
- 在NIO中,SocketChannel传输通道的读写操作都是异步的。如果没有可读写的数据,负责IO通信的线程不会同步等待。这样,线程就可以处理其他连接的通道;不需要像OIO那样,线程一直阻塞,等待所负责的连接可用为止。
- 在NIO中,一个选择器线程可以同时处理成千上万的客户端连接,性能不会随着客户端的增加而线性下降。
总之,有了Linux底层的epoll支持,以及Java NIO Selector选择器等等应用层IO复用技术,Java程序从而可以实现IO通信的高TPS、高并发,使服务器具备并发数十万、数百万的连接能力。Java的NIO技术非常适合用于高性能、高负载的网络服务器。鼎鼎大名的通信服务器中间件Netty,就是基于Java的NIO技术实现的。
