【笔记】Java - IO模型(BIO、NIO、AIO)、案例(消息转发)、踩过的坑

IO模型

# BIO (blocking I/O)

同步并阻塞(传统阻塞模型)

💡 提示

java BIO 相关类和接口在 java.io

特点

  1. 一个连接、一个线程
  2. 连接不断、线程不断(连接等待、线程阻塞 ⇒ 资源浪费)

适用场景

  1. 连接数目比较少且固定
  2. JDK1.4以前的唯一选择(程序简单易于理解)
输入/输出流
输入/输出流
输入/输出流
Server
Thread
Thread
Thread
Socket
(客户端)
Socket
(客户端)
Socket
(客户端)
案例1: BIO多线程连接客户端

代码: https://github.com/LawssssCat/learn-io/blob/bio-1client-1thread/src/test/java/com/lawsssscat/learn/BIOTest.java

案例2: 模仿NIO(伪异步通信)

代码: https://github.com/LawssssCat/learn-io/blob/bio-false-asynchrony-io/src/test/java/com/lawsssscat/learn/BIOTest.java

  • 伪异步io采用了线程池实现,因此避免为每个请求创建一个独立线程造成线程资源耗尽的问题

  • 但是底层依然是采用的同步阻塞模型(BIO),因此无法从根本上解决问题: 如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后序socket的i/o消息都将在队列中排队;如果队列也满了,那么就会报错。

    💡 提示

    将案例的请求数提高(如:100)就能看到问题了。
    案例代码中,最大线程数为6,最大对队列数为10

    也就是说: 同一时间只能处理 6 + 10 = 16 6+10=16 6+10=16 个请求,否则就报错:
    Exception in thread "Thread-0" java.util.concurrent.RejectedExecutionException: Task com.lawsssscat.learn.BIOServerHandlerRunnable@1d098270 rejected from java.util.concurrent.ThreadPoolExecutor@4a105169[Running, pool size = 6, active threads = 6, queued tasks = 10, completed tasks = 0]

案例3: 文件上传

代码: https://github.com/LawssssCat/learn-io/blob/bio-file-upload/src/test/java/com/lawsssscat/learn/file/BIOFileTest.java

案例4: 端口转发(群聊系统)

代码: https://github.com/LawssssCat/learn-io/blob/bio-port-forward/src/test/java/com/lawsssscat/learn/chat/BIOChatTest.java

💡 提示

另外一个(控制台)版本: https://blog.csdn.net/LawssssCat/article/details/103085855 (有输入阻塞功能)

客户端连接服务器;客户端向服务器发送消息,服务器将消息转发到其他客户端

在这里插入图片描述

💡 提示

通过这个例子,我们可以体会到BIO的弊端: 一个客户端需要开一个线程;如果客户端有多种服务需求,客户端要开不同线程处理(否则会一直”卡着“(阻塞))

# NIO(new I/O)

同步非阻塞: 服务器实现一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器(Selector选择器)上,多路复用器轮询到连接有I/O请求就调用线程池进行处理

💡 提示: Java NIO

  • Java NIO(New IO)也有人称为java non-blocking IO是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API(BIO)
  • NIO与BIO有相同的作用,但是使用的方式完全不同,NIO支持面相缓冲区、基于通道的IO操作
  • NIO相关类都放在java.nio,并且对原java.io包中的很多类进行了改写
  • NIO有三大核心部分: Channel(通道)、Buffer(缓冲区)、Selector(选择器)
  • Java NIO的非阻塞模式:
    一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可以用的数据,如果目前没有数据可用,那么就获取空,而不是保持线程阻塞,所以直至数据变得可以读取之前,该线程可以继续做其他的事情。
  • 通俗的理解: NIO可以做到用一个线程来处理多个操作。假设

💡 提示: NIO和BIO的比较

区别NIOBIO
数据单元块(Block)方式处理数据流(Stream)方式处理数据
操作方式基于通道(Channel)和缓冲区(Buffer)进行操作
(数据总是从通道读取到缓冲区中,或者从缓冲区写入到数据到通道中)
基于字节流和字符流进行操作
任务调度选择器(Selector)用于监听多通道事件(如连接请求、数据到达等)开启线程,在新线程中监听端口事件

适用场景

  1. 连接数目多且连接时间短(轻操作),如聊天室服务器、弹幕系统、服务器间通讯等
  2. JDK1.4开始支持(编成比较复杂)
三大核心示意图

NIO有三大核心部分: Channel(通道)、Buffer(缓冲区)、Selector(选择器)

  • Channel(通道)
    • Java NIO的通道类似流(可以写/读数据),但又与流的单向读/写不同,通道支持双向读写(💡即不用为读和写分别创建两个通道了)。
    • 通道是非阻塞读取和写入通道,可以读取或写入缓冲区,也支持异步读写
  • Buffer(缓冲区): 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问这块内存。💡相比较直接对数组的操作,Buffer API更加容易操作和管理。
  • Selector(选择器、多路复用器): Selector是一个Java NIO组件,可以检查一个或多个NIO通道,并确定哪些通道已经准备好进读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率。
Channel
Buffer
Channel
Buffer
Channel
Buffer
Server
Thread Pool
Selector
Socket
(客户端)
Socket
(客户端)
Socket
(客户端)
NIO核心一: 缓冲区(Buffer)

一个用于特定基本数据类型的容器。由java.nio包定义,所有缓冲区都是Buffer抽象类的之类。Java NIO中的Buffer主要用于与NIO通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中的。

在这里插入图片描述

