Java NIO 和 AIO 总结


title: Java NIO 和 AIO 总结
date: 2023-05-10 13:21:26
tags:

  • NIO
  • AIO
    categories:
  • 开发知识及其他
    cover: https://cover.png
    feature: false

1. NIO

Java NIO (New IO) is an alternative IO API for Java, meaning alternative to the standard Java IO and Java Networking API’s. Java NIO offers a different IO programming model than the traditional IO APIs. Note: Sometimes NIO is claimed to mean Non-blocking IO. However, this is not what NIO meant originally. Also, parts of the NIO APIs are actually blocking - e.g. the file APIs - so the label “Non-blocking” would be slightly misleading.

Java NIO(新 IO)是 Java 的替代 IO API,意思是标准 Java IO 和 Java Networking API 的替代。 Java NIO 提供了与传统 IO API 不同的 IO 编程模型。注意:有时 NIO 声称是指非阻塞 IO。然而,这并不是 NIO 最初的意思。此外,部分 NIO API 实际上是阻塞的——例如文件 API - 所以标签“非阻塞”会有点误导

原文链接:Java NIO Tutorial (jenkov.com)

Java NIO 中有三大组件 Buffer、Channel、Selector

1.1 Buffer

一个 Buffer 本质上是内存中的一块,我们可以将数据写入这块内存,之后从这块内存获取数据

java.nio 定义了以下几个 Buffer 的实现

其实核心是最后的 ByteBuffer,前面的一大串类只是包装了一下它而已,我们使用最多的通常也是 ByteBuffer

我们应该将 Buffer 理解为一个数组,IntBuffer、CharBuffer、DoubleBuffer 等分别对应 int[]、char[]、double[] 等,MappedByteBuffer 用于实现内存映射文件 操作 Buffer 和操作数组、类集差不多,只不过大部分时候我们都把它放到了 NIO 的场景里面来使用而已

下面介绍 Buffer 中的几个重要属性和几个重要方法

1.1.1 position、limit、capacity

就像数组有数组容量,每次访问元素要指定下标,Buffer 中也有几个重要属性:position、limit、capacity

最好理解的当然是 capacity,它代表这个缓冲区的容量,一旦设定就不可以更改。比如 capacity 为 1024 的 IntBuffer,代表其一次可以存放 1024 个 int 类型的值。一旦 Buffer 的容量达到 capacity,需要清空 Buffer,才能重新写入值

position 和 limit 是变化的,我们分别看下读和写操作下,它们是如何变化的

position 的初始值是 0,每往 Buffer 中写入一个值,position 就自动加 1,代表下一次的写入位置。读操作的时候也是类似的,每读一个值,position 就自动加 1 从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了

Limit:写操作模式下,limit 代表的是最大能写入的数据,这个时候 limit 等于 capacity。写结束后,切换到读模式,此时的 limit 等于 Buffer 中实际的数据大小,因为 Buffer 不一定被写满了

1.1.2 初始化 Buffer

每个 Buffer 实现类都提供了一个静态方法 allocate(int capacity) 帮助我们快速实例化一个 Buffer。如:

ByteBuffer byteBuf = ByteBuffer.allocate(1024);
IntBuffer intBuf = IntBuffer.allocate(1024);
LongBuffer longBuf = LongBuffer.allocate(1024);
// ...

另外,我们经常使用 wrap 方法来初始化一个 Buffer

public static ByteBuffer wrap(byte[] array) {
    ...
}

1.1.3 填充 Buffer

各个 Buffer 类都提供了一些 put 方法用于将数据填充到 Buffer 中,如 ByteBuffer 中的几个 put 方法:

// 填充一个 byte 值
public abstract ByteBuffer put(byte b);
// 在指定位置填充一个 int 值
public abstract ByteBuffer put(int index, byte b);
// 将一个数组中的值填充进去
public final ByteBuffer put(byte[] src) {...}
public ByteBuffer put(byte[] src, int offset, int length) {...}

上述这些方法需要自己控制 Buffer 大小,不能超过 capacity,超过会抛 java.nio.BufferOverflowException 异常

对于 Buffer 来说,另一个常见的操作中就是,我们要将来自 Channel 的数据填充到 Buffer 中,在系统层面上,这个操作我们称为读操作,因为数据是从外部(文件或网络等)读到内存中

int num = channel.read(buf);

上述方法会返回从 Channel 中读入到 Buffer 的数据大小

1.1.4 提取 Buffer 中的值

前面介绍了写操作,每写入一个值,position 的值都需要加 1,所以 position 最后会指向最后一次写入的位置的后面一个,如果 Buffer 写满了,那么 position 等于 capacity(position 从 0 开始)

