查看原文
其他

图解 NIO 工作原理,太硬核了!

潘志的研发笔记 潘志的研发笔记
2024-08-30

一、简介

现在使用 NIO 的场景越来越多,很多网上的技术框架或多或少的使用 NIO 技术,譬如 Tomcat、Jetty、Netty,学习和掌握 NIO 技术已经不是一个 Java 攻城狮的加分技能,而是一个必备技能。

那什么是 NIO 呢?

NIO,英文全称 Non-blocking I/O,在 Java 领域,也称为 New I/O,是一种同步非阻塞的 I/O 模型,是解决高并发、I/O 高性能的有效方式,已经被越来越多地应用到大型应用服务器中。

既然 NIO 如此的受欢迎,那么它的本质是什么?又是如何实现高性能的呢?

带着这个问题,我们先从传统的阻塞 I/O 说起,在一步一步的分析 NIO 是如何利用非阻塞模式来解决大量请求带来的性能瓶颈问题。

二、传统 BIO 的瓶颈

在介绍 NIO 之前, 让我们先回顾一下传统的服务器端同步阻塞 I/O (也称为 BIO,英文全称 blocking I/O)的经典编程模型。

采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听所有客户端的连接,当服务端接受到多个客户端的链接请求时,通常所有的客户端请求需要排队等待服务端一个一个的处理。

BIO 简易通信模型图如下!

一般在服务端通过while(true)循环中会调用accept() 方法监听客户端的连接,一旦接收到一个连接请求,就可以建立通信套接字进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成再处理下一个连接请求。

下面是一个简易版的 BIO 服务端,示例程序

public class BioServer {

    public static void main(String[] args) throws IOException {
        // 初始化服务端socket并且绑定 8080 端口
        ServerSocket serverSocket = new ServerSocket(8080);
        // 循环监听所有客户端的请求接入
        while (true) {
            // 监听客户端请求
            Socket socket = serverSocket.accept();
            // 读取客户端发送的请求数据
            InputStream in = socket.getInputStream();
            byte[] buffer = new byte[1024];
            int len;
            while ((len = in.read(buffer))!=-1){
                System.out.println(new String(buffer,0,len));
            }
            System.out.println("接收完毕");
            // 关闭流
            in.close();
        }
    }
}

下面是一个简易版的 BIO 客户端,示例程序

public class BioClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1",8080);
        OutputStream out = socket.getOutputStream();
        out.write("Hello,this is Client".getBytes());
        out.close();
        socket.close();
    }
}

随着客户端的请求次数增多,可能需要排队的时间会越来越长,用户等待的时间越久,体验感就越差。

因此有的大仙就将服务端的编程模型进行了适当的改造,引入多线程方式来处理客户端的请求,从而实现了 N (客户端请求数量)大于 M (服务端处理客户端请求的线程数量)的 I/O 模型

改造后的 IO 模型图,如下图:

采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,当有新的客户端接入时,将客户端的 Socket 封装成一个 Task 投递到线程池中进行处理。

下面是一个简易版的改造后 BIO 服务端,示例程序

public class BioServerTest {

    public static void main(String[] args) throws IOException {
        //在线程池中创建5个固定大小线程,来处理客户端的请求
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        //初始化服务端socket并且绑定 8080 端口
        ServerSocket serverSocket = new ServerSocket(8080);
        //循环监听客户端请求
        while (true) {
            // 监听客户端请求
            Socket socket = serverSocket.accept();
            //使用线程池处理多个任务
            executorService.execute(new Runnable() {

                @Override
                public void run() {
                    // 读取客户端发送的请求数据
                    InputStream in = socket.getInputStream();
                    byte[] buffer = new byte[1024];
                    int len;
                    while ((len = in.read(buffer))!=-1){
                        System.out.println(new String(buffer,0,len));
                    }
                    System.out.println("接收完毕");
                    // 关闭流
                    in.close();
                }
            });
        }
    }
}

服务端使用线程池来处理客户端的请求,线程数量为 5 个,由于线程池可以设置消息队列的大小和最大线程数,因此它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

服务端的编程模型进行改造后,处理客户端的请求速度确实提升了不少。

之所以使用多线程,主要原因在于socket.accept()socket.read()socket.write()三个主要函数都是同步阻塞的,当一个连接在处理 I/O 的时候,系统是阻塞的,如果是单线程的话,必然所有的任务等都挂在它名下,处理效率低下;引入多线程之后,等待的资源就可以释放出来,充分发挥 CPU 多任务的并发处理能力。