数据类型

Buffer就像一个数组,可以保存多个相同类型的数据。根据数据类型不同,有以下Buffer常用的子类:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

上述Buffer类都采用类似的方法进行数据管理,只是各自管理的数据类型不同而已。都是通过如下方法获取一个Buffer对象的:

static XxxBuffer allocate(int capacity): 创建一个容量为capacity的XxxBuffer对象
基本属性
  • 容量(capacity): 作为一个内存块,Buffer具有固定大小,也称为“容量”,缓冲区容量不能为负,创建后不能更改
  • 限制(limit): 表示缓冲区中可以操作数据的大小(在limit后面的数据不能读写)。缓冲区的限制不能为负,并且不能大于其容量。写入模式下,限制等于buffer的容量;读取模式下,limit等于写入的数据量。
  • 位置(position): 下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制;
  • 标记(mark)与重置(reset): 标记是一个索引,通过Buffer中的mark()方法指定Buffer中一个特定的position,之后可以通过调用reset()方法回复到这个position;

💡

标记、位置、限制、容量遵守以下不等式:
0 <= mark <= position <= limit <= capacity

常用方法

方法测试代码: https://github.com/LawssssCat/learn-io/blob/nio-buffer-api-test2/src/test/java/com/lawsssscat/learn/normal/NIOTest.java#L25-L96

创建方法

Buffer allocate(int capacity)            创建固定容量(capacity)的缓冲区
Buffer wrap(byte[] bytes)                创建bytes大小的缓冲区,并写入bytes数据

位置操作方法

| ---------- | ---------------- | -- remaining --- | -------------------| 
0          mark             position             limit              capacity
             | <----- reset---- |
| <------------ rewind -------- |
| <------------ clear --------- |                  | ----- clear -----> |
| <------------ flip ---------- | <--- flip ------ |

(before) compact:
0                           position              limit              capacity
| ----------------------------- | --- 未读部分 ---  | -------------------|  
(after) compact:
| --- 未读部分 ---- | -------------------------------------------------- |
0               position                                         limit,capacity

int capacity()               返回Buffer的capacity大小

int position()               返回当前position
Buffer position(int n)       设置当前position为n (返回的Buffer大概为了链式编成)
int limit()                  返回当前limit
Buffer limit(int n)          设置当前limit为n
int remaining()              返回当前position和limit之间的元素个数
boolean hasRemaining()       判断remaining是否为0
Buffer flip()                设置当前limit为position,然后设置当前position为0
Buffer compact()             将未读部分前移至0开始,position设定为结束位置

Buffer mark()                记录position的位置
Buffer reset()               移动position到mark的位置
Buffer rewind()              重置position、mark
Buffer clear()               重置position、mark、limit

数据操作方法

Buffer 所有子类提供了两个用于数据操作的方法: get()put() 方法

get()                   读取单个字节
get(byte[] dst)         批量读取多个字节到dst中
get(int index)          读取指定索引位置的直接(不会移动position)

put(byte b)             将给定单个直接写入缓冲区的当前位置
put(byte[] src)         将src中的字节写入当前缓冲区的当前位置
put(int index, byte b)  将指定字节写入缓冲区的索引位置(不会移动position)
直接缓冲区、非直接缓冲区

byte buffer可以是两种类型:

  1. 基于直接内存(也就是非堆内存):
    JVM在IO操作上有更高的性能,因为它直接作用于本地系统的IO操作;
    本地IO --> 直接内存 --> 本地IO
  2. 基于非直接内存(也就是堆内存):
    非直接内存,也就是堆内存中的数据,如果要做IO操作,要先将本进程内存复制到直接内存,再利用本地IO处理;
    本地IO --> 直接内存 --> 非直接内存 --> 直接内存 --> 本地IO

很明显,在当量数据操作时,直接内存的效率会更高。但是,直接内存的的申请比普通的堆内存需要耗费更多的性能。 因为这部分数据是在JVM之外,系统层面的资源。(换句话说,不占用应用的内存)

总结

  • 如果有很大的数据要缓存,同时它的生命周期很长,那么就适合使用直接内存
  • 如果需要频繁的IO操作,如大并发场景,使用直接内存
  • 如果不是能带来很明显的性能提升,推荐是用普通内存(堆内存)

💡 提示

直接内存使用 allocateDirect 创建

💡 提示

字节缓冲区是直接缓冲区还是非直接缓冲区可以通过 isDirect() 方法来确定

NIO核心二: 通道(Channel)

通道(Channel)由java.nio.channels包定义。Channel表示IO源与目标打开的连接。Channel类似于传统的“流(Stream)”。只不过Channel本身不能直接访问数据,Channel只能与Buffer进行交互

在这里插入图片描述

NIO的通道类似于流,但是有以下区别:

  1. 通道可以同时进行读写,而流只能读或者只能写
  2. 通道可以实现异步读写数据

Channel在NIO中是一个接口

public interface Channel extends Closable{}
常用的Channel实现类
  • (abstract)FileChannel: 用于读取、写入、映射和操作文件的通道
  • DatagramChannel: 通过UDP读写网络中的数据通道
  • SocketChannel: 通过TCP读写网络中的数据
  • ServerSocketChannel: 可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel。
    (类似ServerSocket与Socket的关系)

在这里插入图片描述

💡 提示

SocketChannel 和 ServerSocketChannel 都继承了 SelectableChannel,这意味着它们都能进行选择器事件的注册。因此,分别是客户端、服务端最常用的两个类。

在这里插入图片描述

常用方法 FileChannel