如果要读 Buffer 中的值,需要切换模式,从写入模式切换到读出模式。注意,通常在说 NIO 的读操作的时候,我们说的是从 Channel 中读数据到 Buffer 中,对应的是对 Buffer 的写入操作

调用 Buffer 的 flip() 方法,可以从写入模式切换到读取模式。其实这个方法也就是设置了一下 position 和 limit 值罢了

public final Buffer flip() {
    limit = position; // 将 limit 设置为实际写入的数据数量
    position = 0; // 重置 position 为 0
    mark = -1; // mark 之后再说
    return this;
}

对应写入操作的一系列 put 方法,读操作提供了一系列的 get 方法:

// 根据 position 来获取数据
public abstract byte get();
// 获取指定位置的数据
public abstract byte get(int index);
// 将 Buffer 中的数据写入到数组中
public ByteBuffer get(byte[] dst)

附一个经常使用的方法:

new String(buffer.array()).trim();

当然了,除了将数据从 Buffer 取出来使用,更常见的操作是将我们写入的数据传输到 Channel 中,如通过 FileChannel 将数据写入到文件中,通过 SocketChannel 将数据写入网络发送到远程机器等。对应的,这种操作,我们称之为写操作

int num = channel.write(buf);

1.1.5 mark() & reset()

除了 position、limit、capacity 这三个基本的属性外,还有一个常用的属性就是 mark

mark 用于临时保存 position 的值,每次调用 mark() 方法都会将 mark 设值为当前的 position,便于后续需要的时候使用

public final Buffer mark() {
    mark = position;
    return this;
}

那到底什么时候用呢?考虑以下场景,我们在 position 为 5 的时候,先 mark() 一下,然后继续往下读,读到第 10 的时候,我想重新回到 position 为 5 的地方重新来一遍,那只要调一下 reset() 方法,position 就回到 5 了

public final Buffer reset() {
    int m = mark;
    if (m < 0)
        throw new InvalidMarkException();
    position = m;
    return this;
}

1.1.6 rewind() & clear() & compact()

rewind():会重置 position 为 0,通常用于重新从头读写 Buffer

public final Buffer rewind() {
    position = 0;
    mark = -1;
    return this;
}

clear():有点重置 Buffer 的意思,相当于重新实例化 通常,我们会先填充 Buffer,然后从 Buffer 读取数据,之后我们再重新往里填充新的数据,我们一般在重新填充之前先调用 clear()

public final Buffer clear() {
    position = 0;
    limit = capacity;
    mark = -1;
    return this;
}

compact():和 clear() 一样的是,它们都是在准备往 Buffer 填充新的数据之前调用 前面说的 clear() 方法会重置几个属性,但是我们要看到,clear() 方法并不会将 Buffer 中的数据清空,只不过后续的写入会覆盖掉原来的数据,也就相当于清空了数据了

compact() 方法有点不一样,调用这个方法以后,会先处理还没有读取的数据,也就是 position 到 limit 之间的数据(还没有读过的数据),先将这些数据移到左边,然后在这个基础上再开始写入。很明显,此时 limit 还是等于 capacity,position 指向原来数据的右边

1.2 Channel

所有的 NIO 操作始于通道,通道是数据来源或数据写入的目的地,主要地,我们将关心 java.nio 包中实现的以下几个 Channel:

  • FileChannel:文件通道,用于文件的读和写
  • DatagramChannel:用于 UDP 连接的接收和发送
  • SocketChannel:把它理解为 TCP 连接通道,简单理解就是 TCP 客户端
  • ServerSocketChannel:TCP 对应的服务端,用于监听某个端口进来的请求

这里最应该关注,同样也是重点的是 SocketChannel 和 ServerSocketChannel

Channel 经常翻译为通道,类似 IO 中的流,用于读取和写入。它与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n9ksT9gJ-1689582759656)(http://img.fan223.cn/2023/05/20230509111347.png)]

1.2.1 FileChannel

文件操作对于大家来说应该是最熟悉的,不过我们在说 NIO 的时候,其实 FileChannel 并不是关注的重点。而且后面我们说非阻塞的时候会看到,FileChannel 是不支持非阻塞的

初始化:

FileInputStream inputStream = new FileInputStream(new File("/data.txt"));
FileChannel fileChannel = inputStream.getChannel();

当然,我们也可以从 RandomAccessFile#getChannel 来得到 FileChannel

读取文件内容:

ByteBuffer buffer = ByteBuffer.allocate(1024);

int num = fileChannel.read(buffer);

写入文件内容:

ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("随机写入一些内容到 Buffer 中".getBytes());
// Buffer 切换为读模式
buffer.flip();
while(buffer.hasRemaining()) {
    
    
    // 将 Buffer 中的内容写入文件
    fileChannel.write(buffer);
}

1.2.2 SocketChannel

前面说过,我们可以将 SocketChannel 理解成一个 TCP 客户端。虽然这么理解有点狭隘,因为我们在介绍 ServerSocketChannel 的时候会看到另一种使用方式

打开一个 TCP 连接:

SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("https://www.baidu.com", 80));

