『Netty 入门一』NIO 网络编程

本篇文章为 Netty 入门系列的第一篇文章 NIO 网络编程,该系列主要是记录 Netty 的学习笔记,希望对读者们有所帮助。

前言

为什么 Netty 入门的第一章记录的是 NIO 网络编程?

这是因为 Netty 是基于 NIO 的网络编程框架,即 Netty 是建立在 NIO 之上的,是对 NIO 的一种封装和拓展,所以我认为在学习 Netty 之前,应该先了解一下 NIO 的网络编程。

NIO 是什么?

NIO(非阻塞 I/O,Non-blocking I/O)是 Java 编程语言中的一种 I/O 模型,用于处理 I/O 操作,特别是网络通信。NIO 提供了一种非阻塞的 I/O 机制,允许一个线程处理多个通道(通常是 Socket 通道)的 I/O 操作,而不需要为每个通道创建一个独立的线程。这使得 NIO 在高并发的情况下能够更高效地处理大量的连接。

为什么使用 NIO,相比于传统的网络编程,NIO 网络编程的优势在哪?

NIO 网络编程的优势如下:

  • 非阻塞:传统网络编程中每个 I/O 操作都是阻塞的,这就意味着在处理多个连接时需要创建大量的线程,导致性能降低。而 NIO 使用了非阻塞 I/O,即允许一个线程同时管理多个 I/O 通道,从而提高并发性能。
  • 面向缓冲区存储数据:传统 I/O 基于字节流或字符流实现对数据的读写,而 NIO 使用缓冲区来处理数据,这使得数据的读取和写入更加高效。
  • 多路复用:NIO 引入 Selector(选择器)的概念,允许一个线程监视多个通道的状态。当一个通道准备好执行I/O操作时,线程可以立即处理它,而不需要等待。通过多路复用的机制允许一个线程同时处理多个连接,减少线程的创建和上文切换的开销,提高性能和可伸缩性。

以上就是 NIO 网络编程相对于传统网络编程的优势,NIO 通过三大核心组件实现同步非阻塞式的网络编程,下面将依次介绍这三个组件。


核心组件

下面将依次介绍 NIO 三大核心组件:Buffer(缓冲区)、Channel(通道)、Selector(选择器)。

缓冲区(Buffer)

在 NIO 中,并不是像传统 IO(BIO)以流(stream)的形式处理数据的,而是以缓冲区(buffer)和通道(channel)配合使用处理数据的。NIO 通过 Channel 运输存储着数据的 Buffer 来实现数据的处理。

七种 Buffer 类型

通过 Buffer 源码可以看到 Buffer 有七种子类型:

通过名字可以看出分别用于不同基础类型的 Buffer,其中最常用的是 ByteBuffer 类型(下面子类都以 ByteBuffer 为例)。

通过 ByteBuffer 源码看看 ByteBuffer 和 Buffer 的关系:

可以看到 ByteBuffer 实质上是一个 btye 数组,其实上面的七种类型也就是对应七种基础类型的数组,核心功能还是来自继承类 Buffer。

创建 Buffer 的方式

了解一个类的第一步当然是了解它的创建方式。创建 Buffer 的方式主要分为两种:

  • 创建 JVM 堆内内存块的 Buffer(HeapByteBuffer)

    java 复制代码
    // 方式一:创建指定长度的堆内内存块 HeapByteBuffer
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    
    // 方式二:创建给定 byte 数组的堆内内存块 HeapByteBuffer
    ByteBuffer byteBuffer = ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8));
  • 创建直接内存块(堆外内存块)的 Buffer(DirectByteBuffer)

    java 复制代码
    // 创建指定长度的堆外内存块 DirectByteBuffer
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);

HeapByteBuffer 与 DirectByteBuffer 的区别:

  • 内存分配:HeapByteBuffer 使用 JVM 堆内存进行内存分配,通过 JVM 的垃圾回收机制管理内存,故会带来 GC 的开销;DirectByteBuffer 使用操作系统的本地内存进行内存分配,不受 GC 影响,在某些情况下能更有效地分配和释放内存。
  • 内存访问:HeapByteBuffer 存储在堆内存中,在访问时需要经过一次内存复制(从本地内存到堆内存)会引入一定的性能开销;DirectByteBuffer 存储在本地内存中,可以通过零拷贝方式访问数据,减少了内存复制的开销,提高了性能。

HeapByteBuffer 与 DirectByteBuffer 的使用场景:

  • HeapByteBuffer:适用于大多数情况,在数据频繁发生变化的场景下堆内存创建和销毁缓冲区相对容易。
  • DirectByteBuffer:适用于需要高性能 I/O 操作的场景,如数据量较大(不受堆内存限制)、生命周期较长的数据。