在活动连接数不是特别高的情况下,这种编程模型还是不错的,不用过多考虑系统的过载、限流等问题。

但是呢,这个模型也有弊端,底层还是 BIO 模型,严重依赖于线程,在操作系统中,我们知道线程是很"昂贵"的资源,主要表现在以下几点:

  • 1.线程的创建和销毁成本很高,在 Linux 这样的操作系统中,线程本质上就是一个进程,创建和销毁都是重量级的系统函数
  • 2.线程本身占用较大内存,像 Java 的线程栈,一般至少分配 512K~1M 的空间,如果系统中的线程数过千,恐怕整个 JVM 的内存都会被吃掉一半
  • 3.线程的切换成本也很高,操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用,如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统 load 偏高、CPU 系统使用率特别高,导致系统几乎陷入不可用的状态

最重要的是,当面对十万甚至百万级请求接入的时候,传统的 BIO 模型真的无能为力,随着移动端应用的兴起和各种网络游戏的盛行,百万级的请求接入非常普遍,因此我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

三、NIO 解析

在上文中我们也提到,BIO 是同步阻塞的 IO 模型NIO 是同步非阻塞的 IO 模型,仅仅一个字的差别,它们到底有何不同呢?

首先我们来看看几种常见 I/O 模型!

在 Linux 操作系统上,以发起读取数据为例:

  • 在传统的 BIO 中,也就是同步阻塞 IO 模型,当系统调用recvfrom()函数时,如果里面没有数据,函数会一直阻塞,直到收到数据,最后返回读到的数据。
  • 而在 NIO 中,也就是同步非阻塞 IO 模型,当系统调用recvfrom()函数时,如果里面有数据,就把数据读取出来并返回;如果没有就返回一个EWOULDLOCK标记,永远不会阻塞。
  • 同时还有最新的 AIO,它是一种异步非阻塞的 IO 模型,主要基于事件回调机制来实现,当系统调用recvfrom()函数时,不管里面有没有数据,直接返回结果;当后台处理完成后,操作系统会通知相应的线程进行后续的操作。

通俗的说,在读取数据的过程中,BIO 只关注“我要读”,NIO 只关注“我可以读了”,而 AIO 只关注“我读完了”。

其中 NIO 一个很重要的特点就是:采用单线程非阻塞的编程方式来处理客户端所有的连接请求,虽然执行过程比较消耗 CPU,但性能非常的高

那 NIO 具体是如何实现的呢?下面我们一起来看看!

3.1、NIO 工作原理介绍

与传统的 BIO 不同,NIO 新增了 Channel、Selector、Buffer 等抽象概念,支持面向缓冲、基于通道的 I/O 数据传输方法

NIO 简易模型图,如下:

与此同时,NIO 还提供了与传统 BIO 模型中的 SocketServerSocket 相对应的 SocketChannelServerSocketChannel 两种不同的套接字通道实现。

NIO 这两种通道都支持阻塞和非阻塞两种模式,阻塞模式使用就像传统中的 BIO 一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反

对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发效率和更好的维护性;对于高负载、高并发的网络应用,可以使用 NIO 的非阻塞模式来开发,可以显著的提升数据传输效率。

NIO 在 Java 1.4 中引入,对应的代码实现在java.nio包下,涉及到的核心类关联关系,如下图:

上图中有三个关键类:Channel 、Selector 和 Buffer,它们是 NIO 中的核心概念。

  • Channel:可以理解为通道
  • Selector:可以理解为选择器
  • Buffer:可以理解为数据缓冲区

刚接触 NIO 的同学,当第一眼看到 Channel、Selector、Buffer 等抽象概念,可能感觉难以理解,下面我们还是用之前介绍的城市交通工具来继续形容 一下 NIO 的工作方式。

这里的 Channel 要比 Socket 更加具体,它可以比作为某种具体的交通工具,如汽车、高铁或者飞机等,而 Selector 可以比作为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态,是已经出站还是在路上等等,也就是说它可以轮询每个 Channel 的状态。

还有一个 Buffer 类,你可以将它看作为 IO 中 Stream,但是它比 IO 中的 Stream 更加具体化,我们可以将它比作为车上的座位,Channel 如果是汽车的话,那么 Buffer 就是汽车上的座位,Channel 如果是高铁上,那么 Buffer 就是高铁上的座位,它始终是一个具体的概念,这一点与 Stream 不同。