当然了,上面的这行代码等价于下面的两行:

// 打开一个通道
SocketChannel socketChannel = SocketChannel.open();
// 发起连接
socketChannel.connect(new InetSocketAddress("https://www.baidu.com", 80));

SocketChannel 的读写和 FileChannel 没什么区别,就是操作缓冲区

// 读取数据
socketChannel.read(buffer);

// 写入数据到网络连接中
while(buffer.hasRemaining()) {
    
    
    socketChannel.write(buffer);   
}

1.2.3 ServerSocketChannel

之前说 SocketChannel 是 TCP 客户端,这里说的 ServerSocketChannel 就是对应的服务端 ServerSocketChannel 用于监听机器端口,管理从这个端口进来的 TCP 连接

// 实例化
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 监听 8080 端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));

while (true) {
    
    
    // 一旦有一个 TCP 连接进来,就对应创建一个 SocketChannel 进行处理
    SocketChannel socketChannel = serverSocketChannel.accept();
}

这里我们可以看到 SocketChannel 的第二个实例化方式,到这里,我们应该能理解 SocketChannel 了,它不仅仅是 TCP 客户端,它代表的是一个网络通道,可读可写

ServerSocketChannel 不和 Buffer 打交道了,因为它并不实际处理数据,它一旦接收到请求后,实例化 SocketChannel,之后在这个连接通道上的数据传递它就不管了,因为它需要继续监听端口,等待下一个连接

1.2.4 DatagramChannel

UDP 和 TCP 不一样,DatagramChannel 一个类处理了服务端和客户端

UDP 是面向无连接的,不需要和对方握手,不需要通知对方,就可以直接将数据包投出去,至于能不能送达,它是不知道的

监听端口:

DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9090));
ByteBuffer buf = ByteBuffer.allocate(48);

channel.receive(buf);

1.3 Selector

Selector 建立在非阻塞的基础之上,大家经常听到的 多路复用 在 Java 世界中指的就是它,用于实现一个线程管理多个 Channel

1、首先,我们开启一个 Selector。翻译成选择器也好,多路复用器也好

Selector selector = Selector.open();

2、将 Channel 注册到 Selector 上。前面我们说了,Selector 建立在非阻塞模式之上,所以注册到 Selector 的 Channel 必须要支持非阻塞模式,FileChannel 不支持非阻塞,我们这里讨论最常见的 SocketChannel 和 ServerSocketChannel

// 将通道设置为非阻塞模式,因为默认都是阻塞模式的
channel.configureBlocking(false);
// 注册
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

register 方法的第二个 int 型参数(使用二进制的标记位)用于表明需要监听哪些感兴趣的事件,共以下四种事件:

  • SelectionKey.OP_READ:对应 00000001,通道中有数据可以进行读取
  • SelectionKey.OP_WRITE:对应 00000100,可以往通道中写入数据
  • SelectionKey.OP_CONNECT:对应 00001000,成功建立 TCP 连接
  • SelectionKey.OP_ACCEPT:对应 00010000,接受 TCP 连接

我们可以同时监听一个 Channel 中的发生的多个事件,比如我们要监听 ACCEPT 和 READ 事件,那么指定参数为二进制的 00010001 即十进制数值 17 即可 注册方法返回值是 SelectionKey 实例,它包含了 Channel 和 Selector 信息,也包括了一个叫做 Interest Set 的信息,即我们设置的我们感兴趣的正在监听的事件集合

3、调用 select() 方法获取通道信息。用于判断是否有我们感兴趣的事件已经发生了 Selector 的操作就是以上 3 步,一个简单的示例如下:

Selector selector = Selector.open();

channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

while(true) {
    
    
  // 判断是否有事件准备好
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;

  // 遍历
  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();
  }
}