测试代码: https://github.com/LawssssCat/learn-io/blob/nio-channel-api-test/src/test/java/com/lawsssscat/learn/normal/NIOTest.java#L92-L198

FileChannel 是一个抽象类,通常由 FileInputStream.getChannel()FileOutputStream.getChannel() 提供实例

💡 ReadableByteChannel接口定义,实现: SocketChannelDatagramChannelFileChannel
int read(ByteBuffer dst)Channel读取数据到ByteBuffer
long read(ByteBuffer[] dsts)Channel读取数据“分散”到ByteBuffer[]
⚠️ 注意: 
1. 如果数据读完,read返回0
2. 如果客户端channel调用了close方法,服务端的read会返回-1
3. 如果客户端channel被强行关闭,服务端的read会抛出异常: java.io.IOException: 远程主机强迫关闭了一个现有的连接。

💡 WritableByteChannel接口定义,实现: SocketChannelDatagramChannelFileChannel
int write(ByteBuffer src)ByteBuffer的数据写入到Channel
long write(ByteBuffer[] srcs)ByteBuffer[]的数据“聚集”到Channel

💡 SeekableByteChannel接口定义,实现: FileChannel
long size()                          返回此通道的文件的当前大小
long position()                      返回此通道的文件位置
FileChannel position(long p)         设置此通道的文件位置
FileChannel truncate(long s)         将此通道的文件截取为给定大小

💡 FileChannel特有的抽象方法
void force(boolean metaData)         强制将通道中(内存中)缓存的未写入的数据写入到文件中(存储设备中)
# 转移管道文件数据
long transferFrom(ReadableByteChannel src, long position, long count)
long transferTo(long position, long count, WritableByteChannel target)
常用方法 SocketChannel

状态

   | ------------- | --- finishConnect --- | -------
  Open      ConnectionPending          Connected

socketChannel.isOpen();                      通道是否打开
socketChannel.isConnectionPending();         通道是否正在连接
socketChannel.isConnected();                 通道是否完成连接
socketChannel.finishConnect();               执行连接,返回是否完成连接
常用方法 ServerSocketChannel

ServerSocketChannel是一个基于通道的socket监听器。它同我们所熟悉的java.net.ServerSocket执行相同的基本任务,不过它增加了通道语义,因此能够在非阻塞模式下运行。

💡 静态方法
ServerSocketChannel open()                        获取实例只能通过此方法
ServerSocketChannel bind(SocketAddress local)     
SocketChannel accept()                            通过tcp三次握手建立连接通道
坑💣: finishConnect的作用
  1. 阻塞模式下,调用 connect() 进行连接操作时,会一直阻塞到连接建立完成(无连接异常的情况下)。所以可以不用 finishConnect() 来确认。

  2. 非阻塞模式下,connect() 操作是调用后直接返回结果的,有可能是true(如本地连接),也可能是false(在部分情况下是false)。所以为了确定后续IO操作正常进行需等待连接的建立,这时finishConnect() 就可以阻塞到连接建立好

    ⚠️ 注意

    需要先进行 connect() 后才能调f inishConnect(),如果直接调用 finishConnect() 会出现NoConnectionPendingException异常。

坑💣: UTF8中文乱码

参考: https://www.jianshu.com/p/1170ee2ff5e2

因为,nio中channel数据读入buffer是固定长度的,所以遇到变长度的UTF8字符不可避免的会出现字符截断,从而导致乱码的出现。

这种情况jdk也考虑到了,提供CharsetDecoder类进行处理,但是具体和Buffer的整合还是有待考究的:

package com.lawsssscat.learn.utils;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;

public class ByteBufferReadHelper {

	private ByteBufferReadHelper() {
	}

	public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;

	public static final int DEFAULT_BUFFER_SIZE = 10;

	public static String read(ReadableByteChannel channel) throws IOException {
		CharsetDecoder decoder = DEFAULT_CHARSET.newDecoder(); // 线程不安全
		ByteBuffer byteBuffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE);
		CharBuffer charBuffer = CharBuffer.allocate(DEFAULT_BUFFER_SIZE);
		StringBuilder sb = new StringBuilder();
		int len = 0;
		while ((len = channel.read(byteBuffer)) >= 0) {
			byteBuffer.flip();
			if (len > 0) {
				decoder.decode(byteBuffer, charBuffer, false);
			} else {
				decoder.decode(byteBuffer, charBuffer, true);
			}
			charBuffer.flip();
//			logger.info("append: %s %s", byteBuffer, charBuffer);
			sb.append(charBuffer);
			charBuffer.clear();
			byteBuffer.compact(); // ⚠️是compact
			if (len == 0) {
				break;
			}
		}
		if (len < 0) {
			throw new ClosedChannelException();
		}
		return sb.toString();
	}

}

使用

key       SelectionKey
channel   ReadableByteChannel 

try {
	String msg = ByteBufferReadHelper.read(channel);
	// use msg....
	logger.info("server: " + msg);
} catch (ClosedChannelException e) {
	key.cancel();
	throw e;
}
NIO核心三: 选择器(Selector)

选择器(Selector)是SelectableChannel对象的多路复用器,Selector可以同时监控多个SelectableChannel的IO状况。利用Selector可以使一个单独的线程管理多个Channel。

在这里插入图片描述

💡 提示

  • 多个Channel以事件的方式可以注册到同一个Selector,Selector能够检测多个注册的Channel上是否有事件发生。
  • 如果有事件发生,便获取事件,然后针对每一个事件进行相应的处理。

💡 提示