NIO 引入了 Channel、Buffer 和 Selector 就是想把 IO 传输过程中涉及到的信息具体化,让程序员有机会去控制它们。

当我们进行传统的网络 IO 操作时,比如调用write()往 Socket 中的SendQ队列写数据时,当一次写的数据超过SendQ长度时,操作系统会按照SendQ 的长度进行分割的,这个过程中需要将用户空间数据和内核地址空间进行切换,而这个切换不是程序员可以控制的,由底层操作系统来帮我们处理。

而在Buffer中,我们可以控制Buffercapacity(容量),并且是否扩容以及如何扩容都可以控制。

讲了这么多,可能有的同学感觉,依然很难理解,下面我们直接来看看具体的代码实现。

下面是一个简易版的 NIO 服务端,示例程序

/**
 * NIO 服务端
 */

public class NioServerTest {

    public static void main(String[] args) throws IOException {
        // 打开服务器套接字通道
        ServerSocketChannel ssc = ServerSocketChannel.open();
        // 服务器配置为非阻塞
        ssc.configureBlocking(false);
        // 进行服务的绑定,监听8080端口
        ssc.socket().bind(new InetSocketAddress(8080));

        // 构建一个Selector选择器,并且将channel注册上去
        Selector selector = Selector.open();
        // 将serverSocketChannel注册到selector,并对accept事件感兴趣(serverSocketChannel只能支持accept操作)
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while (true){
            // 查询指定事件已经就绪的通道数量,select方法有阻塞效果,直到有事件通知才会有返回,如果为0就跳过
            int readyChannels = selector.select();
            if(readyChannels == 0) {
                continue;
            };
            //通过选择器取得所有key集合
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectedKeys.iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                //判断状态是否有效
                if (!key.isValid()) {
                    continue;
                }
                if (key.isAcceptable()) {
                    // 处理通道中的连接事件
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel sc = server.accept();
                    sc.configureBlocking(false);
                    System.out.println("接收到新的客户端连接,地址:" + sc.getRemoteAddress());

                    // 将通道注册到选择器并处理通道中可读事件
                    sc.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 处理通道中的可读事件
                    SocketChannel channel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    while (channel.isOpen() && channel.read(byteBuffer) != -1) {
                        // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
                        if (byteBuffer.position() > 0) {
                            break;
                        };
                    }
                    byteBuffer.flip();

                    //获取缓冲中的数据
                    String result = new String(byteBuffer.array(), 0, byteBuffer.limit());
                    System.out.println("收到客户端发送的信息,内容:" + result);

                    // 将通道注册到选择器并处理通道中可写事件
                    channel.register(selector, SelectionKey.OP_WRITE);
                } else if (key.isWritable()) {
                    // 处理通道中的可写事件
                    SocketChannel channel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    byteBuffer.put("server send".getBytes());
                    byteBuffer.flip();
                    channel.write(byteBuffer);

                    // 将通道注册到选择器并处理通道中可读事件
                    channel.register(selector, SelectionKey.OP_READ);
                    //写完之后关闭通道
                    channel.close();
                }
                //当前事件已经处理完毕,可以丢弃
                iterator.remove();
            }
        }
    }
}

下面是一个简易版的 NIO 客户端,示例程序

/**
 * NIO 客户端
 */

public class NioClientTest {

    public static void main(String[] args) throws IOException {
        // 打开socket通道
        SocketChannel sc = SocketChannel.open();
        //设置为非阻塞
        sc.configureBlocking(false);
        //连接服务器地址和端口
        sc.connect(new InetSocketAddress("127.0.0.1"8080));
        while (!sc.finishConnect()) {
            // 没连接上,则一直等待
            System.out.println("客户端正在连接中,请耐心等待");
        }

        // 发送内容
        ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
        writeBuffer.put("Hello,我是客户端".getBytes());
        writeBuffer.flip();
        sc.write(writeBuffer);

        // 读取响应
        ByteBuffer readBuffer = ByteBuffer.allocate(1024);
        while (sc.isOpen() && sc.read(readBuffer) != -1) {
            // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
            if (readBuffer.position() > 0) {
                break;
            };
        }
        readBuffer.flip();

        String result = new String(readBuffer.array(), 0, readBuffer.limit());
        System.out.println("客户端收到服务端:" + sc.socket().getRemoteSocketAddress() + ",返回的信息:" + result);

        // 关闭通道
        sc.close();
    }
}

最后,依次启动服务端、客户端,看看控制台输出情况如何。

服务端控制台结果如下:

接收到新的客户端连接,地址:/127.0.0.1:57644
收到客户端发送的信息,内容:Hello,我是客户端

客户端控制台结果如下:

客户端收到服务端:/127.0.0.1:8080,返回的信息:server send

从编程上可以看到,NIO 的操作比传统的 IO 操作要复杂的多

Selector 被称为选择器 ,当然你也可以翻译为多路复用器 。它是 Java NIO 核心组件中的一个,用于检查一个或多个 Channel(通道)的状态是否处于连接就绪接受就绪可读就绪可写就绪

如此可以实现单线程管理多个 channel 的目的,也就是可以管理多个网络连接。

使用 Selector 的好处在于 :相比传统方式使用多个线程来管理 IO,Selector 只使用了一个线程就可以处理所有的通道,从而实现网络高效传输!

同时,Java 对Selector还做了优化处理,当调用Selector.select()方法时有阻塞的效果,当没有Channel的时候,不会一直不停的去空循环,避免消耗 CPU 资源,这个功能需要操作系统来支持。

针对不同的操作系统,Java 会执行对应的系统调用(Linux 2.6 之前是 select、poll,2.6 之后是 epoll,Windows 是 IOCP),当有新事件到来的时候才会返回。

所以,你可以放心大胆地在一个while(true)里面调用这个函数而不用担心 CPU 空转。

3.2、Buffer 类详解

接着我们再来说说 Buffer,也称为缓冲区,在 Java NIO 中负责数据的存取。

Buffer 其实是一个数组,可以用于存储不同类型的数据,根据数据类型的不同(boolean 除外),提供了相应类型的缓冲区类,类关系图如下:

上述的子类,管理方式几乎一致,都可以通过allocate()静态方法来获取堆内缓冲区对象。

Buffer 有 2 个核心方法和 4 个核心属性,下面我们一起来看看。

核心方法,内容如下:

  • put():存入数据到缓冲区中
  • get():从缓冲区中的读取数据

核心属性,内容如下:

  • capacity:容量,表示缓冲区中最大存储数据的容量,一旦声明不能更改
  • limit:界限,表示缓冲区中可以操作数据的最大界限
  • position:位置,表示缓冲区中正在操作数据的位置
  • mark:标记,可以使用mark()方法记录当前position的位置,后续可以通过reset()方法恢复到mark标记的位置

以分配一个大小为 10 个字节容量的缓冲区,向里面写入abcde并读取数据为例,Buffer 处理过程如下图:

有几个地方,需要特别注意:

  • 0 <= mark <= position <= limit <= capacity
  • limit后的数据不能进行读写

下面是一些关于 Buffer 类相关操作示例介绍!

  • put 和 get 方法基本操作,示例程序