Buffer 的属性及常用方法

通过 Buffer 源码查看属性:

java 复制代码
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

四个属性的作用分别为:

  • capacity:表示缓冲区的容量。通过构造函数赋予,一旦设置无法更改。
  • limit :表示缓冲区的界限。位于该属性后面的数据不可读写,取值范围为 [0, capacity]
  • position :表示缓冲区的下一个读写数据的位置。取值范围为 [0, limit]
  • mark :表示记录当前 position 的位置,当 position 改变后,调用 reset 方法可以回到 mark 的位置。

下面通过一个例子来说明这个四个属性的作用并介绍 Buffer 常用的方法:

java 复制代码
/**
 * Buffer 示例
 *
 * @author 单程车票
 */
public class ByteBufferDemo {
    public static void main(String[] args) {
        // 1. 创建 capacity 为 10 的 Buffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(10);
        // 2. 写入 hello 数据
        byteBuffer.put("hello".getBytes());
        // 3. 切换读模式
        byteBuffer.flip();
        // 4. 读取第一个字符数据
        System.out.println((char)byteBuffer.get());
        // 5. 标记当前 position 位置
        byteBuffer.mark();
        // 6. 读取第二个字符数据
        System.out.println((char)byteBuffer.get());
        // 7. 重置回 mark 位置,并再次读取第二个字符数据
        byteBuffer.reset();
        System.out.println((char)byteBuffer.get());
        // 8. 调用 rewind 重置回初始读模式状态
        byteBuffer.rewind();
        // 9. 再次读取第一个和第二个字符数据
        System.out.println((char)byteBuffer.get());
        System.out.println((char)byteBuffer.get());
        // 10. 清除所有数据
        byteBuffer.clear();
        // 11. 写入 world 数据
        byteBuffer.put("world".getBytes());
        // 12. 切换读模式并读取两个字符数据
        byteBuffer.flip();
        System.out.println((char)byteBuffer.get());
        System.out.println((char)byteBuffer.get());
        // 13. 清除已读数据,将未读取的数据向前压缩
        byteBuffer.compact();
    }
}

/* 运行结果
h
e
e
h
e
w
o
*/

根据上面的代码逐步展示 Buffer 的结构以及常用方法:

  1. 创建 capacity 为 10 的 Buffer
    • 这里使用了上面介绍的创建堆内内存的缓冲区 Buffer 方法。
    • 初始化的 Buffer 结构如图:
  2. 写入 hello 数据
    • 这里使用 Buffer 的 put(byte[] src) 方法,该方法可以把传入字节数组放入缓冲区,每次传入一个字节后 position + 1 指向下一个存储位置。
    • 写入后的 Buffer 结构如图:
  3. 切换读模式
    • 这里使用 Buffer 的 flip() 方法,该方法会切换对缓冲区的操作模式。写模式转读模式时,会将 position 指向 0 索引,limit 指向最后一个写入字节的下一个位置;读模式切换写模式时,会将 position 指向当前缓冲区的最后一个字节的下一个位置,limit 指向 capacity 的位置。
    • 切换模式后的 Buffer 结构如图:
  4. 读取第一个字符数据
    • 这里使用 Buffer 的 get() 方法,该方法可以读取缓冲区的一个字节数据,读取后会将 position 加 1,如果 position 超出 limit 则会抛出异常。
    • 读取后的 Buffer 结构图:
  5. 标记当前 position 位置
    • 这里使用 Buffer 的 mark() 方法,该方法会将 position 的值保存到 mark 属性中。
    • 标记后的 Buffer 结构图:
  6. 读取第二个字符数据
    • 读取后的 Buffer 结构图:
  7. 重置回 mark 位置,并再次读取第二个字符数据
    • 这里使用 Buffer 的 reset() 方法,该方法会将 position 的值重置回 mark 属性中。
    • 重置后的 Buffer 结构图:
  8. 重置回初始读模式状态
    • 这里使用 Buffer 的 rewind() 方法,该方法只能在读模式下使用,可以恢复初始化读模式,将 position 指向 0 索引,mark 指向 -1 索引。
    • 重置后的 Buffer 结构图:
  9. 再次读取第一个和第二个字符数据
    • 读取后的 Buffer 结构图:
  10. 清除所有数据
    • 这里使用 Buffer 的 clear() 方法,该方法会将缓冲区的各个属性恢复到初始化状态,并进入写模式,即 mark = -1position = 0limit = capacity。但是缓冲区的数据依旧存在,下次写入时会覆盖这些旧数据,隐式删除。
    • 清除后的 Buffer 结构图:
  11. 写入 world 数据
    • 写入后的 Buffer 结构图:
  12. 切换读模式并读取两个数据
    • 读取后的 Buffer 结构图:
  13. 清除已读数据,将未读取的数据向前压缩
    • 这里使用 Buffer 的 compact() 方法,该方法会把未读取的数据向前压缩,并切换到写模式,数据前移时原位置的数据不会清除,写入新数据时会覆盖旧数据。
    • 清除已读数据后的 Buffer 结构图:

以上就是整个代码的执行流程,通过结构图可以看到常用方法的调用四个属性的变化,通过属性的变化实现对数据的读写操作。


通道(Channel)

通道(Channel)表示一个可以进行读取或写入操作的开放链接。Channel 通道只负责传输数据,不直接操作数据(数据是通过缓冲区 Buffer 操作)。

常见的 Channel 类型有:

  • FileChannel:建立文件通道,用于在文件中进行 I/O 操作。
  • SocketChannel:建立套接字通道,用于进行网络套接字通信,可以用于建立客户端和服务器之间的连接。
  • ServerSocketChannel:建立服务器套接字通道,用于服务器端接受客户端的连接请求。
  • DatagramChannel:建立数据报通道,用于实现 UDP 协议的网络通信。

本篇文章只讲解网络编程相关的 SocketChannel 与 ServerSocketChannel。

ServerSocketChannel 的常用方法

  1. open() 方法创建一个新的 ServerSocketChannel 示例,方法可能抛出 IO 异常。

    java 复制代码
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  2. bind(SocketAddress local) 方法将 ServerSocketChannel 绑定到指定的本地地址和端口,以便监听客户端连接。

    java 复制代码
    serverSocketChannel.bind(new InetSocketAddress("localhost", 8080));
  3. accept() 方法用于接受客户端的连接请求,并返回一个新的 SocketChannel,该通道可以用于与客户端进行通信。

    java 复制代码
    SocketChannel socketChannel = serverSocketChannel.accept();
  4. configureBlocking(boolean block) 方法用于设置 ServerSocketChannel 的阻塞模式。如果 block 参数为 true,则通道将以阻塞模式工作;如果为 false,则通道将以非阻塞模式工作。

    java 复制代码
    // 非阻塞模式
    serverSocketChannel.configureBlocking(false);
  5. close() 方法用于关闭 ServerSocketChannel,释放资源并停止监听客户端连接。

    java 复制代码
    serverSocketChannel.close();

SocketChannel 的常用方法

  1. open() 方法用于创建一个新的 SocketChannel 实例。

    java 复制代码
    SocketChannel socketChannel = SocketChannel.open();
  2. connect(SocketAddress remote) 方法用于连接到远程服务器。

    java 复制代码
    socketChannel.connect(new InetSocketAddress("localhost", 8080));
  3. configureBlocking(boolean block) 方法用于设置 SocketChannel 的阻塞模式。

    java 复制代码
    // 非阻塞模式
    socketChannel.configureBlocking(false);
  4. read(ByteBuffer dst) 方法用于从 SocketChannel 中读取数据,并将数据写入指定的 dst(ByteBuffer)。返回值表示读取的字节数,如果返回 -1 表示连接已关闭。

    java 复制代码
    int res = socketChannel.read(ByteBuffer.allocate(1024));
  5. write(ByteBuffer src) 方法用于将数据从 src(ByteBuffer)写入到 SocketChannel。返回值表示写入的字节数,可能会写入部分数据。

    java 复制代码
    int res = socketChannel.write(ByteBuffer.wrap("hello".getBytes()));
  6. isConnected() 方法用于检查是否已经连接到远程服务器。

    java 复制代码
    boolean connected = socketChannel.isConnected();
  7. close() 方法用于关闭 SocketChannel,释放资源并关闭连接。

    java 复制代码
    socketChannel.close();

以上就是 SocketChannel 与 ServerSocketChannel 的常用方法。


选择器(Selector)

选择器(Selector)用于实现多路复用,即允许一个线程监视多个通道的事件状态,从而可以在单线程中处理多个通道的 I/O 操作。跟上面的两个组件不同,Selector 只能用于网络 IO,文件 IO 没法利用多路复用。

为什么要有 Selector?