对于 Selector,我们还需要非常熟悉以下几个方法:

  • select():调用此方法,会将上次 select 之后的准备好的 channel 对应的 SelectionKey 复制到 selected set 中。如果没有任何通道准备好,这个方法会阻塞,直到至少有一个通道准备好
  • selectNow():功能和 select 一样,区别在于如果没有准备好的通道,那么此方法会立即返回 0
  • select(long timeout):看了前面两个,这个应该很好理解了,如果没有通道准备好,此方法会等待一会
  • wakeup():这个方法是用来唤醒等待在 select()select(timeout) 上的线程的。如果 wakeup() 先被调用,此时没有线程在 select 上阻塞,那么之后的一个 select()select(timeout) 会立即返回,而不会阻塞,当然,它只会作用一次

1.4 小结

到此为止,介绍了 Buffer、Channel 和 Selector 的常见接口

  • Buffer 和数组差不多,它有 position、limit、capacity 几个重要属性。put() 一下数据、flip() 切换到读模式、然后用 get() 获取数据、clear()一下清空数据、重新回到 put() 写入数据
  • Channel 基本上只和 Buffer 打交道,最重要的接口就是 channel.read(buffer)channel.write(buffer)
  • Selector 用于实现非阻塞 IO

2. 阻塞模式 IO

前面已经介绍过使用 Java NIO 包组成一个简单的客户端-服务端网络通讯所需要的 ServerSocketChannel、SocketChannel 和 Buffer,我们这里整合一下它们,给出一个完整的可运行的例子:

1、服务端,即 ServerSocketChannel

@Slf4j
public class ServerSocketChannelTest {
    
    
    public static void main(String[] args) throws IOException {
    
    
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 监听 8080 端口进来的 TCP 链接
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));

        while (true) {
    
    
            // 这里会阻塞,直到有一个请求的连接进来
            SocketChannel socketChannel = serverSocketChannel.accept();
            // 开启一个新的线程来处理这个请求,然后在 while 循环中继续监听 8080 端口
            SocketHandler handler = new SocketHandler(socketChannel);
            new Thread(handler).start();
        }
    }
}

2、新线程的处理器,SocketHandler

@Slf4j
public class SocketHandler implements Runnable {
    
    

    private final SocketChannel socketChannel;

    public SocketHandler(SocketChannel socketChannel) {
    
    
        this.socketChannel = socketChannel;
    }

    @Override
    public void run() {
    
    
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        try {
    
    
            // 将请求数据读入 Buffer 中
            int num;
            while ((num = socketChannel.read(buffer)) > 0) {
    
    
                // 读取 Buffer 内容之前先 flip 一下
                buffer.flip();

                // 提取 Buffer 中的数据
                byte[] bytes = new byte[num];
                buffer.get(bytes);

                String re = new String(bytes, StandardCharsets.UTF_8);
                log.info("收到请求: " + re);

                // 回应客户端
                ByteBuffer writeBuffer = ByteBuffer.wrap(("我已经收到你的请求, 你的请求内容是: " + re).getBytes());
                socketChannel.write(writeBuffer);

                buffer.clear();
            }
        } catch (IOException e) {
    
    
            IOUtils.closeQuietly(socketChannel);
        }
    }
}

3、客户端,即 SocketChannel

@Slf4j
public class SocketChannelTest {
    
    
    public static void main(String[] args) throws IOException {
    
    
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 8080));

        // 发送请求
        ByteBuffer buffer = ByteBuffer.wrap("1234567890".getBytes());
        socketChannel.write(buffer);

        // 读取响应
        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
        int num;
        if ((num = socketChannel.read(readBuffer)) > 0) {
    
    
            readBuffer.flip();

            byte[] re = new byte[num];
            readBuffer.get(re);

            String result = new String(re, StandardCharsets.UTF_8);
            log.info("返回值: " + result);
        }
    }
}

4、运行结果

先启动服务端,可以看到服务端正在监听

然后再启动客户端,接收到了服务端返回的消息

同时服务端接收到客户端传递的消息

再多运行几次客户端,可以看到来一个新的连接,服务端就新开一个线程来处理这个连接,之后的操作全部由那个线程来完成

那么,这个模式下的性能瓶颈在哪里呢?

  • 首先,每次来一个连接都开一个新的线程这肯定是不合适的。当活跃连接数在几十几百的时候当然是可以这样做的,但如果活跃连接数是几万几十万的时候,这么多线程明显就不行了。每个线程都需要一部分内存,内存会被迅速消耗,同时,线程切换的开销非常大
  • 其次,阻塞操作在这里也是一个问题。首先,accept() 是一个阻塞操作,当 accept() 返回的时候,代表有一个连接可以使用了,我们这里是马上就新建线程来处理这个 SocketChannel 了,但是,但是这里不代表对方就将数据传输过来了。所以,SocketChannel#read 方法将阻塞,等待数据,明显这个等待是不值得的。同理,write 方法也需要等待通道可写才能执行写入操作,这边的阻塞等待也是不值得的

