likes
comments
collection
share

面试上岸篇之五种常见的 IO 模型与NIO

作者站长头像
站长
· 阅读数 4

用户空间与内核空间

在正文开始之前,先了解一下什么是内核空间,什么是用户空间。

内核空间用户空间是操作系统中的两个关键概念,它们在运行时具有不同的特性和权限。

  1. 内核空间(Kernel Space):
    • 内核空间是操作系统内核运行的区域,包括了操作系统内核代码、数据结构和设备驱动程序等。
    • 内核空间通常是操作系统中的一块受保护的内存区域,只有操作系统内核才能够访问这个区域。
    • 在内核空间中,操作系统可以执行任意命令,调用系统的一切资源。
  2. 用户空间(User Space):
    • 用户空间是指用户应用程序运行的区域,包括用户应用程序代码、数据和堆栈等。
    • 用户空间中的普通应用程序可以直接访问,但不能直接访问硬件设备
    • 用户空间中的进程运行在用户态,受到一定的限制,不能执行一些特权操作。

为了安全和隔离,内核空间和用户空间是相互隔离的。即使用户的程序崩溃了,内核也不受影响。用户空间的应用程序需要通过系统接口(也称为系统调用)向内核发出指令,以便访问内核空间的功能和资源。

例如,当应用程序需要进行文件写入时,它必须切换到内核空间,因为用户空间不能直接访问文件系统。内核空间负责处理文件写入操作,然后再切换回用户空间。

面试上岸篇之五种常见的 IO 模型与NIO

DMA(直接存储器访问)是计算机系统中的一项功能,允许某些硬件子系统独立于中央处理单元(CPU)访问主系统内存。

五种 IO 模型

阻塞IO(Blocking I/O)

在内核将数据准备好之前,系统调用会一直等待所有的套接字。这个模型是我们最常见的,程序调用和我们编写的基本程序是一致的。一些传统的输入输出流基本都是阻塞 IO 模型。

面试上岸篇之五种常见的 IO 模型与NIO

举个例子:假如你有个女朋友,你们约好去约会,你女朋友要先化个妆,你在楼下等她,期间啥都不能做,就干等,你女朋友化了 2 个小时终于化完了,然后下楼,你们一起愉快的去约会了

非阻塞IO(Non-blocking I/O)

在没有数据报准备好时,也不阻塞程序,内核直接返回未准备就绪的信号,等待用户程序的下一个轮询。但是,轮询对于CPU来说是较大的浪费,一般只有在特定的场景下才使用。

面试上岸篇之五种常见的 IO 模型与NIO

还是假如你有个女朋友,你女朋友去化妆了,然后你就去做其他事情了,然后每隔 5 分钟给她打个电话,问她化完了没,如果没化完就继续做其他事情,如果化完了,就去陪她约会

信号驱动IO(Signal-driven I/O)

应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。

面试上岸篇之五种常见的 IO 模型与NIO

信号驱动看似很好,但是缺点也很大

优点:

  • 相较于非阻塞IO,信号驱动IO具有资源利用更加充分的优势。
  • 信号到来后就直接强行中断进行处理,更加实时。

缺点:

  • 信号驱动IO的流程较为复杂,因为需要自定义信号,又要有主控流程也要有信号处理流程,并且还需要考虑信号是否可靠导致的事件丢失情况。
  • 无法指定需要监控的事件类型。例如,在TCP套接口中,信号驱动IO几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么事情。
  • 总的来说,信号驱动IO模型在某些场景下可以提供较高的性能,但其实现较为复杂,且在某些应用中可能不太适用。

还是假如你有个女朋友,你跟她说,她化完妆然后打你的电话通知你,这你就不用每隔一段时间去打电话问她了,这电话一来一回也是需要时间的。如果她来信息了,哪怕你是拉屎拉一半都要暂停拉屎,去跟她约会