性能优化:

  • 只有在连接/通道真正有读写事件发生时,才会进行读写,而不必为每个连接都专门创建一个线程进行读写的监听
  • 单线程管理多个Channel,避免了多线程之间的上下文切换导致的开销
常用方法 Selector
获取选择器
Selector selector = Selector.open();

int selector.select()                   阻塞等待获取(至少一个)事件,返回事件数量
int selector.select(long timeout)       同上,设置超时时间
int selector.selectNow()                非阻塞获取事件,返回事件数量

获取的事件,返回遍历器(IteratorIterator<SelectionKey> it = selector.selectedKeys().iterator();
键对象 SelectionKey

每个Channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey将Channel与Selector建立关系,并维护channel事件。

💡 提示: cancel 和 isValid

调用SelectionKey.cancel()方法可以取消注册到selector中的键(SelectionKey),取消的键不会立即从selector中移除,而是添加到cancelledKeys中,在下一次select操作时移除它
所以在调用某个key时,需要使用Selectionkey.isValid()方法进行校验.

  • isValid: 查看SelectionKey是否有效。只要没有调用cancel(),它就是有效的。

事件

SelectionKey.OP_READ        读: 读缓冲区有数据可读。
SelectionKey.OP_WRITE       写: 写缓冲区有空闲空间时就绪。
⚠️ 注意
一般情况下写缓冲区都有空闲空间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不断就绪浪费CPU

💡 服务端专用
SelectionKey.OP_ACCEPT      接收: 接收到一个客户端连接请求时触发,然后就判断是否建立连接了。该操作只给服务器使用。

💡 客户端专用
SelectionKey.OP_CONNECT     连接: 当SocketChannel.connect()连接成功后触发,表示连接建立。该操作只给客户端使用。

事件判断

isReadable()        a channel is ready for reading
isWritable()        a channel is ready for writing
isConnectable()     a connection was established with a remote server.
isAcceptable()      a connection was accepted by a ServerSocketChannel.

💡 上述方法都与使用特定掩码来测试 readyOps( )方法的结果的效果相同
例如:
if (key.isWritable())
等价于:&位与)
if ((key.readyOps() & SelectionKey.OP_WRITE) != 0)

其他:
1. isValid() 这个判断其实不是事件判断,仅仅是判断SelectionKey是否已经关闭(close)

💡 获取可操作的 Channel
如果 select()方法返回值表示有多个 Channel 准备好了, 那么我们可以通过 Selected key set 访问这个 Channel:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
    /* ⚠️ 注意
     在每次迭代时, 我们都调用 "keyIterator.remove()" 将这个 key 从迭代器中删除
     因为 select() 方法仅仅是简单地将就绪的 IO 操作放到 selectedKeys 集合中
     因此如果我们从 selectedKeys 获取到一个 key, 但是没有将它删除, 那么下一次 select 时, 这个 key 所对应的 IO 事件还在 selectedKeys 中
	*/
}

💡 我们可以动态更改 SekectedKeys 中的 key 的 interest set.
如:
key.interestOps(OP_READ | SelectionKey.OP_WRITE);

其他方法

💡 Attaching Object
+ 我们可以在selectionKey中附加一个对象:
	selectionKey.attach(theObject);
	Object attachedObj = selectionKey.attachment();
+ 或者在注册时直接附加:
	SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
坑💣: OP_WRITE事件(触发条件、使用场景)

⚠️OP_WRITE事件的触发并不是发生在调用channel的write方法之后,而是在底层缓冲区有空闲空间时。

⚡ 因为写缓冲区在绝大部分时候都是有空闲空间的,所以如果你注册了写事件,这会使得写事件一直处于就就绪,选择处理现场就会一直占用着CPU资源。

所以,只有当你确实有数据要写时再注册写操作,并在写完以后马上取消注册。

if (key.isReadable()) {
    SocketChannel clientChannel = (SocketChannel) key.channel();
    ByteBuffer buf = (ByteBuffer) key.attachment();
    long bytesRead = clientChannel.read(buf);
    if (bytesRead == -1) {
        clientChannel.close();
    } else if (bytesRead > 0) {
        key.interestOps(OP_READ | SelectionKey.OP_WRITE);
        System.out.println("Get data length: " + bytesRead);
    }
}

if (key.isValid() && key.isWritable()) {
    ByteBuffer buf = (ByteBuffer) key.attachment();
    buf.flip();
    SocketChannel clientChannel = (SocketChannel) key.channel();

    clientChannel.write(buf);

    if (!buf.hasRemaining()) {
        key.interestOps(OP_READ);
    }
    buf.compact();
}

其实,在大部分情况下,我们直接调用channel的write方法写数据就好了,没必要都用OP_WRITE事件。那么OP_WRITE事件主要是在什么情况下使用的了?
其实OP_WRITE事件主要是在发送缓冲区空间满的情况下使用的。如:

while (buffer.hasRemaining()) {
     int len = socketChannel.write(buffer);   
     if (len == 0) {
          selectionKey.interestOps(selectionKey.interestOps() | SelectionKey.OP_WRITE);
          selector.wakeup();
          break;
     }
}
  • 当buffer还有数据,但缓冲区已经满的情况下,socketChannel.write(buffer)会返回已经写出去的字节数,此时为0。那么这个时候我们就需要注册OP_WRITE事件
  • 这样当缓冲区又有空闲空间的时候就会触发OP_WRITE事件,这是我们就可以继续将没写完的数据继续写出了。
  • 而且在写完后,一定要记得将OP_WRITE事件注销:
    selectionKey.interestOps(sk.interestOps() & ~SelectionKey.OP_WRITE);