3. 非阻塞 IO

说完了阻塞模式的使用及其缺点以后,我们这里就可以介绍非阻塞 IO 了

非阻塞 IO 的核心在于使用一个 Selector 来管理多个通道,可以是 SocketChannel,也可以是 ServerSocketChannel,将各个通道注册到 Selector 上,指定监听的事件

之后可以只用一个线程来轮询这个 Selector,看看上面是否有通道是准备好的,当通道准备好可读或可写,然后才去开始真正的读写,这样速度就很快了。我们就完全没有必要给每个通道都起一个线程

NIO 中 Selector 是对底层操作系统实现的一个抽象,管理通道状态其实都是底层系统实现的,这里简单介绍下在不同系统下的实现

  • select:上世纪 80 年代就实现了,它支持注册 FD_SETSIZE(1024) 个 socket,在那个年代肯定是够用的,不过现在嘛,肯定是不行了
  • poll:1997 年,出现了 poll 作为 select 的替代者,最大的区别就是,poll 不再限制 socket 数量

select 和 poll 都有一个共同的问题,那就是它们都只会告诉你有几个通道准备好了,但是不会告诉你具体是哪几个通道。所以,一旦知道有通道准备好以后,自己还是需要进行一次扫描,显然这个不太好,通道少的时候还行,一旦通道的数量是几十万个以上的时候,扫描一次的时间都很可观了,时间复杂度 O(n)。所以,后来才催生了以下实现

  • epoll:2002 年随 Linux 内核 2.5.44 发布,epoll 能直接返回具体的准备好的通道,时间复杂度 O(1)

除了 Linux 中的 epoll,2000 年 FreeBSD 出现了 Kqueue,还有就是,Solaris 中有 /dev/poll

前面说了那么多实现,但是没有出现 Windows,Windows 平台的非阻塞 IO 使用 select,我们也不必觉得 Windows 很落后,在 Windows 中 IOCP 提供的异步 IO 是比较强大的

回到 Selector,毕竟 JVM 就是这么一个屏蔽底层实现的平台,我们面向 Selector 编程就可以了 在介绍 Selector 的时候已经了解过了它的基本用法,这边来一个可运行的实例代码,如下:

@Slf4j
public class SelectorServer {
    
    
    public static void main(String[] args) throws IOException {
    
    
        Selector selector = Selector.open();

        ServerSocketChannel server = ServerSocketChannel.open();
        server.socket().bind(new InetSocketAddress(8080));

        // 将其注册到 Selector 中,监听 OP_ACCEPT 事件
        server.configureBlocking(false);
        server.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
    
    
            int readyChannels = selector.select();
            if (readyChannels == 0) {
    
    
                continue;
            }

            Set<SelectionKey> readyKeys = selector.selectedKeys();
            // 遍历
            Iterator<SelectionKey> iterator = readyKeys.iterator();
            while (iterator.hasNext()) {
    
    
                SelectionKey key = iterator.next();
                iterator.remove();

                if (key.isAcceptable()) {
    
    
                    // 有已经接受的新的到服务端的连接
                    SocketChannel socketChannel = server.accept();

                    // 有新的连接并不代表这个通道就有数据,
                    // 这里将这个新的 SocketChannel 注册到 Selector,监听 OP_READ 事件,等待数据
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
    
    
                    // 有数据可读
                    // 上面一个 if 分支中注册了监听 OP_READ 事件的 SocketChannel
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer readBuffer = ByteBuffer.allocate(1024);

                    try {
    
    
                        int num = socketChannel.read(readBuffer);
                        if (num > 0) {
    
    
                            // 处理进来的数据...
                            log.info("收到数据:" + new String(readBuffer.array()).trim());
                            ByteBuffer buffer = ByteBuffer.wrap("返回给客户端的数据...".getBytes());
                            socketChannel.write(buffer);
                        } else if (num == -1) {
    
    
                            // -1 代表连接已经关闭
                            socketChannel.close();
                        }
                    } catch (IOException e) {
    
    
                        socketChannel.close();
                    }
                }
            }
        }
    }
}

客户端代码用前面的即可,然后运行结果和前面一样,只是新的连接不再创建新线程来完成

4. NIO.2 异步 IO(AIO)

More New IO,或称 NIO.2,随 JDK 1.7 发布,包括了引入异步 IO 接口和 Paths 等文件访问接口

异步这个词,我想对于绝大多数开发者来说都很熟悉,很多场景下我们都会使用异步

