Java I/O 体系概览
Java 的 I/O 体系经历了三次演进:BIO(阻塞 I/O)→ NIO(非阻塞 I/O)→ AIO(异步 I/O)。本文先建立整体认知,后续文章逐一深入。
什么是 I/O?(明确参照物视角)
有很多初学者经常搞混 输入(Input) 和 输出(Output) 的方向:比如把数据写到电脑硬盘里,到底算 In 还是算 Out?
这需要明确一个核心铁律:在讲程序的 I/O 时,永远以“当前运行的程序(即 JVM 内存)”本身为中心视角中心参照物。
- I (Input / 输入):指外界的所有数据(如:电脑硬盘上的文件、从网络另一台服务器发来的请求、用户敲击键盘的按键)进入到 我们的 JVM 内存 的过程。所以通常“文件读取(Read)”属于 Input(比如
InputStream)。 - O (Output / 输出):指把我们 JVM 内存 中处理好的数据,发送到外界(如:保存成硬盘文件、通过网络发给前端浏览器、打印到屏幕显示器)的过程。所以通常“文件写入(Write)”属于 Output(比如
OutputStream)。
通俗比喻:把你的“大脑”想象成当前的 JVM 内存。
- 你用眼睛看书识字(把外界文字装进大脑里)就是 Input (输入)。
- 你用笔写文章或向别人说话(把大脑里的知识表达给外界)就是 Output (输出)。
Java I/O 发展史
| 版本 | 引入 | 特点 |
|---|---|---|
| JDK 1.0 | java.io(BIO) | 阻塞式,以流为单位,简单直观 |
| JDK 1.4 | java.nio(NIO) | 非阻塞,以块为单位,Selector 多路复用 |
| JDK 1.7 | java.nio.file(NIO.2) | 新文件 API(Path、Files),异步 I/O(AIO) |
流(Stream) vs 块(Block)
| 维度 | 流(BIO) | 块(NIO) |
|---|---|---|
| 操作单位 | 逐字节/字符 | 一块数据(Buffer) |
| 方向 | 单向(InputStream 或 OutputStream) | 双向(Channel) |
| 效率 | 较低 | 较高(批量传输) |
| 编程复杂度 | 简单 | 较复杂 |
java.io 的核心类层次
java.io
├── 字节流(以 Stream 结尾)
│ ├── InputStream(抽象基类)
│ │ ├── FileInputStream ← 文件读
│ │ ├── BufferedInputStream ← 缓冲(提升性能)
│ │ ├── DataInputStream ← 读基本类型
│ │ └── ObjectInputStream ← 反序列化
│ └── OutputStream(抽象基类)
│ ├── FileOutputStream ← 文件写
│ ├── BufferedOutputStream ← 缓冲
│ ├── DataOutputStream ← 写基本类型
│ └── ObjectOutputStream ← 序列化
│
└── 字符流(以 Reader/Writer 结尾)
├── Reader(抽象基类)
│ ├── FileReader ← 文本文件读
│ ├── BufferedReader ← 缓冲,支持 readLine()
│ └── InputStreamReader ← 字节流转字符流(指定编码)
└── Writer(抽象基类)
├── FileWriter ← 文本文件写
├── BufferedWriter ← 缓冲,支持 newLine()
└── OutputStreamWriter ← 字符流转字节流
字节流 vs 字符流
| 维度 | 字节流 | 字符流 |
|---|---|---|
| 处理单位 | 8 位字节 | 16 位 Unicode 字符 |
| 适用场景 | 图片、音视频、二进制文件 | 文本文件 |
| API 根类 | InputStream/OutputStream | Reader/Writer |
| 编码问题 | 需要手动处理 | 内部处理(通过 InputStreamReader 指定编码) |
装饰器模式
java.io 大量使用了装饰器模式,可以将流层层包装:
// 读取文件,带缓冲,支持按行读取
BufferedReader reader = new BufferedReader(
new InputStreamReader(
new FileInputStream("file.txt"),
StandardCharsets.UTF_8
)
);
// 写文件,带缓冲,自动刷新
PrintWriter writer = new PrintWriter(
new BufferedWriter(
new FileWriter("out.txt")
)
);
每一层增加一种功能,本体(FileInputStream)不变——这就是装饰器模式的核心。
java.nio 的核心组件
NIO 的三大核心:Buffer、Channel、Selector
Selector(选择器)
│
├── Channel 1(非阻塞通道)←→ Buffer(数据容器)
├── Channel 2(非阻塞通道)←→ Buffer
└── Channel N(非阻塞通道)←→ Buffer
| 组件 | 作用 |
|---|---|
| Buffer | 数据容器,有 position/limit/capacity 三个指针 |
| Channel | 数据的双向通道,可以非阻塞 |
| Selector | 监控多个 Channel,哪个就绪就处理哪个(多路复用) |
NIO 允许一个线程通过 Selector 管理多个连接,大幅降低了服务端的线程开销。
AIO(异步 I/O)
AIO 是真正的异步,操作系统完成 I/O 后主动通知程序:
// NIO(非阻塞,需要轮询)
channel.configureBlocking(false);
// 需要不断检查 Selector 的就绪事件
// AIO(异步,注册回调就行)
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path);
channel.read(buffer, 0, null, new CompletionHandler<>() {
public void completed(Integer result, Void attachment) {
// I/O 完成后自动调用
}
public void failed(Throwable exc, Void attachment) {
// 失败时调用
}
});
// 注册后直接返回,不阻塞
注意:AIO 在 Linux 上的底层实现还是基于线程池模拟(Linux 的异步 I/O 语义不完整),并没有带来明显的性能提升。实际上 Netty、Vertx 等框架更青睐 NIO + Reactor 模式,而不是 AIO。
三种 I/O 的深度对比(OS 视角与通俗比喻)
为了真正理解这三者的区别,我们需要从操作系统的底层机制(如阻塞/非阻塞、同步/异步)来剖析,并结合一个通俗的比喻。
1. 核心概念:阻塞 vs 非阻塞,同步 vs 异步
在操作系统层面,一次完整的网络读(I/O)操作主要分为两个阶段:
- 数据准备阶段(Waiting for data):等待网卡接收外来数据,并将数据复制到 OS 内核缓冲区。
- 数据拷贝阶段(Copying data from kernel to user):将数据从内核缓冲区拷贝到用户态进程空间(JVM 内存)。
- 阻塞 (Blocking) vs 非阻塞 (Non-blocking):区别在于第一阶段。
- 阻塞:如果内核数据还没准备好,当前进程/线程就被挂起休眠(主动交出 CPU),直到数据到来才会被唤醒。
- 非阻塞:如果内核数据没准备好,进程不会休眠,而是直接返回一个错误码(例如
EAGAIN),进程可以利用这段时间干别的,不断重试(轮询)。
- 同步 (Synchronous) vs 异步 (Asynchronous):区别在于第二阶段,也就是数据拷贝是谁来做的。
- 同步:数据拷贝阶段由用户进程自己主动调用执行。在此拷贝期间,用户进程必须等待拷贝完成,因此本质上是存在阻塞的。BIO 和 NIO 都是同步 IO。
- 异步:数据拷贝阶段也由**操作系统(内核)**全权代劳。内核把数据准备好,并且从内核态拷贝到用户态后,直接给出一个回调通知用户进程。用户进程在两阶段都丝毫不受阻塞。
2. BIO(同步阻塞 I/O)
- OS 层面:应用程序发起
read系统调用后,在上述的两个阶段都会被阻塞。进程挂起,直到内核把数据准备好,并且把数据从内核空间拷贝到用户空间后,read调用才会返回。(底层对应 Linux 的默认 blocking socket)。 - 比喻(去餐厅排队排餐):
你在餐厅点了一份现做的牛排(发起
read)。你只能傻傻地站在取餐口苦苦等待,什么都干不了(阻塞),一直等到厨师把牛排做好放在窗口(数据准备阶段),然后你自己小心翼翼地把这盘牛排端到你的座位上(数据拷贝阶段完成,耗时),你才能开吃。
3. NIO(同步非阻塞 I/O 与多路复用)
-
OS 层面:纯粹的“非阻塞 I/O”有一个致命缺陷——如果你有 1 万个连接,应用程序必须在一个死循环里不断向 OS 发起 1 万次
read系统调用,问它“你的数据好了没?”。这种频繁的用户态和内核态切换会导致 CPU 疯狂空转,开销极大。 为了解决这个问题,操作系统设计了 I/O 多路复用(I/O Multiplexing) 技术。简单来说:把轮询 1 万个连接的脏活,交给操作系统在内核态去做,而不是让应用程序在用户态疯狂去问。核心技术演进(select → epoll):
select/poll(早期的多路复用): 应用程序把 1 万个 socket 的清单(文件描述符 FD)打包一次性发给 OS 内核。内核去遍历这 1 万个连接,如果有哪个准备好了,就标记一下并把清单返回给应用程序。应用程序再自己遍历一遍找出准备好的 socket 并进行读取。 缺点:1) 每次都要把 1 万个 FD 从用户态全量拷贝给内核;2) 内核每次都要傻傻地遍历这全量 1 万个 FD;3)select在 Linux 下有默认 1024 个最大连接的限制。epoll(现代 Linux 的终极杀器,Java NIO / Netty / Redis 的高性能基石): 为了解决select的性能瓶颈,Linux 引入了epoll,将原本臃肿的操作分离了:epoll_create:在内核空间开辟一片场地,创建出一个 epoll 实例(内部结构是红黑树 + 就绪链表)。epoll_ctl:当服务器每收到一个新连接时,只把这 1 个 新连接注册到内核的红黑树上。这样就不需要像select那样每次重复上传全量连接栈。内核底层会利用网卡中断回调,如果某个连接有数据来了,就自动把它放进“就绪链表”里。epoll_wait:应用程序调用此方法时,仅仅是去查看内核里的“就绪链表”有没有东西。如果没有,当前线程就会阻塞休眠。只要有连接的数据到了,内核就会唤醒阻塞在epoll_wait上的线程。线程被唤醒后,拿到的直接是一个只包含已经就绪的连接列表,精准命中! 优点:没有最大连接数限制;不需要重复拷贝全量连接;不需要盲目遍历(时间复杂度从 O(N) 降为 O(1)),支持百万级并发。
补充答疑:操作系统里所有进程共享同一个全局的 epoll 吗? 不是的。
epoll实例并不是操作系统全局唯一的。每次你的应用程序(比如 JVM 里的 Tomcat 或 Netty)调用epoll_create时,内核都会在它的内核空间里为你单独开辟一个新的专属 epoll 实例(拥有自己独立的红黑树和就绪队列)。 甚至在同一个应用程序进程里,你也可以创建多个不同的 epoll 实例。这也是后来 Netty “多线程 Reactor 模型”的底层基础:每个处理线程(EventLoop)内部都抱着属于自己的那个独有 epoll 实例,在不停地做epoll_wait监控。实战场景举例(为什么要用 epoll): 想象一台拥有百万级在线用户的微信聊天服务器。虽然同时有 100 万个用户保持着长连接(100 万个 Socket),但绝大多数人都在潜水,可能在某一秒钟内,只有 10 个人刚好在发送消息。
- 如果使用
select:服务器每秒钟都要把这 100 万人的清单全部发给内核,并且内核要苦逼地遍历这 100 万个连接,结果好不容易遍历完,发现只有 10 个人发了消息。这会导致 CPU 瞬间被打满,根本扛不住并发。 - 如果使用
epoll:这 100 万个人只在当初建立连接时调一次epoll_ctl挂在内核里。之后服务器线程只调用epoll_wait在那里睡觉。当那 10 个人发消息时,网卡的硬件中断会立刻悄悄把这 10 个连接拎出来放到“就绪链表”里。epoll_wait瞬间被唤醒,醒来后列表里只有这 10 个精确的目标,拿着就去处理了,极其干脆利索!
因此,在 Java NIO 中调用 Selector 时,底层实际上是:用少量线程,通过
epoll_wait同时阻塞式地监控海量连接。当epoll_wait返回后,应用程序对这些“确实就绪”的连接主动调用read去拷贝数据(注意:第二阶段将数据从内核态拷贝到 JVM 用户态的过程仍然是同步阻塞的,但因为已经确认此时必有数据,所以拷贝绝不落空)。 -
比喻(去餐厅吃饭 - 前台带多个震动号牌): 你去网红餐厅点了份牛排,服务员给了你一个可以震动的号牌。你拿到号牌后可以去外边逛街玩手机了。 而在 Java NIO 的多路复用模型下,可以想象成:你作为全公司的老大哥,帮全公司上百号人在餐厅拿了号牌(Selector/多路复用器管理连接)。你的任务就是盯着一桌子的号牌(
epoll_wait阻塞监控)。发现几号和二十号牌同时震动了(数据准备就绪),你就赶紧跑去取餐窗口,向他们展示牌子然后你自己把这一盘盘做好的牛排端回公司的座位上(依然是第二阶段:亲自将数据从内核端回用户态,此时也阻塞)。但这比雇一百个人去排队高效得多。
4. AIO(异步 I/O)
- OS 层面:应用程序发起一个异步的读调用(例如 Windows 中的 IOCP API),并传入一个用于存放最终数据的 Buffer 容器和一个回调函数。进程告诉内核:“如果数据在网卡上准备好了,并且你要帮我把数据存进我在用户态划好的这个 Buffer 里,全都完事了再喊我。” 整个过程从头到尾应用程序都不用傻等,毫无阻塞。
- 比喻(外卖送上门 - 万事不用操心): 你在家里点了一份外卖(发起 AIO 调用),并给外卖员留了你的家庭住址(传入 Buffer 或者回调函数)。点完之后你去打游戏、洗澡(完全不阻塞)。等外卖员不仅把饭做好了,还主动推开你家的门把美食一盘盘完整端到了你的桌子上摆好(数据准备 + 数据拷贝全部由内核后台代工完成),然后喊你:“饭做好了请慢用呀”(执行回调函数)。在这个过程中,端盘子的脏活全被代工了。
三者核心差异总结表
| 维度 | BIO (同步阻塞) | NIO (同步非阻塞 + 多路复用) | AIO (真正的异步) |
|---|---|---|---|
| 数据准备阶段 | 一直等待(挂起进程休眠) | 多路复用器集中挂起等待(监控全体聚合通道不阻塞某个具体连接) | 操作系统后台处理(进程绝不等待) |
| 数据拷贝阶段 | 进程自己主动拷贝(该阶段阻塞线程) | 进程自己主动拷贝(该阶段阻塞线程) | 操作系统彻底代完成拷贝后通知进程 |
| 线程模型 | 1 个连接分配 1 个独立工作线程 | 少量选择器线程来管理数万长连接 | 依赖 OS 完成回调通知极少线程 |
| 适用场景 | 并发极低、连接数非常少的远古系统 | 高并发、海量长连接、高负载的网络框架(如 Netty、Redis 等) | 理想丰满但 Linux 下 AIO 落地鸡肋(靠 epoll 模拟),实战中不如 NIO 流行 |
三种 I/O 架构的核心代码实现
为了从源码角度更加直观地感受到它们三者的差别,我们来看一下这三种网络编程模型在 Java 侧的基础服务端代码长什么样。(请重点关注代码中的注释,它们揭示了导致阻塞和系统调用的关键所在)。
1. BIO 服务端示例(阻塞点:accept 与 read)
BIO 服务端每接收到一个新的连接,一般都会分配一个独立的线程来处理,否则无法接收其他新人的连接。
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class BioServer {
public static void main(String[] args) throws Exception {
// 创建一个服务端 Socket,监听 8080 端口
ServerSocket serverSocket = new ServerSocket(8080);
// 使用线程池替代每次 new Thread,稍微优化性能
ExecutorService threadPool = Executors.newCachedThreadPool();
System.out.println("BIO 服务端已启动...");
while (true) {
// 【阻塞点 1:等待连接发起】
// 主线程在这里会一直挂起休眠,直到有客户端通过网络连过来(对应底层操作系统中的阻塞机制)
Socket socket = serverSocket.accept();
System.out.println("检测到一个新客户端连接入此服务器...");
// 每当有一个客户端连接成功,必须分配一个专门的独立线程去处理它的 I/O 读写
// 因为接下来的 read() 方法也是阻塞的,如果让主线程在这读,就无法回去 accept 接收其它人的连接了
threadPool.execute(() -> {
try {
byte[] bytes = new byte[1024];
// 从此连接获取输入流(本质是要从操作系统的内核缓冲区提数据了)
InputStream inputStream = socket.getInputStream();
while (true) {
// 【阻塞点 2:等待数据到达】
// 该专属线程会在此挂起休眠,只要客户端一天不发数据,它就乖乖地一直等下去,白白浪费该线程
int readCount = inputStream.read(bytes);
// -1 代表客户端正常断开了连接
if (readCount != -1) {
System.out.println("收到数据:" + new String(bytes, 0, readCount));
} else {
break; // 客户端断开,退出死循环,结束工作线程
}
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
}
2. NIO 服务端示例(核心:Selector 多路复用监控)
NIO 模型下,单线程(或者少量线程)可以维护海量的连接。它把所有连接聚拢在 Selector 内部统一交给操作系统底层(如 epoll)来高效管理。
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NioServer {
public static void main(String[] args) throws Exception {
// 1. 创建服务端的 ServerSocketChannel(类似 BIO 的 ServerSocket)
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 2. 绑定 8080 端口
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
// ★ 核心步骤:将服务端自身配置为“非阻塞模式”,这样 accept() 就不会死等了
serverSocketChannel.configureBlocking(false);
// 3. 打开多路复用器(这句代码在 Linux 底层触发 epoll_create,在内核开辟红黑树模型仓库)
Selector selector = Selector.open();
// 4. 把服务端自己也注册到 Selector 身上去,指明我们要监听 "OP_ACCEPT"(连接到来事件)
// 往后不管是何种连接进出,都统一经由这一个 selector 把控
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("NIO 服务端已启动...");
while (true) {
// 【阻塞点:仅仅阻塞在此,等待任何关联事件触发】
// select() 是一个基于多路复用器的阻塞方法(在 Linux 对应 epoll_wait)。
// 它会睡去,直到监控的所有 Channel 中(无论是有人建连还是发送数据)有动静为止。
if (selector.select(1000) == 0) {
// 如果 1 秒钟还没新事件发生,就暂时放过,可以趁机跑一跑其它定时任务(非纯粹挂死)
continue;
}
// 能越过 select() 来到这里,说明一定接到了唤醒信号,有事件发生了!
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 拿出一个事件必须立刻将其移除,防止下一次 while 循环重复处理这段已触发的动静
iterator.remove();
// 🌟 事件 1:是不是有新人刚刚发起了连接握手?
if (key.isAcceptable()) {
// 因为上面判定了肯定是接收就绪,这个时候再触发 accept ,立即返回通讯 SocketChannel 并不会有哪怕 1ms 阻塞!
SocketChannel socketChannel = serverSocketChannel.accept();
// 将这个新客人的通信连接也配置为“非阻塞模式”!因为后续不希望他的 read() 导致线程去干瞪眼
socketChannel.configureBlocking(false);
// 【将新人上报至内核管控】:交给 selector 去监听该新通道的读动静(OP_READ)
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("成功处理并接收了一个新客户端的连接!");
}
// 🌟 事件 2:是不是之前连上的老访客发新的数据过来了?
if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment(); // 这是上面为客人专门分配的缓冲盘
// 【摆脱阻塞的核心动作】:系统底层通过 epoll 保证了此时这里 100% 有数据可读!
// 所以这一句的 read 将不再盲目挂起,而是雷厉风行地把已沉淀在内核池的数据直接顺势兜进程序 Buffer 去算完。
int readLen = channel.read(buffer);
if(readLen > 0) {
System.out.println("接收到一段客户端发来的数据:" + new String(buffer.array(), 0, readLen));
}else if(readLen < 0){
// 如果返回值是负数说明客户端自己断开了对应的 Socket
channel.close();
}
}
}
}
}
}
3. AIO 服务端示例(核心:真正的异步回调机制)
在 AIO 模型下,所有涉及等待的事情全部被操作系统托管了。应用程序只需向 OS 抛出“读”或“接收连接”的指令,附上一个带有成功失败回调(CompletionHandler)的对象,就可以高枕无忧了。
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
public class AioServer {
public static void main(String[] args) throws Exception {
// 创建一个异步服务端通道
AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open()
.bind(new InetSocketAddress(8080));
System.out.println("AIO 服务端已启动...");
// 发起等待接收新连接的异步请求指令!
// 与 NIO 的 select 死循环不同!这里调用 accept() 根本不会阻塞,方法将极快返回闪退。
// 原理就是通过 CompletionHandler 来拜托系统:“如果哪天有客户端连上来了,自动帮我触发下面的 completed()!”
serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
// 当操作系统真真切切完成了一个连接分配之后,这个钩子就会自动执行!
@Override
public void completed(AsynchronousSocketChannel socketChannel, Void attachment) {
// (极为重要)要继续挂上下一个监听钩子,类似击鼓传花地接力,不然服务器不会再接待其它的客流量了。
serverChannel.accept(null, this);
System.out.println("接待了一个新连入的客户端!目前服务这单的系统线程是:" + Thread.currentThread().getName());
// 给这个新连接指派一个专用的数据包裹袋 ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 【核心体现 AIO 异步之美的代码实现点】:读取也绝不允许自己死等待!
// 我们直接再次发号施令委托操作系统:“听好了,等哪天他的数据通过网卡飞进了我们的 OS 内核……”
// “你别光喊我,好人做到底,顺带帮我把数据存进这个名为 buffer 的包裹袋里边!”
// “这一切如果顺利做完之后,再回头调一下下面的 completed 去找程序。”
// 注:代码走到该行同样是一瞬间就滑过去了,根本不会干愣着等。
socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
// 当系统【已经代劳将数据规规矩矩在用户态这存放进 ByteBuffer】时,它便会通知这最后一步。
@Override
public void completed(Integer result, ByteBuffer attachment) {
if (result > 0) {
attachment.flip();
System.out.println("太舒服了!系统给我直接端上了客户端数据:" + new String(attachment.array(), 0, result));
attachment.clear(); // 整理清洗容器留待后面重用
// 如果还得继续听该客户端发牢骚,就继续提交 read 委托令。
socketChannel.read(attachment, attachment, this);
} else if (result == -1) {
// 优雅处理连接正常断开
try { socketChannel.close(); } catch (Exception e) {}
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println("很不幸,读取发生错误异常!");
}
});
}
@Override
public void failed(Throwable exc, Void attachment) {
System.out.println("接收到来的连接时遭到了意外断开或出错异常!");
}
});
// 为了防止处于非阻塞的主线程一执行而过彻底退出生命跑没了,我们在此阻塞住让整个守护程序能一直停靠活着(打个生桩)
Thread.currentThread().join();
}
}
文件 I/O 最佳实践
传统 BIO 文件读取与内核流转过程
BIO 当然可以用于读取本地文件(事实上 FileInputStream 和 FileReader 就是最经典的 BIO 文件操作组件)。在文件 I/O 的场景下,BIO 依然是“同步阻塞”的——如果硬盘磁头寻道慢或者数据还没被缓存进内存,read() 操作同样会阻塞住当前执行线程。
下面通过示例为您演示如何使用纯粹的 BIO 去读取文件。请重点关注其中的底层原理注释,它展示了数据是如何从磁盘一步步来到您的 JVM 程序里的:
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
public class BioFileDemo {
public static void main(String[] args) {
File file = new File("demo.txt");
// 1. 发起系统调用,打开文件句柄(底层对应 Linux 的 File Descriptor 文件描述符)
try (InputStream is = new FileInputStream(file)) {
// 2. 在 JVM 用户态内存里开辟一个字节数组,作为接收数据的“集装箱”
// (注意:数组开多大要根据自身物理内存进行权衡,通常使用 1024~8192 等 2 的整数次幂大小)
byte[] buffer = new byte[1024];
int readLen;
// 3. 开始向操作系统发起 read() 系统调用,进行循环摄取数据
// 【阻塞点】:如果系统内核还没把数据从硬盘搬进主板内存,当前执行流就会被挂起休眠等待
while ((readLen = is.read(buffer)) != -1) {
// 【深入底层原理解读:这一行 read(buffer) 到底发生了什么跨越?】
// 1) 硬件阶段:磁盘控制器收到硬件读指令,通过 DMA(直接内存访问技术)将文件所在的硬盘区块数据,默默拷贝到操作系统的【内核缓冲区】(Page Cache)中,期间不消耗主 CPU 算力。
// 2) 挂起阶段:在这个纯粹等磁盘转动的等待期内,你的该 java 线程是处于剥夺 CPU 执行权并挂起状态的。
// 3) 拷贝阶段:当内核缓冲池有了属于该文件的数据后,CPU 介入,将这批数据从操作系统的【内核缓冲区】再次原封不动地跨界拷贝到上面的字节数组 buffer 里(即跨越到【JVM 用户态空间】)。
// 4) 唤醒阶段:全部拷贝妥当后,操作系统大喊一声唤醒当前线程,read 方法这才宣告结束它的阻塞,并老老实实返回本次实际装进大铁盆的有效字节数量。
// 处理已装上车的数据(因为往往到了文件最后一次可能塞不满 1024 舱位,所以必须根据实际长度 readLen 截取有效部分)
String chunk = new String(buffer, 0, readLen);
System.out.println("成功读取到一块数据:" + chunk);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
深度进阶思考:为什么大家总说读文件务必要再套一层
BufferedInputStream? 因为上述极其原始普通的FileInputStream每次调用read(),都会触发一次哪怕极少量字节的底层系统调用(这种跨态调用会频繁引发极其昂贵的 CPU 线程上下文切换开销)。而套上BufferedInputStream相当于给该通道做了一个“缓冲中转站”——它内部维护了一个默认 8KB 大小的内置字节数组。当代码想要获取仅仅 1 字节时,它都会一口气强迫 OS 把 8KB 的一整块数据统统拷到自身内存,随后接下来的绝大部分读请求(在 8KB 内)都不会再去惊动内核了,直接在内存数组里划拉给业务方,从而砍掉千万次无谓的底层系统交互。
NIO 文件读取(通道与零拷贝的基石)
很多初学者存在一个误区,以为用了 NIO 读文件就不会阻塞了。事实上,本地磁盘文件在绝大多数操作系统底层本就不支持“非阻塞模式”(比如你想调用 FileChannel.configureBlocking(false) 会发现根本没这个方法)。
NIO 在文件读取上的绝对优势并不是因为非阻塞,而是它对内存的精细操作(如直接物理内存缓冲区堆外分配)以及支持系统级的“零拷贝(Zero-Copy)”机制。
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class NioFileDemo {
public static void main(String[] args) {
// 1. 获取到底层的文件通道(Channel),可以由传统的 FileXXXStream 或者是 RandomAccessFile 中提取
try (RandomAccessFile file = new RandomAccessFile("demo.txt", "r");
FileChannel channel = file.getChannel()) {
// 2. 依然是在 JVM 用户态开辟用于接收数据的 Buffer 容器
// 【进阶优化点 / 必考题】:如果这里改用 ByteBuffer.allocateDirect(1024),则会直接“避开 Java 堆”,直接在操作系统的物理内存中划分空间!
// 这样内核在读取到文件数据后,就直接写入这块共享内存,省去了从“内核态空间”再次往“JVM 堆内存空间”搬砖的二次拷贝损耗。
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 3. 通道开始工作了。
// 【阻塞点】:注意!刚刚说过,本地文件读区在这里依然是同步阻塞的,必须等磁盘硬件转动并把数据读出来。
int bytesRead = channel.read(buffer);
while (bytesRead != -1) {
// 就像用完笔需要把笔帽套到笔尾,这里准备从 Buffer 中取用刚才装好的数据,必须得 flip() 一下
// 通俗来讲,就是把内部的游标(Position)从写完的末尾拉回到开头,以便让你按顺序往外读。
buffer.flip();
// 判断这辆运货车(Buffer)里还有没有卸完的货(有效数据)
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
// 取完数据(这一车已卸完),立刻清空或者重置车厢,准备掉头去让通道 read 继续装载下一车
buffer.clear();
bytesRead = channel.read(buffer);
}
// 🌟 【性能终极武器:零拷贝机制(Zero-Copy)】:
// 如果你不是为了把文件内容取出来打印,而仅仅是为了“读取某文件,然后原封不动发给网络侧(比如通过 Socket 发给前端)”
// 在 NIO 里,你可以直接调用一个逆天的方法: channel.transferTo(...),
// 此时 OS 内核不仅不会把文件数据傻傻地拷贝到上面的应用程序里,甚至会在内核层面上直接找根管子连到网卡,让文件顺着网卡就流向了网络对端。这是所有如 Kafka/RocketMQ 这种百万吞吐量中间件的真正底牌机制!
} catch (Exception e) {
e.printStackTrace();
}
}
}
AIO 异步文件读取(甩手掌柜的极致体验)
JDK 7 的 NIO.2 补全了最后一块拼图,引入了真正意义上针对文件读取从头到尾不用去守着的异步类:AsynchronousFileChannel。这种模式下,你的线程大可以布置完作业之后出去玩一整天。
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class AioFileDemo {
public static void main(String[] args) throws Exception {
Path path = Path.of("demo.txt");
// 1. 打开一个被赋予全新异步能力的文件通道
try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
// 2. 依然是准备收纳内容的空行囊
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 3. 【颠覆体验点:发了布告就撒手人寰】:丢出一个读取指令并贴上一张完工后的处理清单(回调函数对象)。
// 参数列表分别代表:数据放哪、从文件第0个字节起读、透传给回调用的附带物(此处透传 buffer)、以及完工反馈的钩子对象。
// 注意:这行代码会瞬间、极速地一跃而过!无论文件是一个 G 还是十个 G!
fileChannel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
// 【操作系统幕后处理完了,自动顺藤摸瓜呼叫这个钩子】
@Override
public void completed(Integer result, ByteBuffer attachment) {
System.out.println("\n[底层神秘力量(回调系统线程)] 任务达成!这次成功从硬盘撬出了 " + result + " 个字节的数据。");
attachment.flip();
byte[] data = new byte[attachment.limit()];
attachment.get(data);
System.out.println("[底层神秘力量] 文件详细内容为:" + new String(data));
attachment.clear();
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println("[底层神秘力量] 报告老板,硬盘可能歇菜了,抓取任务挂掉...");
exc.printStackTrace();
}
});
// 4. 【完美诠释非阻塞】主线剧情在这里将继续肆无忌惮地推进!
System.out.println("[主工作线程] 既然读取硬盘的粗活儿丢下去了,咱别浪费大好青春光阴干瞪眼死等,我现在去打一把吃鸡再说...");
// (输出结果必然是主线程打游戏的日志先打印,因为上面交待读取的动作毫不拖沓就返回过了)
// 仅仅为了在此作示例演示:我们要给底层的异步回调留点命案时间存活,如果不 sleep 主系统就直接关闭,回调线程就会跟着被强制超度
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
使用 try-with-resources
// ✅ 推荐:自动关闭
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
process(line);
}
}
// ❌ 不推荐:手动关闭容易遗漏
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("file.txt"));
// ...
} finally {
if (reader != null) reader.close();
}
使用 NIO.2(JDK 7+)的 Files 工具类
// 读取所有行
List<String> lines = Files.readAllLines(Path.of("file.txt"), UTF_8);
// 写入文件
Files.write(Path.of("out.txt"), content.getBytes(UTF_8));
// 复制文件
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
// 递归遍历目录
Files.walk(Path.of("/dir"))
.filter(Files::isRegularFile)
.forEach(System.out::println);
NIO.2 的 Files 和 Path API 比传统的 File API 更简洁,推荐优先使用。
生产环境核心踩坑点
| 问题 | 答案要点 |
|---|---|
| BIO/NIO/AIO 的区别? | BIO 阻塞一连接一线程;NIO 非阻塞多路复用;AIO 异步回调 |
| 字节流和字符流的区别? | 字节流处理二进制,字符流处理文本;字符流内部做了编码转换 |
| java.io 用了什么设计模式? | 装饰器模式,层层包装增强功能 |
| NIO 的三大组件是什么? | Buffer(数据容器)、Channel(双向通道)、Selector(多路复用) |
| 为什么实际上 NIO 比 AIO 更流行? | Linux AIO 实现不完整;NIO + Reactor 模式(Netty)已足够高效 |