坑💣: 修改selectionKey的interest后,需要调用wakeup()

selectionKey的interest是在每次selector.select()操作的时候注册到系统进行监听的,所以在selector.select()调用之后修改的interest需要在下一次selector.select()调用才会生效。

这里在修改了interest之后调用了wakeup();方法是为了唤醒被堵塞的selector方法,这样当while中判断selector返回的是0时,会再次调用selector.select()

坑💣: 唤醒select的方法 wakeup

有三种方式可以唤醒在 selector.select()方法 中睡眠的线程。

  1. 调用 selector.wakeup()延迟唤醒
    • 将使得选择器上的第一个还没有返回的选择操作立即返回。
    • 如果当前没有在进行中的选择,那么下一次对 select()方法 的调用将立即返回。后续的选择操作将正常进行。

      💡如果这种延迟的唤醒行为不是你想要的(你想要立刻唤醒),可以在调用 wakeup()方法 后立刻调用 selectNow()方法

    • 在选择操作之间多次调用 wakeup()方法 与调用它一次没有什么不同。
  2. 调用 selector.close()
    • 任何一个在选择操作中阻塞的线程都将被唤醒,就像wakeup( )方法被调用了一样。与选择器相关的通道将被注销,而键将被取消。
  3. 调用 thread.interrupt()
    • 如果睡眠中的线程的 interrupt()方法 被调用,它的返回状态将被设置。

      ⚠️注意

      如果被唤醒的线程之后将试图在通道上执行 I/O 操作,通道将立即关闭,然后线程将捕捉到一个异常。
      因此,应该使用 wakeup()方法 优雅地将一个在 select()方法 中睡眠的线程唤醒。而不是调用 interrupt()方法

应用方法(服务端) ⭐️

测试代码: https://github.com/LawssssCat/learn-io/blob/nio-selector-api-test2/src/test/java/com/lawsssscat/learn/normal/NIOTest.java#L206-L240

服务端代码: https://github.com/LawssssCat/learn-io/blob/nio-selector-api-test2/src/main/java/com/lawsssscat/learn/normal/NIOServer.java

创建 Selector: 通过调用 Selector.open()方法 创建

Selector selector = selector.open();

向选择器注册通道:

// 1. 获取通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 2. 切换非阻塞模式
serverChannel.configureBlocking(false);
// 3. 绑定连接
serverChannel.bind(new InetSocketAddress(9898));
// 4. 获取选择器
Selector selector = Selector.open();
// 5. 将通道注册到选择器上,并且指定“监听接收事件”
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

💡 提示

当调用 register(Selector sel, int ops) 将通道注册选择器时,选择器对通道的监听事件需要通过第二个参数ops指定。 可以监听的事件类型:

  • 读: SelectionKey.OP_READ
  • 写: SelectionKey.OP_WRITE
  • 连接: SelectionKey.OP_CONNECT
  • 接收: SelectionKey.OP_ACCEPT
  • 如果注册时不止监听一个事件,则可以使用“位或”操作符连接
    // 监听读、写
    int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE
    

轮询式的获取选择器上已经“准备就绪”的事件

while(selector.select() > 0) {
	Iterator<SelectionKey> it = selector.selectedKeys().iterator();
	while(it.hashNext()) {
		// 获取准备“就绪”的事件
		SelectionKey key = it.next();
		SocketChannel channel = null;
		try {
			// 具体判断事件类型
			if(key.isAcceptable()) {
				// “接收就绪”
				/* ⚠️注意
					+ 在 OP_ACCEPT 事件中, 从 key.channel() 返回的 Channel 是 ServerSocketChannel.
					+ 而在 OP_WRITE 和 OP_READ 中, 从 key.channel() 返回的是 SocketChannel.
				*/
				SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
				// channel  = serverChannel.accept();
				channel.configureBlocking(false); // 切换非阻塞模式
				// ⚠️注意: 如果没有设置OP_READ, 默认interest set是OP_CONNECT, 那么select方法会一直直接返回
				channel.register(selector, SelectionKey.OP_READ); // 💡非阻塞特点: 只需声明监听,而无须专门创建一个线程进行阻塞监听
			} else if(key.isReadable()) {
				// “读就绪”
				hannel = (SocketChannel) key.channel();
				ByteBuffer buffer = ByteBuffer.allocate(1024);
				while(true) {
					int len = channel.read(buffer)
					if(len > 0) {
						buffer.flip();
						String msg = new String(buffer.array(), 0, buffer.remaining());
						buffer.clear();
					} else {
						// ⚠️ 这里必须关闭channel,否则会死循环
						// 💡 当客户端调用close()方法,这里会收到-1的长度,【并且读事件不断产生】
						if(len < 0) {
							channel.close();
						}
						break;
					}
				}
			}
		} catch(Throwable e) {
			e.printStackTrace();
			// ⚠️ 这里必须捕获IOException,然后将channel关闭,否则会死循环
			// 💡 如果客户端异常断开,会袍笏IOException,【并且读事件会不断产生】
			if(channel != null) {
				channel.close();
			}
		}
		// 取消选择键SelectionKey
		it.remove();
	}
}
坑💣:客户端关闭,服务端循环读问题

因为 SocketChannel 的 read 有下面几种返回值情况:

  • >0(大于零): 正常情况
  • =0: 表示读结束
  • <0: 客户端关闭通道
  • IO异常: 客户端通道异常关闭(💡抛出异常的连接如果不关闭,将一直处于可读状态)