通常,我们会有一个线程池用于执行异步任务,提交任务的线程将任务提交到线程池就可以立马返回,不必等到任务真正完成。如果想要知道任务的执行结果,通常是通过传递一个回调函数的方式,任务结束后去调用这个函数

同样的原理,Java 中的异步 IO 也是一样的,都是由一个线程池来负责执行任务,然后使用回调或自己去查询结果。异步 IO 主要是为了控制线程数量,减少过多的线程带来的内存消耗和 CPU 在线程调度上的开销

在 Unix/Linux 等系统中,JDK 使用了并发包中的线程池来管理任务,具体可以查看 AsynchronousChannelGroup 的源码

在 Windows 操作系统中,提供了一个叫做I/O Completion Ports 的方案,通常简称为 IOCP,操作系统负责管理线程池,其性能非常优异,所以在 Windows 中 JDK 直接采用了 IOCP 的支持,使用系统支持,把更多的操作信息暴露给操作系统,也使得操作系统能够对我们的 IO 进行一定程度的优化

在 Linux 中其实也是有异步 IO 系统实现的,但是限制比较多,性能也一般,所以 JDK 采用了自建线程池的方式

总共有三个类需要我们关注,分别是 AsynchronousSocketChannelAsynchronousServerSocketChannelAsynchronousFileChannel,只不过是在之前介绍的 FileChannel、SocketChannel 和 ServerSocketChannel 的类名上加了个前缀 Asynchronous

Java 异步 IO 提供了两种使用方式,分别是返回 Future 实例和使用回调函数

4.1 返回 Future 实例

返回 java.util.concurrent.Future 实例的方式我们应该很熟悉,JDK 线程池就是这么使用的。Future 接口的几个方法语义在这里也是通用的,这里先做简单介绍

  • future.isDone();:判断操作是否已经完成,包括了正常完成、异常抛出、取消
  • future.cancel(true);:取消操作,方式是中断。参数 true 说的是,即使这个任务正在执行,也会进行中断
  • future.isCancelled();:是否被取消,只有在任务正常结束之前被取消,这个方法才会返回 true
  • future.get();:获取执行结果,阻塞
  • future.get(10, TimeUnit.SECONDS);:如果上面的 get() 方法的阻塞你不满意,那就设置个超时时间

4.2 提供 CompletionHandler 回调函数

java.nio.channels.CompletionHandler 接口定义:

public interface CompletionHandler<V,A> {
    
    

    void completed(V result, A attachment);

    void failed(Throwable exc, A attachment);
}

注意,参数上有个 attachment,虽然不常用,我们可以在各个支持的方法中传递这个参数值

AsynchronousServerSocketChannel listener = AsynchronousServerSocketChannel.open().bind(null);

// accept 方法的第一个参数可以传递 attachment
listener.accept(attachment, new CompletionHandler<AsynchronousSocketChannel, Object>() {
    
    
    public void completed(
      AsynchronousSocketChannel client, Object attachment) {
    
    
          // 
      }
    public void failed(Throwable exc, Object attachment) {
    
    
          // 
      }
});

4.3 AsynchronousFileChannel

异步的文件 IO,前面我们说了,文件 IO 在所有的操作系统中都不支持非阻塞模式,但是我们可以对文件 IO 采用异步的方式来提高性能

下面是 AsynchronousFileChannel 里面的一些重要的接口:

实例化:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(Paths.get("/Users/hongjie/test.txt"));

一旦实例化完成,我们就可以着手准备将数据读入到 Buffer 中

ByteBuffer buffer = ByteBuffer.allocate(1024);
Future<Integer> result = channel.read(buffer, 0);

异步文件通道的读操作和写操作都需要提供一个文件的开始位置,文件开始位置为 0

除了使用返回 Future 实例的方式,也可以采用回调函数进行操作,接口如下:

public abstract <A> void read(ByteBuffer dst,
                              long position,
                              A attachment,
                              CompletionHandler<Integer,? super A> handler);

顺便也贴一下写操作的两个版本的接口:

public abstract Future<Integer> write(ByteBuffer src, long position);

public abstract <A> void write(ByteBuffer src,
                               long position,
                               A attachment,
                               CompletionHandler<Integer,? super A> handler);

我们可以看到,AIO 的读写主要也还是与 Buffer 打交道,这个与 NIO 是一脉相承的 另外,还提供了用于将内存中的数据刷入到磁盘的方法:

public abstract void force(boolean metaData) throws IOException;

因为我们对文件的写操作,操作系统并不会直接针对文件操作,系统会缓存,然后周期性地刷入到磁盘。如果希望将数据及时写入到磁盘中,以免断电引发部分数据丢失,可以调用此方法。参数如果设置为 true,意味着同时也将文件属性信息更新到磁盘