没有 Selector 之前,面临连接数多的场景,使用单线程建立连接会出现阻塞,大大降低性能。所以每处理一个 Socket 连接都需要创建一个线程,随着连接数的增多会导致内存占用过高,线程上下文切换成本增加。可以使用线程池技术管理线程,但是连接数过多时,线程依旧只能处理一个连接从而导致其他连接阻塞。所以在没有 Selector 之前适用于连接数少的场景。

有了 Selector 之后,Selector 可以配合单线程来管理多个 Channel,监听多个 Channel 上的事件,这些 Channel 工作在非阻塞模式下,当一个 Channel 没有事件发生时,可以处理有事件发生的 Channel。当没有事件发生时,Selector 会处于阻塞状态直到 Channel 发生事件。这就是多路复用的概念。

举一个多线程处理、多路复用的例子。假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确。则:

  • 多线程处理:你创建30个分身,每个分身检查一个学生的答案是否正确。类似于为每一个客户端创建一个进程处理连接。
  • 多路复用:你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时A、B又举手,然后去处理A和B。

Selector 的常用方法

  1. open() 方法创建一个新的 Selector 实例。

    java 复制代码
    Selector selector = Selector.open();
  2. register() Channel 绑定 Selector 方法(Channel 调用),通过将 Channel 注册到 Selector 上,可以指定要监视的通道以及关注的事件类型。返回参数类型为 SelectionKey。

    • 方法有两种传参方式:register(Selector sel, int ops, Object att)register(Selector sel, int ops)
    • sel 参数表示绑定的 Selector。
    • ops 参数表示关注的事件类型:SelectionKey.OP_CONNECT(连接就绪事件)、SelectionKey.OP_ACCEPT(接受连接事件)、SelectionKey.OP_READ(读就绪事件)、SelectionKey.OP_WRITE(写就绪事件)。
    • att 参数表示要附加到 SelectionKey 上的对象,可以是任何自定义对象,用于存储额外信息。
    java 复制代码
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT, buffer);
  3. select() 方法用于阻塞等待直到至少有一个通道发生了就绪事件或者发生了超时。当有一个或多个通道发生了就绪事件时,select() 方法会返回就绪通道的数量。可以传入一个 long 类型的参数 timeout(时间单位:毫秒),允许在指定时间内阻塞等待。

    java 复制代码
    selector.select();
    selector.select(5000);
  4. selectedKeys() 方法用于获取就绪通道的集合。

    java 复制代码
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
  5. isAcceptable()isConnectable()isReadable()isWritable() 方法判断具体的就绪事件类型。

    java 复制代码
    for (SelectionKey key : selectedKeys) {
        if (key.isAcceptable()) { // 处理接收连接事件
        } else if (key.isReadable()) { // 处理读就绪事件
        } else if (key.isWritable()) { // 处理写就绪事件
        }
    }
  6. cancel() 方法用于处理完一个 SelectionKey 对象后,需要取消它,以避免重复处理。

    java 复制代码
    key.cancel();
  7. close() 方法用于关闭 Selector,释放资源。

    java 复制代码
    selector.close();

以上就是 Selector 的常用方法。


实现 NIO 网络编程

上一节介绍了 NIO 的三大核心组件,下面将通过这三大组件实现 NIO 网络编程。

NIO 服务器代码

java 复制代码
/**
 * NIO 网络传输的服务器
 *
 * @author 单程车票
 */