所以,下面这种写法是不对的: (会让服务端陷入死循环)

while(selector.select() > 0) {
	Iterator<SelectionKey> it = selector.selectedKeys().iterator();
	try {
		while(it.hashNext()) {
			// 获取准备“就绪”的事件
			SelectionKey key = it.next();
			// 具体判断事件类型
			if(key.isAcceptable()) {
				// “接收就绪”
				SocketChannel channel = serverChannel.accept();
				channel.configureBlocking(false); // 切换非阻塞模式
				channel.register(selector, SelectionKey.OP_READ);
			} else if(key.isReadable()) {
				// “读就绪”
				SocketChannel channel = (SocketChannel) key.channel();
				ByteBuffer buffer = ByteBuffer.allocate(1024);
	
				// 💡 区别 ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
				while(channel.read(buffer)>0) {
					buffer.flip();
					String msg = new String(buffer.array(), 0, buffer.remaining());
					buffer.clear();
				}
			}
		} catch(Throwable e) {
			e.printStackTrace();
			// 💡 区别 ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
			// 没有关闭 channel
		}
		// 💡 这里remove也是必须的,甚至可以提前
		// 取消选择键SelectionKey
		it.remove();
	}
}
坑💣: 关闭channel需要用channel.close()方法,不能用SelectionKey.cancel()方法

关闭channel需要用channel.close()方法,如果只是通过调用SelectionKey.cancel()来注销是有问题的

参考: https://www.jianshu.com/p/1af407c043cb

因为selector.select()在处理cancel selectionKey set(注销的SelectionKey集合)的时候,会判断若该SelectionKey对应的channel已经没有注册到其他的selector,并且该channel open表示为false的情况下,才会去调用底层套接字的关闭操作。所以如果之调用SelectionKey.cancel()来注销一个远端已经关闭了的channel,会导致本段的TCP连接处于“CLOSE_WAIT”状态,一直在等待程序调用套接字的关闭。
补充:channel的open标志,只有在下面两种情况下才会将open置为false。
a) 调用了channel.close()方法;

应用方法(客户端) ⭐️

测试代码: https://github.com/LawssssCat/learn-io/blob/nio-selector-api-test/src/test/java/com/lawsssscat/learn/normal/NIOTest.java#L200-L234