还有,还提供了对文件的锁定功能,我们可以锁定文件的部分数据,这样可以进行排他性的操作

public abstract Future<FileLock> lock(long position, long size, boolean shared);

position 是要锁定内容的开始位置,size 指示了要锁定的区域大小,shared 指示需要的是共享锁还是排他锁

当然,也可以使用回调函数的版本:

public abstract <A> void lock(long position,
                              long size,
                              boolean shared,
                              A attachment,
                              CompletionHandler<FileLock,? super A> handler);

文件锁定功能上还提供了 tryLock 方法,此方法会快速返回结果:

public abstract FileLock tryLock(long position, long size, boolean shared)
    throws IOException;

这个方法很简单,就是尝试去获取锁,如果该区域已被其他线程或其他应用锁住,那么立刻返回 null,否则返回 FileLock 对象

4.4 AsynchronousServerSocketChannel

这个类对应的是非阻塞 IO 的 ServerSocketChannel,可以类比下使用方式

@Slf4j
public class AsynchronousServer {
    
    
    public static void main(String[] args) throws IOException {
    
    
        // 实例化,并监听端口
        AsynchronousServerSocketChannel server =
                AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(8080));

        // 自己定义一个 Attachment 类,用于传递一些信息
        Attachment att = new Attachment();
        att.setServer(server);

        server.accept(att, new CompletionHandler<AsynchronousSocketChannel, Attachment>() {
    
    
            @Override
            public void completed(AsynchronousSocketChannel client, Attachment att) {
    
    
                try {
    
    
                    SocketAddress clientAddr = client.getRemoteAddress();
                    log.info("收到新的连接:" + clientAddr);

                    // 收到新的连接后,server 应该重新调用 accept 方法等待新的连接进来
                    att.getServer().accept(att, this);

                    Attachment newAtt = new Attachment();
                    newAtt.setServer(server);
                    newAtt.setClient(client);
                    newAtt.setReadMode(true);
                    newAtt.setBuffer(ByteBuffer.allocate(2048));

                    // 这里也可以继续使用匿名实现类,不过代码不好看,所以这里专门定义一个类
                    client.read(newAtt.getBuffer(), newAtt, new ChannelHandler());
                } catch (IOException ex) {
    
    
                    ex.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable t, Attachment att) {
    
    
                log.info("accept failed");
            }
        });

        // 为了防止 main 线程退出
        try {
    
    
            Thread.currentThread().join();
        } catch (InterruptedException e) {
    
    
            Thread.currentThread().interrupt();
        }
    }
}

ChannelHandler 服务端处理器

@Slf4j
public class ChannelHandler implements CompletionHandler<Integer, Attachment> {
    
    

    @Override
    public void completed(Integer result, Attachment att) {
    
    
        if (att.isReadMode()) {
    
    
            // 读取来自客户端的数据
            ByteBuffer buffer = att.getBuffer();
            buffer.flip();
            byte[] bytes = new byte[buffer.limit()];
            buffer.get(bytes);
            String msg = new String(buffer.array()).trim();
            log.info("收到来自客户端的数据: " + msg);

            // 响应客户端请求,返回数据
            buffer.clear();
            buffer.put("Response from server!".getBytes(Charset.forName(StandardCharsets.UTF_8.toString())));
            att.setReadMode(false);
            buffer.flip();
            // 写数据到客户端也是异步
            att.getClient().write(buffer, att, this);
        } else {
    
    
            // 到这里,说明往客户端写数据也结束了,有以下两种选择:
            // 1. 继续等待客户端发送新的数据过来
//            att.setReadMode(true);
//            att.getBuffer().clear();
//            att.getClient().read(att.getBuffer(), att, this);
            try {
    
    
                // 2. 既然服务端已经返回数据给客户端,断开这次的连接
                att.getClient().close();
            } catch (IOException ignored) {
    
    
            }
        }
    }

    @Override
    public void failed(Throwable exc, Attachment attachment) {
    
    
        log.info("连接断开");
    }
}

自定义的 Attachment 类

@Data
public class Attachment {
    
    

    private AsynchronousServerSocketChannel server;

    private AsynchronousSocketChannel client;

    private boolean isReadMode;

    private ByteBuffer buffer;
}

4.5 AsynchronousSocketChannel

AsynchronousSocketChannel 和前面的非阻塞 IO 基本类似

