Netty 学习笔记(二)、NIO 简单介绍

概述

上篇博客我简单介绍了传统 socket 模型以及它的缺陷,在博客最后给出了 NIO 的模型。本篇博客我打算简单介绍 NIO 的基础知识以及如何使用:毕竟 NIO 是 Netty 的基础,如果对 NIO 完全不了解就去看 Netty 的话,很多抽象概念不能理解,学起来比较困难。


NIO

本篇博客我打算分以下几个模块展开:

  • NIO 简单介绍
  • 缓冲区 Buffer
  • 通道 Channel
  • 选择器 Selector
  • NIO 示例
  • NIO 常见问题解释
  • NIO 优势和劣势

NIO 简单介绍

NIO 是 JDK 1.4 新引入的输入/输出包,它弥补了原来 I/O 的不足,提供了全新的非阻塞的 I/O 模型,极大的提高了 JAVA 程序在高并发场景下的性能和可靠性。

相比传统 Socket 模型提供的 Server、ServerSocket 服务端,客户端封装类,NIO 提供了全新的封装类:ServerChannel、ServerSocketChannel。这两种新增的实体类不仅支持阻塞模式、还支持非阻塞模式,开发者可以根据具体的业务场景自行选择。毕竟部分低并发场景下,非阻塞模型由于不能发挥出多核 CPU 的优势,有可能降低性能。


缓冲区 Buffer

缓冲区 Buffer 是 NIO 相比 I/O 所做的重大改变。其中 Buffer 是一个对象,它包含一些要写入或者读出的数据,在面向流的编程中,可以直接将数据读取或写入到 Stream 对象中。

Stream 对象:Stream 是一组支持串行、并行、聚合操作的元素。我们可以把它理解为迭代器(Iterator)的增强版,通过 Stream 可以遍历对象,同时它还支持去重、求最大、最小值、判断格式等操作

缓冲区本质上可以理解为数组,不过在数组的基础上,它还特别提供了结构化访问数据的接口以及维护指针记录当前读写位置。需要注意的一点是:NIO 中所有数据操作都在缓冲区中进行。读取数据时,数据读取到缓冲区中,写入数据时,数据写入到缓冲区中。

最后我们来看看缓冲区 Buffer 的类图:
缓冲区Buffer
如上图所示,根据不同的数据类型实现了不同的 Buffer 处理类,其中具体的读取方法封装在顶层 Buffer 接口中。与其他实现类不同的是,ByteBuffer 除了提供读取、写入数据的方法外,还支持特别的 I/O 操作以满足网络 I/O,NIO 一般使用 ByteBuffer 作为自己的缓冲池。


通道 Channel

Channel 是一个通道,网络数据通过 Channel 读取和写入。

通道相比流是双向的,一个流只能是输入流或是输出流,而通道不仅可以读、写,还可以读写同步进行。这也更契合底层操作系统的 API,底层操作系统的通道都是全双工的。

在上篇 TCP 总结的博客中我们提到,一个 TCP Socket 会创建两个缓冲区:读取缓冲区和写入缓冲区,其中这两个缓冲区对应同一个连接,从侧面也说明底层操作系统的通道都是全双工的。Java 早期不支持全双工是由于没有集成底层操作系统已经实现好的 API,这也是早期 JAVA 应用不支持非阻塞的主要原因。

最后我们来看 Channel 类图:
Channel 类图
如上图所示,上面三层主要定义 Channel 常用方法,底层根据不同的用途实现不同的方法。根据功能的不同,Channel 主要分为用于网络读写的 SelectableChannel 和 文件读写的 FileChannel。后面我们要用到的 ServerSocketChannel 和 ServerChannel 都是 SelectableChannel 的子类。


多路复用器 Selector

Selector 是 NIO 的基础,它提供了选择已经就绪 I/O 的能力。其中它可以找出当前已经就绪的 I/O,并向下执行相应的操作。

Selector 通过不断地轮询注册在其上的 Channel,如果某个 Channel 上发生读或者写等事件,这个 Channel 就会处于就绪状态,会被 Selector 轮询出来,然后通过 SelectionKey 可以获取就绪的 Channel 集合,执行后续的 I/O 操作。

一个 Selector 可以轮询多个 Channle,NIO 中使用 epoll() 代替传统的 select(),这也意味着一个 Selector 可以支持更多的 Channel 连接,极大的提升了程序的可扩展性。


NIO 示例

我们通过 NIO 模拟时间服务器。下面我分别给出客户端服务端源码以及其运行结果,关于代码的功能我通过注释的方式给出:

时间服务器:客户端向服务端发送消息 “QUERY TIME ORDER”,服务端返回当前时间,否则返回“I don’t know”

NIO 服务端源码

public class NioServer implements Runnable {

    public static void main(String[] args) throws IOException {
        // 启动 NIO 服务器
        new Thread(new NioServer()).start();
    }

    /**
     * 用来记录服务端监听端口
     */
    private static final int PORT = 8888;

    /**
     * 用来标识服务端是否停止
     */
    private boolean stop = false;

    /**
     * 声明多路复用器对象
     */
    Selector selector = null;

    /**
     * 声明服务端套接字
     */
    ServerSocketChannel serverSocketChannel = null;

    private NioServer() {
        try {
            // 创建多路复用器对象
            selector = Selector.open();
            // 创建 NIO 服务端套接字
            serverSocketChannel = ServerSocketChannel.open();
            // 设置模式为非阻塞
            serverSocketChannel.configureBlocking(false);
            // 绑定监听端口,设置请求最大数量
            serverSocketChannel.socket().bind(new InetSocketAddress(PORT), 1024);
            // 将套接字注册到多路复用器上,监听 ACCEPT 事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            // 资源初始化失败(可能是由于端口被占用),停止程序
            e.printStackTrace();
            System.exit(1);
        }
    }

