IO

网络IO

BIO

  • 同步阻塞IO
  • 每连接一线程:每个客户端连接需要一个独立的线程处理,线程的创建和销毁开销较大。
  • 应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。

NIO

  • 同步非阻塞模型
  • 单线程处理多连接:基于Selector(选择器)实现多路复用,一个线程可监控多个通道(Channel)的就绪事件。
  • 面向缓冲区(Buffer):数据通过缓冲区(ByteBuffer)进行块传输,支持双向读写。
  • 服务器实现一个连接一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4之后开始支持。

Channel(通道)

  • Channel是一个对象,可以通过它读取和写入数据。通常我们都是将数据写入包含一个或者多个字节的缓冲区,然后再将缓存区的数据写入到通道中,将数据从通道读入缓冲区,再从缓冲区获取数据。
  • Channel 类似于原I/O中的流(Stream),但有所区别:流是单向的,通道是双向的,可读可写。流读写是阻塞的,通道可以异步读写。

多路复用器

  • IO多路复用属于阻塞IO,但可以对多个文件描述符进行阻塞监听,所以效率较阻塞IO的高。允许一个进程/线程监视多个文件描述符(sockets, pipes, 设备文件等),当其中任何一个或多个描述符就绪(可读、可写或发生异常)时,通知应用程序进行处理,避免大量线程/进程的开销。
  • 特性 select poll epoll
    FD 数量限制 1024(可调整,但需重新编译) 无限制 无限制
    时间复杂度 O(n) O(n) O(1)(仅处理就绪 FD)
    数据拷贝 每次调用需全量拷贝 FD 集合 同 select 通过 mmap 共享内存减少拷贝
    触发模式 水平触发 水平触发 支持水平触发(LT)和边缘触发(ET)
    跨平台支持 所有系统 所有系统 仅 Linux
    适用场景 低并发、简单应用 中低并发 高并发、高性能网络服务

Select

  • 通过阻塞等待多个文件描述符(如套接字)的状态变化(可读、可写或异常),并在任意一个或多个描述符就绪时唤醒进程
  • 监视的是文件描述符,所以有最大限制。select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
  • Selector可以称为通道的集合,每次客户端来了之后我们会把Channel注册到Selector中并且我们给他一个状态,在用死循环来环判断(判断是否做完某个操作,完成某个操作后改变不一样的状态)状态是否发生变化,直到IO操作完成后在退出死循环
  • Selector与Channel是相互配合使用的,将Channel注册在Selector上之后,才可以正确的使用Selector,但此时Channel必须为非阻塞模式。Selector可以监听Channel的四种状态(Connect、Accept、Read、Write),当监听到某一Channel的某个状态时,才允许对Channel进行相应的操作。
  • 效率问题 - 线性扫描:
    • 用户空间到内核空间: 每次调用 select,都需要将整个 fd_set(即使很多位是空的)从用户空间复制到内核空间。
    • 内核空间扫描: 内核需要线性扫描从 0 到 nfds-1 的所有 fd,检查它们是否在监控集合中以及是否就绪。复杂度是 O(n)。
    • 内核空间到用户空间: select 返回时,内核将修改后的 fd_set(包含就绪的 fd)复制回用户空间。
    • 用户空间扫描: 应用程序需要线性扫描从 0 到 nfds-1 的所有可能 fd(或者至少扫描它知道的所有 fd),使用 FD_ISSET 检查哪些就绪。复杂度也是 O(n)。
    • 总结: 大量的内存复制(用户/内核切换)和 O(n) 的扫描开销,在管理大量 fd 时性能急剧下降。每次调用 select 都需要传递整个监控列表。

poll

  • poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。