public class AsynchronousClient {
    
    
    public static void main(String[] args) throws ExecutionException, InterruptedException, IOException {
    
    
        AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
        // 来个 Future 形式的
        Future<?> future = client.connect(new InetSocketAddress("127.0.0.1", 8080));
        // 阻塞一下,等待连接成功
        future.get();

        Attachment att = new Attachment();
        att.setClient(client);
        att.setReadMode(false);
        att.setBuffer(ByteBuffer.allocate(2048));
        byte[] data = "I am obot!".getBytes();
        att.getBuffer().put(data);
        att.getBuffer().flip();

        // 异步发送数据到服务端
        client.write(att.getBuffer(), att, new ClientChannelHandler());

        // 这里休息一下再退出,给出足够的时间处理数据
        Thread.sleep(2000);
    }
}

ClientChannelHandler 客户端处理器

@Slf4j
public class ClientChannelHandler implements CompletionHandler<Integer, Attachment> {
    
    

    @Override
    public void completed(Integer result, Attachment att) {
    
    
        ByteBuffer buffer = att.getBuffer();
        if (att.isReadMode()) {
    
    
            // 读取来自服务端的数据
            buffer.flip();
            byte[] bytes = new byte[buffer.limit()];
            buffer.get(bytes);
            String msg = new String(bytes, Charset.forName(StandardCharsets.UTF_8.toString()));
            log.info("收到来自服务端的响应数据: " + msg);

            // 接下来,有以下两种选择:
            // 1. 向服务端发送新的数据
//            att.setReadMode(false);
//            buffer.clear();
//            String newMsg = "new message from client";
//            byte[] data = newMsg.getBytes(Charset.forName("UTF-8"));
//            buffer.put(data);
//            buffer.flip();
//            att.getClient().write(buffer, att, this);
            try {
    
    
                // 2. 关闭连接
                att.getClient().close();
            } catch (IOException ignored) {
    
    
            }
        } else {
    
    
            // 写操作完成后,会进到这里
            att.setReadMode(true);
            buffer.clear();
            att.getClient().read(buffer, att, this);
        }
    }

    @Override
    public void failed(Throwable exc, Attachment attachment) {
    
    
        log.info("服务器无响应");
    }
}

运行结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8EP3nyQD-1689582759658)(http://img.fan223.cn/2023/05/20230509163807.png)]

4.6 Asynchronous Channel Groups

之前我们说过,异步 IO 一定存在一个线程池,这个线程池负责接收任务、处理 IO 事件、回调等。这个线程池就在 group 内部,group 一旦关闭,那么相应的线程池就会关闭

AsynchronousServerSocketChannels 和 AsynchronousSocketChannels 是属于 group 的,当我们调用 AsynchronousServerSocketChannel 或 AsynchronousSocketChannel 的 open() 方法的时候,相应的 channel 就属于默认的 group,这个 group 由 JVM 自动构造并管理

如果我们想要配置这个默认的 group,可以在 JVM 启动参数中指定以下系统变量:

  • java.nio.channels.DefaultThreadPool.threadFactory:此系统变量用于设置 ThreadFactory,它应该是 java.util.concurrent.ThreadFactory 实现类的全限定类名。一旦我们指定了这个 ThreadFactory 以后,group 中的线程就会使用该类产生
  • java.nio.channels.DefaultThreadPool.initialSize:此系统变量也很好理解,用于设置线程池的初始大小

可能你会想要使用自己定义的 group,这样可以对其中的线程进行更多的控制,使用以下几个方法即可:

  • AsynchronousChannelGroup.withCachedThreadPool(ExecutorService executor, int initialSize)
  • AsynchronousChannelGroup.withFixedThreadPool(int nThreads, ThreadFactory threadFactory)
  • AsynchronousChannelGroup.withThreadPool(ExecutorService executor)

熟悉线程池的读者对这些方法应该很好理解,它们都是 AsynchronousChannelGroup 中的静态方法 至于 group 的使用就很简单了

AsynchronousChannelGroup group = AsynchronousChannelGroup
        .withFixedThreadPool(10, Executors.defaultThreadFactory());
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group);
AsynchronousSocketChannel client = AsynchronousSocketChannel.open(group);

AsynchronousFileChannels 不属于 group。但是它们也是关联到一个线程池的,如果不指定,会使用系统默认的线程池,如果想要使用指定的线程池,可以在实例化的时候使用以下方法:

public static AsynchronousFileChannel open(Path file,
                                           Set<? extends OpenOption> options,
                                           ExecutorService executor,
                                           FileAttribute<?>... attrs) {
    
    
    ...
}

猜你喜欢

转载自blog.csdn.net/ACE_U_005A/article/details/131769505
今日推荐