// 获取通道
// ❌ SocketChannel channel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9999));
// ❌ channel.configureBlocking(false);
SocketChannel channel = SocketChannel.open(); // ✔️
// 切换非阻塞模式
channel.configureBlocking(false); // ✔️
// ⚠️ channel发起connect请求之前,最好设置非阻塞。如果注册了selector,这样可以监听到OP_CONNECT事件
channel.connect(new InetSocketAddress("127.0.0.1", 9999)); // ✔️ tcp三次握手
// 分配指定大小的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 发送数据给服务端
Scanner scan = new Scanner(System.in);
while(scan.hasNext()) {
	String str = scan.nextLine();
	buffer.put(str.getBytes());
	buffer.filp();
	channel.write(buffer)
	buffer.clear();
}
// 关闭通道
channel.close();
案例1: 简单NIO服务器(回显功能) ⭐️
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectableChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import javax.swing.text.html.HTMLDocument.Iterator;
/**
* Simple echo-back server which listens for incoming stream connections and
* echoes back whatever it reads. A single Selector object is used to listen to
* the server socket (to accept new connections) and all the active socket
* channels.
* @author zale (zalezone.cn)
*/
public class SelectSockets {
    public static int PORT_NUMBER = 1234;
    public static void main(String[] argv) throws Exception 
    {
        new SelectSockets().go(argv);
    }
    public void go(String[] argv) throws Exception 
    {
        int port = PORT_NUMBER;
        if (argv.length > 0) 
        { // 覆盖默认的监听端口
            port = Integer.parseInt(argv[0]);
        }
        System.out.println("Listening on port " + port);
        ServerSocketChannel serverChannel = ServerSocketChannel.open();// 打开一个未绑定的serversocketchannel
        ServerSocket serverSocket = serverChannel.socket();// 得到一个ServerSocket去和它绑定    
        Selector selector = Selector.open();// 创建一个Selector供下面使用
        serverSocket.bind(new InetSocketAddress(port));//设置server channel将会监听的端口
        serverChannel.configureBlocking(false);//设置非阻塞模式
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);//将ServerSocketChannel注册到Selector
        while (true) 
        {
            // This may block for a long time. Upon returning, the
            // selected set contains keys of the ready channels.
            int n = selector.select();
            if (n == 0) 
            {
                continue; // nothing to do
            }            
            java.util.Iterator<SelectionKey> it = selector.selectedKeys().iterator();// Get an iterator over the set of selected keys
            //在被选择的set中遍历全部的key
            while (it.hasNext()) 
            {
                SelectionKey key = (SelectionKey) it.next();
                // 判断是否是一个连接到来
                if (key.isAcceptable()) 
                {
                    ServerSocketChannel server =(ServerSocketChannel) key.channel();
                    SocketChannel channel = server.accept();
                    registerChannel(selector, channel,SelectionKey.OP_READ);//注册读事件
                    sayHello(channel);//对连接进行处理
                }
                //判断这个channel上是否有数据要读
                if (key.isReadable()) 
                {
                    readDataFromSocket(key);
                }
                //从selected set中移除这个key,因为它已经被处理过了
                it.remove();
            }
        }
    }
    // ----------------------------------------------------------
    /**
    * Register the given channel with the given selector for the given
    * operations of interest
    */
    protected void registerChannel(Selector selector,SelectableChannel channel, int ops) throws Exception
    {
        if (channel == null) 
        {
            return; // 可能会发生
        }
        // 设置通道为非阻塞
        channel.configureBlocking(false);
        // 将通道注册到选择器上
        channel.register(selector, ops);
    }
    // ----------------------------------------------------------
    // Use the same byte buffer for all channels. A single thread is
    // servicing all the channels, so no danger of concurrent acccess.
    //对所有的通道使用相同的缓冲区。单线程为所有的通道进行服务,所以并发访问没有风险
    private ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    /**
    * Sample data handler method for a channel with data ready to read.
    * 对于一个准备读入数据的通道的简单的数据处理方法
    * @param key
    *
    A SelectionKey object associated with a channel determined by
    the selector to be ready for reading. If the channel returns
    an EOF condition, it is closed here, which automatically
    invalidates the associated key. The selector will then
    de-register the channel on the next select call.
    
    一个选择器决定了和通道关联的SelectionKey object是准备读状态。如果通道返回EOF,通道将被关闭。
    并且会自动使相关的key失效,选择器然后会在下一次的select call时取消掉通道的注册
    */
    protected void readDataFromSocket(SelectionKey key) throws Exception 
    {
        SocketChannel socketChannel = (SocketChannel) key.channel();
        int count;
        buffer.clear(); // 清空Buffer
        // Loop while data is available; channel is nonblocking
        //当可以读到数据时一直循环,通道为非阻塞
        while ((count = socketChannel.read(buffer)) > 0) 
        {
            buffer.flip(); // 将缓冲区置为可读
            // Send the data; don't assume it goes all at once
            //发送数据,不要期望能一次将数据发送完
            while (buffer.hasRemaining()) 
            {
                socketChannel.write(buffer);
            }
            // WARNING: the above loop is evil. Because
            // it's writing back to the same nonblocking
            // channel it read the data from, this code can
            // potentially spin in a busy loop. In real life
            // you'd do something more useful than this.
            //这里的循环是无意义的,具体按实际情况而定
            buffer.clear(); // Empty buffer
        }
        if (count < 0) 
        {
            // Close channel on EOF, invalidates the key
            //读取结束后关闭通道,使key失效
            socketChannel.close();
        }
    }
    // ----------------------------------------------------------
    /**
    * Spew a greeting to the incoming client connection.
    *
    * @param channel
    *
    The newly connected SocketChannel to say hello to.
    */
    private void sayHello(SocketChannel channel) throws Exception 
    {
        buffer.clear();
        buffer.put("Hi there!".getBytes());
        buffer.flip();
        channel.write(buffer);
    }
}
案例2: 案例1的线程池优化 ⭐️
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
import java.util.List;
/**
* Specialization of the SelectSockets class which uses a thread pool to service
* channels. The thread pool is an ad-hoc implementation quicky lashed togther
* in a few hours for demonstration purposes. It's definitely not production
* quality.
*
* @author Ron Hitchens (ron@ronsoft.com)
*/
public class SelectSocketsThreadPool extends SelectSockets 
{
    private static final int MAX_THREADS = 5;
    private ThreadPool pool = new ThreadPool(MAX_THREADS);
    // -------------------------------------------------------------
    public static void main(String[] argv) throws Exception 
    {
        new SelectSocketsThreadPool().go(argv);
    }
    // -------------------------------------------------------------
    /**
    * Sample data handler method for a channel with data ready to read. This
    * method is invoked from(被调用) the go( ) method in the parent class. This handler
    * delegates(委托) to a worker thread in a thread pool to service the channel,
    * then returns immediately.
    *
    * @param key
    *
    A SelectionKey object representing a channel determined by the
    *
    selector to be ready for reading. If the channel returns an
    *
    EOF condition, it is closed here, which automatically
    *
    invalidates the associated key. The selector will then
    *
    de-register the channel on the next select call.
    */
    @Override
    protected void readDataFromSocket(SelectionKey key) throws Exception 
    {
        WorkerThread worker = pool.getWorker();
        if (worker == null) 
        {
            // No threads available. Do nothing. The selection
            // loop will keep calling this method until a
            // thread becomes available. This design could
            // be improved.
            return;
        }
        // Invoking this wakes up the worker thread, then returns
        worker.serviceChannel(key);
    }
    // ---------------------------------------------------------------
    /**
    * A very simple thread pool class. The pool size is set at construction
    * time and remains fixed. Threads are cycled through a FIFO idle queue.
    */
    private class ThreadPool
    {
        List idle = new LinkedList();
        ThreadPool(int poolSize) 
        {
            // Fill up the pool with worker threads
            for (int i = 0; i < poolSize; i++)
            {
                WorkerThread thread = new WorkerThread(this);
                // Set thread name for debugging. Start it.
                thread.setName("Worker" + (i + 1));
                thread.start();
                idle.add(thread);
            }
        }
        /**
        * Find an idle worker thread, if any. Could return null.
        */
        WorkerThread getWorker() 
        {
            WorkerThread worker = null;
            synchronized (idle) 
            {
                if (idle.size() > 0) 
                {
                    worker = (WorkerThread) idle.remove(0);
                }
            }
            return (worker);
        }
        /**
        * Called by the worker thread to return itself to the idle pool.
        */
        void returnWorker(WorkerThread worker) 
        {
            synchronized (idle) 
            {
                idle.add(worker);
            }
        }
    }
    /**
    * A worker thread class which can drain(排空) channels and echo-back(回显) the input.
    * Each instance is constructed with a reference(参考) to the owning thread pool
    * object. When started, the thread loops forever waiting to be awakened to
    * service the channel associated with a SelectionKey object. The worker is
    * tasked by calling its serviceChannel( ) method with a SelectionKey
    * object. The serviceChannel( ) method stores the key reference in the
    * thread object then calls notify( ) to wake it up. When the channel has
    * been drained, the worker thread returns itself to its parent pool.
    */
    private class WorkerThread extends Thread 
    {
        private ByteBuffer buffer = ByteBuffer.allocate(1024);
        private ThreadPool pool;
        private SelectionKey key;
        WorkerThread(ThreadPool pool) 
        {
            this.pool = pool;
        }
        // Loop forever waiting for work to do
        public synchronized void run() 
        {
            System.out.println(this.getName() + " is ready");
            while (true) 
            {
                try 
                {
                    // Sleep and release object lock
                    //休眠并且释放掉对象锁
                    this.wait();
                } 
                catch (InterruptedException e) 
                {
                    e.printStackTrace();
                    // Clear interrupt status
                    this.interrupted();
                }
                if (key == null) 
                {
                    continue; // just in case
                }
                System.out.println(this.getName() + " has been awakened");
                try 
                {
                    drainChannel(key);
                } 
                catch (Exception e) 
                {
                    System.out.println("Caught '" + e + "' closing channel");
                    // Close channel and nudge selector
                    try 
                    {
                        key.channel().close();
                    } 
                    catch (IOException ex) 
                    {
                        ex.printStackTrace();
                    }
                    key.selector().wakeup();
                }
                key = null;
                // Done. Ready for more. Return to pool
                this.pool.returnWorker(this);
            }
        }
        /**
        * Called to initiate a unit of work by this worker thread on the
        * provided SelectionKey object. This method is synchronized, as is the
        * run( ) method, so only one key can be serviced at a given time.
        * Before waking the worker thread, and before returning to the main
        * selection loop, this key's interest set is updated to remove OP_READ.
        * This will cause the selector to ignore read-readiness for this
        * channel while the worker thread is servicing it.
        * 通过一个被提供SelectionKey对象的工作线程来初始化一个工作集合,这个方法是同步的,所以
        * 里面的run方法只有一个key能被服务在同一个时间,在唤醒工作线程和返回到主循环之前,这个key的
        * 感兴趣的集合被更新来删除OP_READ,这将会引起工作线程在提供服务的时候选择器会忽略读就绪的通道
        */
        synchronized void serviceChannel(SelectionKey key) 
        {
            this.key = key;
            key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));
            this.notify(); // Awaken the thread
        }
        /**
        * The actual code which drains the channel associated with the given
        * key. This method assumes the key has been modified prior to
        * invocation to turn off selection interest in OP_READ. When this
        * method completes it re-enables OP_READ and calls wakeup( ) on the
        * selector so the selector will resume watching this channel.
        */
        void drainChannel(SelectionKey key) throws Exception 
        {
            SocketChannel channel = (SocketChannel) key.channel();
            int count;
            buffer.clear(); // 清空buffer
            // Loop while data is available; channel is nonblocking
            while ((count = channel.read(buffer)) > 0)
            {
                buffer.flip(); // make buffer readable
                // Send the data; may not go all at once
                while (buffer.hasRemaining()) 
                {
                    channel.write(buffer);
                }
                // WARNING: the above loop is evil.
                // See comments in superclass.
                buffer.clear(); // Empty buffer
            }
            if (count < 0) 
            {
                // Close channel on EOF; invalidates the key
                channel.close();
                return;
            }
            // Resume interest in OP_READ
            key.interestOps(key.interestOps() | SelectionKey.OP_READ);
            // Cycle the selector so this key is active again
            key.selector().wakeup();
        }
    }
}
案例3: 端口转发(群聊系统) ⭐️⭐️

