Java IO模型
I/O模型
1. NIO
- 全称为Non-blocking I/O或New I/O (非阻塞输入/输出),是 Java 1.4 中引入的新 I/O 模型
- 它与传统 I/O(Old I/O,即 BIO)有着本质的区别,主要用于高并发、高吞吐量的网络通信和文件操作
- 有三大核心组件:Buffer、Channel、Selector
1.1. Buffer 缓冲区
传统 I/O是面向“流”的,数据像水流一样单向流动,无法前后移动
NIO是面向“缓冲区”的,数据被读取到一个缓冲区中,可以在缓冲区中前后移动(通过position,limit,capacity等指针控制)
常见缓冲区类型有:
ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer
ByteBuffer(最常用)
- MappedByteBuffer
- DirectByteBuffer
- HeapByteBuffer
1.2. Channel 通道
传统 I/O 通过Stream读写数据,流是单向的(分为InputStream和OutputStream)
NIO 的Channel是双向的,可以同时进行读和写操作;Channel 可以从 Buffer 中读取数据,也可以把数据写入 Buffer
常见的 Channel 有:
FileChannel(文件)、SocketChannel(TCP 客户端)、ServerSocketChannel(TCP 服务端)
1.3. Selector 选择器
Selector 被称为“多路复用器”,是 NIO 实现非阻塞的核心
它可以同时监听多个 Channel 的事件(如连接建立、数据到达、写入完成等)
通过 Selector,一个单独的线程就可以管理多个 Channel(成百上千个网络连接),极大地减少了线程上下文切换的开销
2. BIO
- 全称为Blocking I/O(阻塞式输入/输出),是 Java 早期(JDK 1.4 之前)唯一的网络通信模型
- 无论是客户端还是服务端,在进行网络读写操作时,如果数据没有准备好,线程就会被“卡住”(阻塞),直到数据就绪并完成操作
2.1. 工作模式
在 BIO 模型中,服务端的典型工作流程如下:
- ServerSocket 绑定端口:服务端创建
ServerSocket,监听指定端口 - 阻塞等待连接:调用
serverSocket.accept()方法,线程被阻塞,直到有新的客户端连接进来。一旦有连接,accept()返回一个Socket对象 - 阻塞等待数据:通过
Socket获取输入流,调用inputStream.read()方法,线程再次被阻塞,直到客户端发送来数据并读取完毕 - 处理与响应:业务逻辑处理,然后通过输出流
outputStream.write()将结果返回给客户端(写操作也可能阻塞) - 循环往复:处理完一个请求后,继续调用
accept()等待下一个连接
2.2. 线程模型
2.2.1. 一请求一线程
为了解决单线程无法并发的问题,传统的 BIO 采用了**“一请求一线程”**的模型:
- 主线程专门负责调用
accept()监听连接 - 每当
accept()返回一个Socket,就新建一个工作线程去处理这个Socket的读写和业务逻辑
缺点:
- 内存占用高,每次都要新建线程
- 如果并发很高,理论上会无限制的创建线程
- 线程上下文切换成本高
- 只适合连接数少的场景
2.2.2. 线程池
未解决方案一中线程资源频繁创建回收的问题,引入了线程池版方案
缺点:
- 阻塞模式下,线程仅能处理一个 socket 连接
- 仅适合短连接场景
2.3. 致命缺陷
如果按照上述流程,服务端是单线程的,当线程在为客户端 A 执行read()等待数据时,如果客户端 A 迟迟不发送数据,线程就会一直卡在 A 身上。此时客户端 B 尝试连接,服务端根本无力响应(因为无法执行到accept())
虽然“多线程”解决了单线程无法并发的问题,但它带来了极其严重的性能瓶颈,这也是后来 NIO 诞生的根本原因:
2.3.1. 线程资源极度浪费(并发量受限)
在 BIO 中,如果客户端建立连接后不发送数据(或者网络很慢),处理该连接的工作线程就会一直阻塞在read()上。这意味着一个线程即使什么活都没干,也被独占了
服务器的线程数是极其宝贵的系统资源(通常几百上千个就顶天了),如果有一万个空闲连接,BIO 就需要一万个线程,服务器直接 OOM 崩溃
2.3.2. 频繁的上下文切换开销
当活跃连接数较多时,JVM 需要在大量阻塞的线程之间进行调度和切换。线程上下文切换是非常耗费 CPU 资源和时间的操作,导致系统整体吞吐量急剧下降
2.3.3. 连接建立与数据读取耦合
在 BIO 中,accept()和read()都是阻塞的,且必须由同一个执行流来串行处理。没有机制能够做到“我先去干点别的,等有数据了再来读”
2.4. BIO vs NIO
为了更深刻地理解 BIO 的局限,我们对比一下 NIO
| 特性 | BIO (Blocking I/O) | NIO (Non-blocking I/O) |
|---|---|---|
| 核心模型 | 面向流 | 面向缓冲区 |
| 阻塞特性 | 阻塞式(accept/read/write都会卡住线程) | 非阻塞式(read没数据直接返回,线程可做其他事) |
| 并发策略 | 一请求一线程 | 一个线程可管理多请求(基于 Selector 多路复用) |
| 适用场景 | 连接数少且固定、业务处理耗时长的架构(如传统后台管理) | 连接数多、但每个连接活跃度低(长连接但数据少)的架构(如聊天服务器、高并发网关) |
打个通俗的比方:
- BIO 就像传统餐厅的服务员:一个服务员全程只服务一桌客人。客人看菜单时,服务员傻站着等(阻塞);客人点菜后,服务员又去后厨傻等出菜。如果餐厅来了100桌客人,就需要100个服务员,哪怕其中90桌只是在聊天还没点菜,服务员也被占着
- NIO 就像现代餐厅的大堂经理:大堂经理手里拿着对讲机,在餐厅里巡视。哪桌客人举手了(连接有数据可读/可写),经理就安排对应的人员去处理一下,处理完继续巡视。一个经理就能照看整个餐厅的上百桌客人
3. AIO
- 全称为Asynchronous I/O(异步输入/输出),在 Java 中也被称为NIO 2.0
- 它是自 JDK 7 引入的全新 I/O 模型,旨在解决 Java 原生 NIO 编程复杂度高的问题,并实现真正的底层异步网络通信与文件操作
- 理解 AIO 的核心是两个词:真正异步和回调驱动
3.1. 前置概念
- 同步:应用程序发起 I/O 调用后,必须亲自参与等待数据就绪或把数据从内核空间拷贝到用户空间的过程。哪怕像 NIO 那样不阻塞线程,但数据拷贝阶段仍需应用程序自己轮询或触发,依然是同步的
- 异步:应用程序发起 I/O 调用后立刻返回,完全可以去做别的事情。底层的操作系统负责等待数据就绪,并将数据从内核空间拷贝到用户空间。完成后,操作系统会主动通知应用程序(通常通过回调函数)
AIO 的核心组件是Future和CompletionHandler(完成回调)
- Future 模型:发起操作后返回一个
Future对象,你可以稍后调用future.get()阻塞等待结果(这其实退化为同步了,较少作为核心使用) - CompletionHandler 回调模型:这是 AIO 的精髓。发起操作时传入一个回调对象,当 I/O 操作彻底完成(数据已拷贝到用户空间)时,系统自动调用回调对象中的方法
3.2. 工作模式
AIO 的网络通信和文件操作使用了不同的核心类:
- 网络 AIO:
AsynchronousServerSocketChannel(服务端)、AsynchronousSocketChannel(客户端) - 文件 AIO:
AsynchronousFileChannel
AIO 服务端的典型工作流程:
- 创建通道并监听:打开
AsynchronousServerSocketChannel,绑定端口 - 发起接受连接请求:调用
channel.accept(),此时不会阻塞。你需要传入一个CompletionHandler - 系统接管:操作系统在底层监听连接。当有客户端连接时,操作系统完成连接建立,并自动触发你传入的
CompletionHandler的completed()方法 - 在回调中发起读操作:在
completed()方法中,你拿到客户端的Channel,再次调用channel.read()读取数据,并再次传入一个新的CompletionHandler - 系统再次接管:操作系统等待数据到达并拷贝到 Buffer 中,完成后触发读回调的
completed()方法 - 处理与响应:在读回调中处理业务逻辑,并调用
channel.write()写回数据(同样可以传入回调)
关键点:整个过程中,Java 线程只负责“发起请求”和“在回调中处理结果”,所有的“等待”和“数据搬运”全部由操作系统内核完成
3.3. AIO vs NIO
| 维度 | NIO (Non-blocking I/O) | AIO (Asynchronous I/O) |
|---|---|---|
| 核心模型 | 同步非阻塞 | 真正异步 |
| 操作系统依赖 | 依赖 Linux 的epoll | 依赖 Linux 的io_uring(新) 或 Windows 的IOCP |
| 谁负责数据拷贝 | 应用程序。操作系统通知数据就绪后,应用线程仍需自己调用read()把数据从内核拷贝到用户空间 | 操作系统。操作系统在后台默默完成数据拷贝,拷贝完毕后才通知应用程序来取 |
| 编程范式 | 轮询/事件触发。需要 Selector 不断select(),在代码里判断是读事件还是写事件并处理 | 回调驱动。发起请求后不管,完成时系统自动调用completed()方法 |
| 编程复杂度 | 较高。需处理半包、粘包、空轮询 Bug 等复杂问题 | 极高(原生 API)。回调地狱,状态管理极其困难 |
打个通俗的比方:
- NIO 像去干洗店洗衣服:你把衣服放下,拿到一个小票。你不需要在店里傻等(非阻塞),你可以去逛街。但你得时不时掏出小票看看(轮询/Selector),一旦发现衣服洗好了,你得自己把衣服拿走(数据拷贝)
- AIO 像叫外卖:你下单后直接去打游戏(异步)。外卖小哥不仅把饭做好了,还亲自把饭送到你嘴里(内核完成拷贝),然后拍拍你的肩膀说“饭到了,吃吧”(触发回调)
3.4. AIO 的真正主场:文件 I/O
虽然 AIO 在网络通信中败给了 NIO(配合 Netty),但它在文件 I/O领域却是王者
对于大文件的异步读写,AsynchronousFileChannel是 Java 中唯一的标准异步文件操作 API
因为文件 I/O 通常比较耗时,交给操作系统在后台异步完成,对主线程的影响最小