    @Override
    public void run() {
        try {
            // 多路复用器在 run() 方法的无限循环体中轮询就绪的 Key
            while (!stop) {
                // 这里1000表示休眠时间,每隔1s,多路复用器被唤醒一次
                selector.select(1000);
                // 该方法返回应该被处理的 SelectionKey 集合
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                // 使用迭代器遍历 SelectionKey 集合
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                SelectionKey key = null;
                while (iterator.hasNext()) {
                    key = iterator.next();
                    iterator.remove();
                    try {
                        handleInput(key);
                    } catch (IOException e) {
                        // 如果处理就绪事件时出现问题(可能由于该管道已停止)
                        // 关闭选择键,并关闭该键对应的管道
                        if (key != null) {
                            key.channel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }
            }
            // 程序执行到这里说明多路复用器已跳出循环,关闭多路复用器,释放资源
            if (selector != null) {
                selector.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 处理就绪的 Channel
     *
     * @param key
     * @throws IOException
     */
    private void handleInput(SelectionKey key) throws IOException {
        // 判断该选择键是否有效
        if (key.isValid()) {
            // 判断是否连接就绪
            if (key.isAcceptable()) {
                // 获取服务端套接字
                ServerSocketChannel socketChannel = (ServerSocketChannel) key.channel();
                // 完成三次握手,建立正式链路
                SocketChannel channel = socketChannel.accept();
                // 设置该通管道为非阻塞
                channel.configureBlocking(false);
                // 注册该管道的读就绪事件到多路复用器
                channel.register(selector, SelectionKey.OP_READ);
            }
            // 判断是否读就绪
            if (key.isReadable()) {
                // 获取对应管道 Channel
                SocketChannel channel = (SocketChannel) key.channel();
                // 创建 ByteBuffer 缓冲区,默认设置大小为 1M
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                // 此时通道是非阻塞的,因此需要对返回值判断
                int length = channel.read(buffer);
                if (length > 0) {
                    // 如果大于0,说明读取到字节,对字节进行编解码
                    // filp() 主要为了方便后期缓冲区读取操作,下文我们详细介绍
                    buffer.flip();
                    // 创建需要处理大小的字节数组
                    byte[] bytes = new byte[buffer.remaining()];
                    // 将缓冲区可读的字节数组复制到我们创建的字节数组中
                    buffer.get(bytes);
                    String body = new String(bytes, "UTF-8");
                    System.out.println("This time server receive message:" + body);
                    String result = body.equalsIgnoreCase("QUERY TIME ORDER")
                            ? new Date(System.currentTimeMillis()).toString() : "I don't know";
                    // 将请求结果返回给客户端
                    doWrite(channel, result);
                } else if (length < 0) {
                    // 如果小于0,说明链路已关闭,需要关闭管道、释放资源
                    key.channel();
                    channel.close();
                } else {
                    // 如果等于0,属于正常情况,忽略
                }
            }
        }
    }

    /**
     * 向客户端写数据
     *
     * @param channel
     * @param message
     * @throws IOException
     */
    private void doWrite(SocketChannel channel, String message) throws IOException {
        if (message != null && message.trim().length() > 0) {
            byte[] bytes = message.getBytes();
            ByteBuffer buffer = ByteBuffer.allocate(bytes.length);
            buffer.put(bytes);
            buffer.flip();
            channel.write(buffer);
        }
    }
}

NIO 客户端源码

public class NioClient implements Runnable {

    public static void main(String[] args) {
        // 启动 NIO 客户端
        new Thread(new NioClient()).start();
    }

    /**
     * 用来记录连接的地址
     */
    private static final String ADDRESS = "127.0.0.1";

    /**
     * 用来记录连接的端口
     */
    private static final int PORT = 8888;

    /**
     * 用来标识服务端是否停止
     */
    private boolean stop = false;

    /**
     * 声明多路复用器对象
     */
    Selector selector = null;

    /**
     * 客户端套接字
     */
    SocketChannel socketChannel = null;

    private NioClient() {
        try {
            // 初始化资源
            selector = Selector.open();
            socketChannel = SocketChannel.open();
            socketChannel.configureBlocking(false);
        } catch (IOException e) {
            // 初始化资源失败则关闭程序
            e.printStackTrace();
            System.exit(1);
        }
    }

    @Override
    public void run() {
        try {
            doConnect();
            while (!stop) {
                selector.select(1000);
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                SelectionKey key = null;
                while (iterator.hasNext()) {
                    key = iterator.next();
                    iterator.remove();
                    handleInput(key);
                }
            }
            if (selector != null) {
                selector.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 客户端连接服务器方法
     *
     * @throws IOException
     */
    private void doConnect() throws IOException {
        if (socketChannel.connect(new InetSocketAddress(ADDRESS, PORT))) {
            // 连接成功,将读就绪事件注册监听到管道
            socketChannel.register(selector, SelectionKey.OP_READ);
            doWrite(socketChannel);
        } else {
            // 连接失败,可能是由于客户端还没有收到服务器的 TCP 连接报文
            // 将连接就绪事件注册监听到管道
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
        }
    }

    /**
     * 处理就绪 Channel
     *
     * @param key
     */
    private void handleInput(SelectionKey key) throws IOException {
        if (key.isValid()) {
            SocketChannel channel = (SocketChannel) key.channel();
            // 判断是否连接就绪事件
            if (key.isConnectable()) {
                // 判断是否连接成功,此时可能收到服务端回复的 TCP 连接报文
                if (channel.finishConnect()) {
                    // 连接成功,将管道的读就绪事件注册到多路复用器
                    channel.register(selector, SelectionKey.OP_READ);
                    doWrite(socketChannel);
                } else {
                    // 连接失败,断开客户端程序
                    System.exit(1);
                }
            }
            if (key.isReadable()) {
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                int length = socketChannel.read(buffer);
                if (length > 0) {
                    buffer.flip();
                    byte[] bytes = new byte[buffer.remaining()];
                    buffer.get(bytes);
                    String message = new String(bytes, "UTF-8");
                    System.out.println("Received form Server:" + message);
                    // 一通完整的交互已完成,关闭客户端
                    this.stop = true;
                } else if (length < 0) {
                    key.channel();
                    socketChannel.close();
                } else {

                }
            }
        }
    }

    /**
     * 客户端发送消息方法
     *
     * @param socketChannel
     */
    private void doWrite(SocketChannel socketChannel) throws IOException {
        byte[] bytes = "QUERY TIME ORDER".getBytes();
        ByteBuffer buffer = ByteBuffer.allocate(bytes.length);
        buffer.put(bytes);
        buffer.flip();
        socketChannel.write(buffer);
        if (!buffer.hasRemaining()) {
            System.out.println("Send Message to Server Success");
        }
    }
}

执行结果

服务端:
This time server receive message:QUERY TIME ORDER

客户端:
Send Message to Server Success
Received form Server:Sat Oct 10 17:22:35 CST 2020

NIO 常见问题解释

下面我主要根据上述代码示例,对没有解释清楚的点做简要说明:

SelectionKey 是什么?

SelectionKey 表示选择键,其中它定义了四种事件,管道 Channel 根据不同的业务场景注册不同的选择键事件到多路复用器上,后续多路复用器会轮询监听这部分事件,如果事件发生,就将该选择键放入 selected-keys 集合中。

一个 Channel 可以注册监听多种事件,而每个事件只对应一个 Channel。其中 SelcetionKey 中定义了以下四种事件类型:

  • SelectionKey.OP_ACCEPT:接收连接就绪事件,表示服务端收到客户端连接请求
  • SelectionKey.OP_CONNECT:连接就绪事件,表示客户端与服务端连接就绪
  • SelectionKey.OP_READ:读就绪事件,表示通道中包含有可读消息
  • SelectionKey.OP_WRITE:写就绪事件,表示已经可以向通道写数据了

当出现以下三种情况,SelectionKey 将会失效,Selector 将不会再监听该选择键对应的管道事件:

  • 调用 SelectionKey.cannel() 方法,关闭该选择键
  • SelectionKey 所对应的管道 Channel 已关闭
  • Selector 多路复用器已关闭

简单来说一个 SelectionKey 会绑定一个 Channel 以及对应监听事件,后续将 Channel 注册到多路复用器上,多路复用器轮询检测监听事件,如果事件就绪就返回。

select() 和 selectedKeys()

select() 方法可以唤醒多路复用器,轮询所有注册的选择键,返回其中就绪的选择键的数量。

  • 无参调用该方法会阻塞,直到产生就绪事件才向下执行。
  • 有参调用不会阻塞,而是每隔 x 毫秒轮询一次,无论有无就绪选择键都会向下执行。

selectedKeys() 方法可以返回当前就绪的选择键集合,该方法一般配合 select() 方法一起使用。

channel.read(buffer) 方法为什么要判断返回值长度?

NIO 在非阻塞模式下,channel.read() 方法是非阻塞,因此可能存在以下三种情况:

  • length > 0:说明确实读取到数据,按正常模式处理
  • length = 0:未读取到数据,可能此时由于非阻塞的原因,数据还未返回,属于正常情况,不做任何处理
  • length < 0:说明此时数据链路以断开,通过 close() 方法关闭管道

这里需要注意的一点是,length = 0 时,虽然本轮循环不能读取到任何数据,但不会造成数据的丢失。因为 selectedKeys() 方法每次返回就绪的选择键,如果管道中的数据还没有被读取,那么这个选择键就一直有效,下一轮循环还可以继续判断。

buffer.flip() 方法详解

想要理解缓冲区的 flip() 方法,我们得首先弄懂以下三个概念:

  • capacity:在读/写模式下都是固定的,表示缓冲区的最大容量
  • position:指针,表示当前读/写到什么位置
  • limit:在写模式下表示最多写多少内存,此时和 capacity 相等、在读模式下表示最多读多少内存,此时和 position 相等

缓冲区 buffer 根据不同的业务场景存在两种模式:写模式 和 读模式。在写模式下调用 flip() 方法会切换到读模式,此时 limit 设置为 position,表示最多可以读多少数据,position 置为0,表示从缓存头开始读取。其中 flip() 方法源码如下:

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

也就是说调用 flip() 之后,position 重置到缓冲区头部,limit 设置到之前写到的位置,保证每次只能读取到已经写入的长度,而不是整个缓冲池。

其它知识

System.exit(0) 和 System.exit(1) 的区别:System.exit() 方法用来结束当前运行的虚拟机,System.exit(0) 表示正常退出,而当参数非0时,表示非正常退出。

一般情况下,System.exit(0) 写在正常逻辑代码中,表示正常退出。而 System.exit(1) 写在 catch 捕获代码块中,表示非正常退出。


NIO 优势与劣势

相比传统 IO 模式,NIO 具有如下优势:

  • 客户端发起连接操作是异步的,不会像传统 IO 模型那样被阻塞
  • 管道读写操作都是异步的,如果没有可读写的数据它不会同步等待,可以优先处理其它就绪的链路
  • 线程模型更简单,只需要一个多路复用线程就可以处理大量的客户端连接

不过相比传统 IO 模型,NIO 开发太过复杂,需要编写大量的公共处理代码。并且实际应用场景中,我们还必须考虑 读/写 半包等问题。

读/写半包:NIO 的读写操作都基于缓冲区完成,由于服务端缓存区大小限制以及网速不均匀等原因,很有可能造成读取或写入的数据不完整,此时就有可能造成半包问题。
一般情况下,通过在数据包中记录数据长度,根据读取到的长度做比较解决该问题。

由于上述示例比较简单,暂时不涉及读写半包问题,关于读写半包问题的解决方案后序我们在其它博客中展开,关于NIO的介绍先写到这里。


参考
《Netty 权威指南》
https://blog.csdn.net/wangmx1993328/article/details/83212473
https://blog.csdn.net/hbtj_1216/article/details/53129588
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值