IO多路转接(I/O Multiplexing

IO多路转接是多了一个select函数,select函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时,就对这个文件描述符进行处理。

  • select

    • select函数监视的文件描述符分3类,分别是writefdsreadfds、和exceptfds
    • 当用户进程调用select的时候,select会将需要监控的readfds集合拷贝到内核空间,然后遍历自己监控的socket buffer,挨个调用socket buffer的poll逻辑以便检查该socket是否有可读事件
    • 遍历完所有的socket buffer后,如果没有任何一个socket可读,那么select会调用schedule_timeout进入schedule循环,使得进程进入睡眠。
    • 如果在timeout时间内某个socket上有数据可读了,或者等待timeout了,则调用select的进程会被唤醒,接下来select就是遍历监控的集合,挨个收集可读事件并返回给用户。
  • poll

    • poll函数与select函数类似,但是没有最大连接数的限制,因为poll使用的是链表结构而不是select使用的数组结构
    • poll函数同样也会将需要监控的文件描述符集合拷贝到内核空间,然后遍历自己监控的socket buffer,挨个调用socket buffer的poll逻辑以便检查该socket是否有可读事件。原理同上。
  • epoll

    • epoll是Linux特有的IO多路复用机制,它的效率比select和poll更高。

    • epoll使用一组函数(epoll_createepoll_ctlepoll_wait)来完成任务。

    • 当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果;内核把整个IO处理完后,会通知进程结果。

    • 如果IO操作成功则进程直接获取到数据。

    • epoll模型(selector模型)还有不足,就是如果当epoll_wait方法返回了10w个就绪事件,就需要等待这10w个就绪事件处理完成,才能继续下面的命令,去响应新的事件,这样就容易让新的事件超时。

    还是假如你有个女朋友,select 就是宿管阿姨,她监听着每个宿舍,每隔一段时间去看你的女朋友们化好妆了没,发现谁的女朋友化好妆了就通知她男朋友过来接她(这里就是相当于 select 监听这每个文件描述符,如果数据已经完全加载到了内核缓冲区中,文件描述符的状态就会从 0 改成 1,select 每次都遍历所有的文件描述符,发现文件描述符改成1 之后,就通知它的调用线程来进行后续处理)

异步IO(Asynchronous I/O)

  1. 基本概念

    • 在异步I/O模型中,应用程序发起I/O请求后,内核会立即返回,而不会阻塞应用程序。
    • 内核会在I/O操作完成后通知应用程序,以便获取I/O结果。
  2. 特点

    • 非阻塞:应用程序不会被I/O操作阻塞,可以继续执行其他任务。
    • 高并发:一个线程可以同时处理多个I/O请求,无需切换线程。
    • 数据一步到位:I/O操作成功后,应用程序直接获取数据。

NIO

很多地方说 NIO 也属于非阻塞 IO 的一种,它是一种同步非阻塞 IO,笼统来说也确实是。

NIO 是从 Java 1.4 版本开始引入的一个新的 I/O API,它可以替代标准的 Java I/O API。虽然 NIO 和原来的 I/O 有相同的作用和目的,但使用方式完全不同。让我们逐步了解一些核心概念:

通道和缓冲区

  • **通道(Channel):**表示打开到 I/O 设备(例如文件、套接字)的连接。在使用 NIO 系统时,我们需要获取用于连接 I/O 设备通道以及用于容纳数据的缓冲区。。
  • **缓冲区(Buffer):**是一个用于特定基本数据类型的容器。Java NIO 中的所有缓冲区都是 Buffer 抽象类的子类。它们用于存储不同类型的数据,例如 ByteBuffer、CharBuffer、ShortBuffer 等。

简而言之,通道负责传输,缓冲区负责存储。好比于通道是一条铁路,缓冲区是一辆火车,火车就装着数据,一车一车运过去

面向流和面向缓冲区

  • **传统 I/O 流:**原来的 I/O 面对的是直接在管道中流动的字节数据。它是单向的,需要建立输入流输出流
  • **NIO:**NIO 也是为了完成数据传输,但它使用通道连接目标地点和源地点。通道本身不能传输数据,必须依赖缓冲区。缓冲区就像火车,通过通道进行数据传输。

缓冲区的数据存取

  • 缓冲区提供了 put() 和 get() 方法来存取数据。
  • 缓冲区有四个核心属性:
    • 容量(capacity):表示缓冲区中最大存储数据的容量,一旦声明后不能更改。
    • 界限(limit):表示缓冲区中可以操作数据的大小,limit 后的数据不能进行读写。
    • 位置(position):表示缓冲区中正在操作数据的位置。
    • 标记(mark):记录当前 position 的位置,可以通过 reset() 恢复到 mark 的位置。
public class TestBuffer {
    public static void main(String[] args) {
        String str = "abcde";
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        System.out.println("Capacity: " + byteBuffer.capacity()); // 1024
        System.out.println("Limit: " + byteBuffer.limit()); // 1024
        System.out.println("Position: " + byteBuffer.position()); // 0
        byteBuffer.put(str.getBytes()); // 写入缓存
        byteBuffer.flip(); // 切换为读模式 默认是写模式
        System.out.println("Limit: " + byteBuffer.limit()); // 5
        System.out.println("Position: " + byteBuffer.position()); // 0
        byte[] dst = new byte[byteBuffer.limit()];
        byteBuffer.get(dst); // 读取
        System.out.println(new String(dst, 0, dst.length)); // "abcde"
        byteBuffer.rewind();
        System.out.println("Position after rewind: " + byteBuffer.position()); // 0
    }
}

NIO 使用案例

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class NIOFileReaderExample {
    public static void main(String[] args) {
        // 定义文件路径
        Path filePath = Paths.get("file.txt");

        try (FileChannel fileChannel = FileChannel.open(filePath, StandardOpenOption.READ)) {
            // 创建一个缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(1024);

            // 从文件通道读取数据到缓冲区
            int bytesRead;
            while ((bytesRead = fileChannel.read(buffer)) != -1) {
                buffer.flip(); // 切换到读模式
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get()); // 逐字节打印到控制台
                }
                buffer.clear(); // 清空缓冲区,准备下一次读取
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

疑问?

既然NIO这么牛,为什么我在做项目的时候却几乎没有用到呢?

  1. 历史原因
    • 传统的 IO 是 Java 最早引入的 I/O 操作方式,因此在许多旧项目中仍然广泛使用。
    • 开发人员可能对传统的 IO 更熟悉,因此不愿意迁移到新的 NIO。代码能跑就不动。
  2. 简单性
    • 传统的 IO 提供了简单的、阻塞式的 I/O 模型,适用于许多简单的场景。
    • 对于小规模的文件读写,IO 流的使用更加直观
  3. NIO 的复杂性
    • NIO 引入了更复杂的概念,如通道、缓冲区和选择器。
    • 使用 NIO 需要更多的代码和理解,因此在某些情况下,开发人员可能更愿意使用传统的 IO。
  4. 性能考虑
    • NIO 的设计目标是提高 I/O 操作的效率,特别是在处理大量并发连接时。
    • 但并不是所有项目都需要高度并发的能力。对于简单的文件读写,传统的 IO 也足够。

NIO 适用于什么类型的场景

  1. 高并发连接
    • NIO 适合处理大量并发连接,例如聊天室、即时通讯、多人在线游戏等。
    • 通过 NIO 的非阻塞模式,可以使用一个线程来处理多个连接,减少线程开销。
  2. 长连接
    • 长连接指的是客户端和服务器之间保持持久连接的情况,例如 WebSocket、推送服务等。
    • NIO 可以有效地管理多个长连接,而不需要为每个连接创建一个线程。
  3. 大文件处理
    • 如果需要处理大文件,NIO 的缓冲区和通道机制可以提高效率。
    • 通过 NIO,可以将文件分块读取到缓冲区,然后进行处理。
  4. 网络编程
    • NIO 适用于网络编程,特别是需要高伸缩性的网络应用。
    • 例如 Web 服务器、代理服务器、负载均衡器等。
  5. 异步操作
    • NIO 的选择器(Selector)允许异步地处理多个通道上的事件。
    • 适用于需要同时处理多个读写事件的场景。