Java IO 模型深度解析
Java IO 模型深度解析
一、同步阻塞 IO (BIO)
1.1 核心概念
⭐首先解释什么是同步和阻塞。同步,调用线程必须等到操作完成,才能继续执行;阻塞,当条件不满足时,调用线程会被操作系统挂起(sleep),不占用 CPU。
⭐然后解释为什么是同步阻塞的。同步阻塞 API ServerSocket.accept()、Socket.read()、Socket.write(),而这些 API 是同步阻塞的是因为最终依赖操作系统的阻塞式系统调用(如 accept/read/write)
1.2 底层实现原理
⭐并介绍具体是怎么调用到的,介绍 JDK8 的 ServerSocket.accept() 的底层实现原理。通过策略模式(Debug 可以发现在 Windows 下 ServerSocket 内部使用的是 DualStackPlainSocketImpl 至少Vista(支持 IPv4 和 IPv6 同时处理) 或者 TwoStacksPlainSocketImpl 低于Vista),组合持有一个 SocketImpl 抽象类对象来实现的。ServerSocket.accept() 底层调用 implAccept(),再委托给 SocketImpl.accept(),调用 accept 虚方法,JVM 通过 DualStackPlainSocketImpl 对象的 Klass 指针(JOL)找到该类的 vtable,再在 vtable 中的索引直接拿到 accept0 实际方法入口地址,并跳转执行。(趣事:Java 9 之后由于模块系统的强封装,直接用反射访问 JDK 内部字段会抛不可访问对象异常 InaccessibleObjectException)
⭐接着再解释 JNI(Java Native Interface)的作用,调用 native 方法 accept0,首先查找加载实现的动态链接库 libc,然后就是调用 C/C++ 函数,调用的是 glibc 的 accept(),最后就是 syscall(SYS_accept)
JNI 调用链路
Java 方法调用 native 方法
↓
JVM 查找 native 方法实现(动态链接库)
↓
JNI 调用 C/C++ 函数
↓
C/C++ 执行操作系统 API 或计算
↓
返回结果给 JVM
JNI 是 Java 提供的一套本地方法调用接口,允许 Java 代码调用 C/C++ 等原生代码,也可以让原生代码调用 Java 方法或访问对象
libc 的作用
libc 是 JVM 与内核之间的”缓冲层”。libc 是 C 标准库的实现规范,glibc 只是其中一种实现。JVM 并不直接调用内核 syscall,而是通过 JNI 调用 libc 提供的接口。glibc 的 accept() 本质是一个系统调用封装器,最终通过 syscall 进入 Linux 内核完成真正的连接建立。
完整调用链
1 | Java API: ServerSocket.accept() |
或者更详细的:
1 | ServerSocket.accept() |
重要说明
- 虚方法调用并不是每次实例方法调用都会发生,只有非 static、非 final、非 private 的实例方法且对象存在多态可能时,才会触发虚方法调用;否则编译期就确定调用目标。
- Socket 的阻塞不是 JVM 层的线程自旋或锁等待,而是操作系统内核将”当前线程”挂起的内核级阻塞。
1.3 使用多线程优化
线程池的好处
- 可以让线程的创建和回收成本相对较低
- 充分利用多核 CPU
无脑增加线程池线程数量的问题
内存问题
- 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半
切换成本
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是调度抖动也就是系统load偏高、CPU 使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态
- 系统 load(负载)=「一段时间内,系统中处于 可运行 或 不可中断 状态的平均任务数量」 load = Runnable 线程数 ≫ 8
不支持高并发
- 只能完成一对一的通信,而且还是同步阻塞的方式,所以实际上很难达成 C10K 的目标(C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题)
低并发使用场景
- 编程模型简单,在活动连接数不是特别高(小于单机1000)的情况下。因为不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
二、同步非阻塞 IO (NIO)
2.1 核心概念
⭐首先解释 Java NIO 的本质。就是 Selector + 非阻塞 Channel + Buffer,通过 epoll 进行事件通知,把”是否可读/可接收连接”的阻塞集中在 select(),而 read/accept 本身不再阻塞线程,没有就绪会直接返回0。
2.2 底层实现原理
⭐然后分析 NIO 实现的底层原理。首先是创建 Selector,然后再创建 ServerSocketChannel,接着将这个 Channel 注册到 selector,关注 ACCEPT 事件,Selector 监听到 ACCEPT 事件之后会创建 SocketChannel,注册 READ 事件,Selector 监听到 READ 事件,就会从 ByteBuffer 中执行数据拷贝
2.3 ByteBuffer 详解
⭐最后可以分析一个这个 ByteBuffer。注意 ByteBuffer 是带状态管理的缓冲区,默认是写,通过 filp 改为读。
两个子类
非直接缓冲区(HeapByteBuffer)
- 数据在 JVM 堆内存
- 读写时,需要从堆拷贝到内核缓冲区
- GC 可管理
直接缓冲区(DirectByteBuffer)
- 数据在堆外内存(off-heap)
- 靠近内核缓冲区,读写时尽量直接映射到内核缓冲区,减少一次 copy
- 高性能 I/O 和零拷贝
- 分配和释放成本略高
- GC 不直接管理
mmap 技术
最后可以简单介绍一下映射的技术 mmap(内存映射文件),将文件内容映射到虚拟内存页,这样程序可以像访问内存一样访问文件, IO 数据不经过内核 Page Cache,直接在用户缓冲区与设备之间传输,而不需要每次调用 read/write 拷贝数据。
2.4 NIO 不是万能的
当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
2.5 Selector 底层的多路复用原理
select
select 就是将已连接的 Socket 放到一个文件描述符集合中,然后拷贝到内核里,通过遍历由内核检查是否有网络事件发生,标记完可读还是可写之后再拷贝回用户态,然后用户态再遍历一遍找到可读或可写的 Socket 进行处理
poll
poll 与 select 不同的是,select 使用固定长度的 bitmap,而 poll 底层使用的是动态数组,以链表来组织,突破了 select 的文件描述符个数限制,不过都是使用线性结构存储进程关注的 Socket 集合,遍历标记的时间复杂度都是 O(n),而且都需要在用户态和内核态之间拷贝文件描述符集合
epoll
底层原理
epoll 解决了 select/poll 的问题,通过 epoll_create() 函数创建 epoll 对象,首先在内核中使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的 Socket 通过 epoll_ctl() 函数加入内核的红黑树中,不用将文件描述符集合全部传递进内核,由内核的红黑树来保存,减少了大量的数据拷贝工作
两种事件触发机制
epoll 支持两种事件触发机制,分别是边缘触发(当被监控的 Socket 有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次)和水平触发(当被监控的 Socket 有可读事件发生时,服务器端会不断从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完)一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用(因为多路复用 API 返回的事件并不一定是可读写的,如果使用阻塞 I/O,会发生阻塞)
2.6 IO 多路复用解决的问题
多路复用模型通过一个进程来管理多个连接,避免了多进程模型中每个进程都需要创建一个线程的缺点,从而减少了系统资源的消耗。使用一个进程来维护多个 Socket,解决了 1 对 1 服务模型的问题,本质上就是处理每个请求的时候只处理 1ms,这样 1s 内就可以处理上千个请求,多个请求复用⼀个进程 / 线程
2.7 Reactor 模式
由于 NIO 底层使用到了 IO 多路复用,所以十分适合实现 Reactor / Dispatcher 模型
核心组成
主要由 Reactor(负责监听和分发事件,事件类型包含连接事件、读写事件) 和处理资源池(负责处理事件,如 read -> 业务逻辑 -> send)这两个核心部分组成
三种模型
Reactor 模式是灵活多变的,Reactor 的数量可以只有一个,也可以有多个,处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程。接下来谈谈 Reactor 模式用的比较多的三种模型:
1. 单 Reactor 单进程 / 线程
不适用计算机密集型的场景,只适用于业务处理非常快速的场景,如 Redis。Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,连接建立的事件交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件,如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应,Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程
2. 单 Reactor 多线程 / 多进程
前面的操作和单 Reactor 单进程 / 线程是一样的,后面开始有区别了,Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client,该方案优势在于能够充分利用多核 CPU 的能力
3. 多 Reactor 多进程 / 线程
Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,「多 Reactor 多进程」方案的开源软件是 Nginx。主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程,子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件,如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。Handler 对象通过 read -> 业务逻辑 -> send 的流程来完成完整的业务流程。
同步非阻塞的本质
Reactor 收到事件之后,进行分发,但是还是同步非阻塞 IO,之所以是同步的是因为最后将内核态的数据拷贝到用户程序的缓存是需要同步的,只是内核数据没有准备好就立即返回,但是从内核态拷贝到用户态的这个过程还是得同步,其实就是 NIO 的落地方案
三、异步非阻塞 IO (AIO)
3.1 核心概念
⭐先讲讲为什么是异步的,发起IO操作(例如accept、read、write)调用的线程,和最终完成这个操作的线程不是同一个。
3.2 底层原理
⭐然后讲讲底层原理。首先创建 AsynchronousSocketChannel,接着注册 accept 回调,使用 CompletionHandler 构建回调逻辑进行业务处理。
AIO 调用链
1 | 应用线程发起 accept/read/write |
3.3 是不是真的异步
⭐最后谈谈是不是真的异步。由于内核态无法直接调用用户态函数,因为内核态是不可能主动去调用用户态的函数,只能通过发送信号,所以Java AIO的本质,就是只在用户态实现异步,并未使用内核级异步 IO,而是基于 epoll 的 Reactor 模型,通过线程池执行同步 IO 并回调 CompletionHandler,从而只在语义上实现异步效果,本质是 Reactor + 线程池的伪 Proactor 实现,也就是用户态的自导自演。所谓监听回调的本质,就是用户态线程调用内核态的函数(准确的说是API,例如read、write、epollWait),该函数还没有返回时,用户线程被阻塞了。当函数返回时,会唤醒阻塞的线程,执行所谓回调函数,这个和BIO、NIO先阻塞,阻塞唤醒后开启异步线程处理的本质一致。
3.4 为什么 AIO 没有 NIO 流行
编程方式略显复杂,比如”死亡回调”,多步依赖操作就必须嵌套回调。而且性能上AIO不一定比NIO高,只是再多一层抽象(NIO.2)而不是完全的异步没什么意义
四、Proactor 模式
4.1 理想中的异步模型
那么理想中的异步模型是什么样呢,Proactor 就是这样的模型。Proactor 模式用到了异步 IO 技术,不用等待是指内核数据准备好的过程不用等,数据从内核态拷贝到用户态的过程不用等,只用等待异步通知,因为 Linux 的异步 IO 不太完善,所以还是在使用 Reactor 模式,相比之下,Windows 实现了 IOCP 这套接口,所以可以使用 Proactor 方案。简单的说,同步/异步 IO 就是指从内核态拷贝到用户态的过程需不需要等待,阻塞/非阻塞 IO 指的是排不排对等待,内核数据没准备好是直接走还是继续等待(一个是拷贝,一个是等待能不能拷贝)
4.2 Proactor 模式工作流程
Proactor Initiator 负责创建 Proactor 和 Handler 对象,通过 Asynchronous Operation Processor 将 Handler 注册到内核
Asynchronous Operation Processor 处理注册和 IO 内核组件,负责接收注册信息,当应用发起异步 IO 操作时,由 Asynchronous Operation Processor 执行,完成 I/O 后通知 Proactor
内核完成 IO 操作后,将完成事件传递给 Proactor,Proactor 根据事件类型(读/写/连接等)进行处理,调用对应 Handler
Proactor 根据事件类型回调对应的 Handler,Handler 负责处理具体业务逻辑(数据解析、业务计算等),Handler 完成业务处理
Handler 处理完业务后,可以再次发起新的异步 IO 周期继续,形成事件驱动的异步处理链
4.3 组件角色类比
- Proactor Initiator(in逆袭a特尔) → 系统管理员,安排事件和处理人员
- Asynchronous(鹅sin困润丝) Operation Processor → 内核操作员,异步执行任务
- Proactor → 前台接待员,收到完成通知后分配 Handler
- Handler → 执行实际业务的人
五、Linux 异步 IO 的实现方案 io_uring
5.1 底层原理
io_uring 本质上是两个环形缓冲区,分别叫 SQ 与 CQ。前者负责向内核传递 io 请求,后者负责将 io 产生的数据传递给我们的程序。每当我们想执行一个操作,例如从网络 io 读取数据,我们便向 SQ 发送一条相关的指令。内核收到这个指令后,会在后台执行相关的操作,并将数据从 CQ 发送给我们的程序。
5.2 优缺点
io_uring 最大的创新点就是大大减少了系统调用的数量。这样一来,我们就可以省去大量用于切换上下文的 CPU 和内存拷贝等待。那么他是如何做到这一点的呢?
首先,io_uring 使用了两个特殊的缓冲区。这两个缓冲区打破了系统内核与应用程序之间的内存保护,使得信息无需被从系统内存复制到应用内存。这样一来,内核从 io 设备接收的信息可以直接放到缓冲区内,无需再次拷贝到用户内存中。
其次,我们可以在一次系统调用之前攒下很多的 io 请求。相比起传统的 io 操作每次读取都要系统调用,我们可以一次性向内核提交一堆读取/写入操作,让内核自己慢慢消化,我们继续我们的操作。这样一来,我们既能大大节省内存拷贝占用的时间,也能降低因为 CPU 状态切换而带来的额外开销。