测试代码: https://github.com/LawssssCat/learn-io/blob/nio-echo-server/src/test/java/com/lawsssscat/learn/chat/NIOChatTest.java

  • NIO群聊系统,实现客户端与客户端的通信
  • 服务端: 检测用户上限、离线、消息转发
  • 客户端: 发送消息给服务端、接收服务端消息
# AIO(Async I/O)

AIO(NIO2): 异步、非阻塞(基于NIO)(JDK7开始支持)

服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS完成了再通知服务器应用去启动线程进行处理,一般适用于连接数较多且连接时间较长的应用

💡 提示

与NIO不同,当进行读写操作时,只需直接调用API的read或write方法既可,这两种方法都是异步的:

  • 对于读操作而言,当有可读取流时,操作系统会将可读的流传入read方法的缓冲区
  • 对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序

即可以理解为: read/write方法都是异步的,完成后会主动调用回调函数。

💡 接口名称对比

平台BIONIOAIO
客户端SocketSocketChannelAsynchronousSocketChannel
服务端ServerSocketServerSocketChannelAsynchronousServerSocketChannel

适用场景

  1. 连接数目多且连接时间长(重操作),比如:相册服务器、充分调用OS参与并发操作
  2. JDK7开始支持(编成比较复杂)
新增通道(channel)

在JDK1.7中,这部分内容被称作NIO.2,主要在java.nio.channels包下增加了下面四个异步通道:

  1. AsynchronousSocketChannel
  2. AsynchronousServerSocketChannel
  3. AsynchronousFileChannel
  4. AsynchronousDatagramChannel
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

骆言

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值