public static void main(String[] args) {
    String str = "abcde";

    //分配一个指定大小的缓冲区
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    System.out.println("---------allocate-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //1024
    System.out.println(byteBuffer.position());   //0

    //利用 put() 存入数据到缓冲区中
    byteBuffer.put(str.getBytes());
    System.out.println("---------put-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //1024
    System.out.println(byteBuffer.position());   //5

    //切换到读数据模式
    byteBuffer.flip();
    System.out.println("---------flip-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //5,limit 表示可以操作数据的大小,只有 5 个字节的数据给你读,所以可操作数据大小是 5
    System.out.println(byteBuffer.position());   //0,读数据要从第 0 个位置开始读

    //利用 get() 读取缓冲区中的数据
    byte[] dst = new byte[byteBuffer.limit()];
    byteBuffer.get(dst);
    System.out.println(new String(dst,0,dst.length));
    System.out.println("---------get-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //5,可以读取数据的大小依然是 5 个
    System.out.println(byteBuffer.position());   //5,读完之后位置变到了第 5 个

    //rewind() 可重复读
    byteBuffer.rewind();         //这个方法调用完后,又变成了读模式
    System.out.println("---------rewind-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //5
    System.out.println(byteBuffer.position());  //0

    //clear() 清空缓冲区,虽然缓冲区被清空了,但是缓冲区中的数据依然存在,只是出于"被遗忘"状态。意思其实是,缓冲区中的界限、位置等信息都被置为最初的状态了,所以你无法再根据这些信息找到原来的数据了,原来数据就出于"被遗忘"状态
    byteBuffer.clear();
    System.out.println("---------clear-----------");
    System.out.println(byteBuffer.capacity());   //1024
    System.out.println(byteBuffer.limit());      //1024
    System.out.println(byteBuffer.position());  //0
}

输出结果如下:

---------allocate-----------
1024
1024
0
---------put-----------
1024
1024
5
---------flip-----------
1024
5
0
abcde
---------get-----------
1024
5
5
---------rewind-----------
1024
5
0
---------clear-----------
1024
1024
0
  • mark 方法基本操作,示例程序
public static void main(String[] args) {
    String str = "abcde";

    // 写数据
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    byteBuffer.put(str.getBytes());

    // 读取指定几个数据
    byteBuffer.flip();
    byte[] byteArray = new byte[byteBuffer.limit()];
    byteBuffer.get(byteArray,0,2);
    System.out.println(new String(byteArray,0,2));  //结果是 ab
    System.out.println(byteBuffer.position());   //结果是 2

    // 利用mark()标记一下当前 position 的位置
    byteBuffer.mark();
    byteBuffer.get(byteArray,2,2);
    System.out.println(new String(byteArray,2,2));
    System.out.println(byteBuffer.position());   //结果是 4

    // 利用 reset() 恢复到 mark 的位置
    byteBuffer.reset();
    System.out.println(byteBuffer.position());   //结果是 2

    //判断缓冲区中是否还有剩余数据
    if (byteBuffer.hasRemaining()) {
        //获取缓冲区中可以操作的数量
        System.out.println(byteBuffer.remaining());  //结果是 3,上面 position 是从 2 开始的
    }
}

输出结果如下:

ab
2
cd
4
2
3
  • allocate 和 allocateDirect 的区别

在 Buffer 类中,除了有allocate()静态方法可以创建对象外,还有allocateDirect()也可以创建对象。

它们直接最明显的区别是:allocate()创建的对象,是建立在 JVM 的内存之中的,也称为堆内内存;allocateDirect()创建的对象,是建立在 JVM 的内存之外的,不会占用 JVM 的内存空间,也称为堆外内存,更具体的说是建立到物理内存之中,由操作系统来代管。

示例程序如下:

public static void main(String[] args) {
    // 分配直接缓冲区
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
    // 判断是直接缓冲区还是非直接缓冲区
    System.out.println(byteBuffer.isDirect());
}

输出结果:

true

JVM 对堆外对象的管理,主要是通过操作系统与物理内存之间建立一份映射文件,以此来扩充或者回收物理内存空间。

此外,JVM 堆内内存和堆外内存的区别,我们可以用如下的例子来解释。

以 Java 程序读取磁盘文件为例,操作系统出于安全的考虑,当应用程序向操作系统发起读取磁盘数据的操作时,首先是操作系统会将从磁盘读取的数据存放到内核空间,然后 CPU 再将内核空间的数据复制到 JVM 内存中,以供 Java 程序使用,在 JVM 中的数据,都属于堆内内存。

还有另一种方式,就是省掉 CPU 将内核空间的数据复制到 JVM 内存的过程,当操作系统将从磁盘读取的数据存放到内核空间后,Java 程序通过映射文件直接操作物理内存的数据,效率上会有所提高,在 JVM 中的数据,都属于堆外内存。

那是不是所有的对象都可以直接采用堆外内存呢?

通过allocateDirect()方式分配的内存,比起 JVM 内存的分配要耗时得多,所以并非不论什么时候使用allocateDirect()的操作效率都是最高的。

以拷贝文件为例,从实际的使用情况看,当操作的数据量很小时,两种操作使用时间基本是同样的,第一种方式有时可能会更快些;当操作的数据量大,比如大文件,堆外内存有优势。

四、小结

最后总结一些,NIO 相比传统的 BIO 模型,最大的不同点在于:采用单线程非阻塞的编程方式来处理客户端所有的连接请求,虽然执行过程比较消耗 CPU,但性能非常的高。

不过 java NIO 并没有完全屏蔽平台的差异,它仍然是基于各个操作系统的 I/O 系统实现的,差异仍然存在。

其次 JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%!

最后如果自己使用 NIO 做网络编程并不容易,自行实现的 NIO 很容易出现各类 bug,陷阱重重,维护成本较高。

推荐大家使用成熟的 NIO 框架,比如 Netty,MINA 等。解决了很多 NIO 的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。

五、参考

1、美团技术团队 - Java NIO浅析

1、hepingfly - Java 中 NIO 看这一篇就够了

写到最后

最后感谢各位的阅读,原创不易,如果喜欢文章,欢迎大家转发,点击【在看】让更多的人看到,谢谢大家的支持!

往期精选


继续滑动看下一个
潘志的研发笔记
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存