epoll

  • 使用了内核文件级别的回调机制O(1),epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
  • 关键工作流程:
    1. 创建 epoll 实例 (epoll_create)。
    2. 使用 epoll_ctl 向实例中添加 (EPOLL_CTL_ADD) 需要监控的文件描述符 (fd),并指定关心的事件 (events) 和关联的用户数据 (data)。
    3. 进入循环,调用 epoll_wait 阻塞等待事件发生。
    4. epoll_wait 返回时,其 events 参数指向的数组已经被内核填充了就绪的事件 (struct epoll_event)。返回的数量就是就绪 fd 的数量
    5. 应用程序直接遍历 events 数组(0 到 返回值-1)。每个元素包含:
      • 就绪的事件 (events 字段)。
      • 关联的用户数据 (data 字段,通常包含对应的 fd 或指向包含 fd 的结构体的指针)。
    6. 处理这些就绪的 fd。
    7. 根据需要,可以在循环中再次调用 epoll_ctl 动态添加、修改或删除监控的 fd。
    8. 回到步骤 3,继续等待。

水平触发和边沿触发

  • 水平触发(Level Tigger)
    • 默认的模式,poll也使用的这个模式
    • 信号只需要处于水平,就一直会触发
  • 边缘触发(Edge Tigger)
    • 信号为上升沿或者下降沿时触发
  • 对于读操作
    • LT
      • 只要缓冲内容不为空,LT模式返回读就绪。
    • ET
      • 当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
      • 当有新数据到达时,即缓冲区中的待读数据变多的时候。
      • 当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
  • 对于写操作
    • LT
      • 只要缓冲区还不满,LT模式会返回写就绪。
    • ET
      • 当缓冲区由不可写变为可写时。
      • 当有旧数据被发送走,即缓冲区中的内容变少的时候。
      • 当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。

Buffer(缓冲区)

  • Buffer 是一个缓冲数据的对象, 它包含一些要写入或者刚读出的数据。
  • 在普通的面向流的 I/O 中,一般将数据直接写入或直接读到 Stream 对象中。当是有了Buffer(缓冲区)后,数据第一步到达的是Buffer(缓冲区)中
  • 缓冲区实质上是一个数组(底层完全是数组实现的,感兴趣可以去看一下)。通常它是一个字节数组,内部维护几个状态变量,可以实现在同一块缓冲区上反复读写(不用清空数据再写)

capacity

  • 作为一个内存块,Buffer有固定的大小值,也叫作“capacity”,只能往其中写入capacity个byte、long、char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清楚数据)才能继续写数据。

position

  • 当你写数据到Buffer中时,position表示当前的位置。初始的position值为0,当写入一个字节数据到Buffer中后,position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity-1。当读取数据时,也是从某个特定位置读,将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取一个字节数据后,position向前移动到下一个可读的位置。

limit

  • 在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

AIO

  • 异步非阻塞模型:Java 7引入,基于事件回调或Future机制,I/O操作完成后系统主动通知线程。

  • 底层依赖操作系统支持(如Linux的epoll或Windows的IOCP)。

  • 适用场景:适用于文件I/O或超大规模并发,但实际应用较少(NIO已能满足多数需求)。

  • 服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由操作系统先完成了再通知服务器应用去启动线程进行处理,AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用操作系统参与并发操作,编程比较复杂,JDK1.7之后开始支持。.

  • image-20240225163910748

文件IO

  • JavaNIO FileChannnel

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    //Java NIO对mmap的支持
    public class MmapTest {

    public static void main(String[] args) {
    try {
    FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
    MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
    FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    //数据传输
    writeChannel.write(data);
    readChannel.close();
    writeChannel.close();
    }catch (Exception e){
    System.out.println(e.getMessage());
    }
    }
    }
    // Java NIO对sendfile的支持
    public class SendFileTest {
    public static void main(String[] args) {
    try {
    FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
    long len = readChannel.size();
    long position = readChannel.position();

    FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    //数据传输
    readChannel.transferTo(position, len, writeChannel);
    readChannel.close();
    writeChannel.close();
    } catch (Exception e) {
    System.out.println(e.getMessage());
    }
    }
    }

参考文章


IO
https://x-leonidas.github.io/2022/02/01/04Java/IO/
作者
听风
发布于
2022年2月1日
更新于
2025年6月26日
许可协议