@Slf4j
public class NIOServer {
    public static void main(String[] args) {
        // 创建 ServerSocketChannel
        try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {
            // 绑定端口
            serverSocketChannel.bind(new InetSocketAddress("localhost", 8080));
            // 设置非阻塞
            serverSocketChannel.configureBlocking(false);
            // 创建 Selector
            Selector selector = Selector.open();
            // 将服务端通道注册到选择器,监听客户端连接事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                // 阻塞监听
                selector.select();
                // 获取就绪事件的 SelectionKey 集合
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                // 遍历事件处理(这里使用迭代器)
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    // 获取就绪事件的 key
                    SelectionKey key = iterator.next();
                    // 从 SelectionKey 移除当前的 key
                    iterator.remove();
                    // 判断事件类型
                    if (key.isAcceptable()) {
                        // 获取 key 绑定的服务器通道
                        ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
                        // 获取客户端连接
                        SocketChannel socketChannel = ssc.accept();
                        // 设置客户端非阻塞
                        socketChannel.configureBlocking(false);
                        // 将客户端通道注册到选择器,监听读事件,并附加绑定一个缓冲区 Buffer
                        socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                    } else if (key.isReadable()) {
                        // 获取读事件 key 绑定的客户端通道
                        SocketChannel sc = (SocketChannel) key.channel();
                        // 获取关联的 buffer
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        // 获取服务器接收客户端发来的信息
                        int count = sc.read(buffer);
                        if (count == -1) { // 已经没有数据需要读取,客户端断开连接
                            // 移除 Selector 注册的 key
                            key.cancel();
                            // 关闭通道
                            sc.close();
                        } else {
                            // 切换读模式
                            buffer.flip();
                            // 打印
                            log.info("接收客户端消息:{}", StandardCharsets.UTF_8.decode(buffer));
                            // 清理 buffer
                            buffer.clear();
                        }
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

代码比较长,下面分步讲解一下实现 NIO 网络编程:

  1. 创建服务端通道 ServerSocketChannel 并绑定 IP 和 端口。
  2. 通过 configureBlocking(false) 将通道设置为非阻塞,通道在创建时默认是阻塞状态。NIO 网络编程需要工作在非阻塞模式。
  3. 创建选择器 Selector,并通过 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT) 将服务端通道注册进 Selector 中,指定 Selector 需要关注服务端通道的事件,这里因为是服务端通道,所以一般都是关注客户端的连接事件 SelectionKey.OP_ACCEPT
  4. 循环等待客户端连接,通过 selector.select() 阻塞直到有就绪事件发生。
  5. 就绪事件发生后可以通过 selector.selectedKeys() 获取就绪事件的 SelectionKey 的集合。
    • 这里说明一下 SelectionKey 集合是什么?
    • 通过图片流程可以看到当有就绪事件发生时,会将在 Selector 上注册的 key 自动放入 SelectionKey 中。注意:key 只能自动放入,但是不能删除,这也是为什么后面需要使用迭代器进行遍历的原因。
  6. 通过迭代器遍历 SelectionKey 集合,获取到遍历的 key 之后要使用迭代器的方法 remove() 删除集合的元素,不删除会出现下次迭代时处理的还是上一个就绪事件的 key,导致出现错误。
  7. 根据事件类型进行判定处理
    • 对于连接事件,先获取 key 对应的 Channel(这里的 Channel 是 ServerSocketChannel)。通过 ssc.accept() 建立客户端连接,并获取客户端通道 socketChannel。设置客户端通道非阻塞并注册到 Selector 上,设置关注事件为读事件,监听客户端发来的消息。设置附件 Buffer(每个通道都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个通道共同使用,因此需要为每个通道维护一个独立的 ByteBuffer)。
    • 对于读事件,先获取 key 对应的 Channel(这里的 Channel 是 SocketChannel)。通过 key.attachment() 可以获取到附件 Buffer,通过 sc.read(buffer) 可以将客户端发送的数据存储进 Buffer 中,并获取读取的数据的长度。如果长度为 -1,说明客户端正常断开,此时需要 key.cancel() 移除注册在 Selector 上的 key,并关闭通道。否则切换 Buffer 读模式打印客户端发送的数据。

到这里就基本实现了一个简单的 NIO 服务器的网络编程,实际开发中还需要考虑客户端发送的消息边界问题以及 Buffer 的大小分配问题,这里不再细讲后续在 Netty 中介绍。

NIO 客户端测试代码

java 复制代码
/**
 * BIO 网络传输的客户端测试
 *
 * @author 单程车票
 */
@Slf4j
public class NIOClient {
    public static void main(String[] args) {
        try {
            // 创建客户端通道
            SocketChannel socketChannel = SocketChannel.open();
            // 建立连接
            socketChannel.connect(new InetSocketAddress("localhost", 8080));
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNextLine()) {
                String msg = scanner.nextLine();
                ByteBuffer buffer = StandardCharsets.UTF_8.encode(msg);
                // 通道传输 buffer
                socketChannel.write(buffer);
            }
            socketChannel.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

启动服务器与两个客户端,测试结果:

客户端1: 客户端2: 服务器:

以上就是 NIO 网络编程的所有内容了,希望这篇文章能对你有所帮助。


相关推荐
龙鸣丿2 小时前
Linux基础学习笔记
linux·笔记·学习
NiNg_1_2344 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Nu11PointerException4 小时前
JAVA笔记 | ResponseBodyEmitter等异步流式接口快速学习
笔记·学习
Chrikk5 小时前
Go-性能调优实战案例
开发语言·后端·golang
亦枫Leonlew5 小时前
三维测量与建模笔记 - 3.3 张正友标定法
笔记·相机标定·三维重建·张正友标定法
幼儿园老大*5 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue5 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
考试宝5 小时前
国家宠物美容师职业技能等级评价(高级)理论考试题
经验分享·笔记·职场和发展·学习方法·业界资讯·宠物
杜杜的man5 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer087 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源