Appearance
IO
一、为什么需要理解 IO 模型?
IO 是程序与外部世界交互的通道——文件、网络、管道。IO 性能直接影响系统吞吐量。理解 Java 的 BIO、NIO、AIO 三种模型,是理解 Netty、Tomcat 等框架的基础。
二、IO 分类全景
Java IO
├── 传统 IO(BIO,阻塞式)
│ ├── 字节流:InputStream / OutputStream
│ │ ├── FileInputStream / FileOutputStream
│ │ ├── BufferedInputStream / BufferedOutputStream(带缓冲)
│ │ └── ObjectInputStream / ObjectOutputStream(序列化)
│ └── 字符流:Reader / Writer
│ ├── FileReader / FileWriter
│ ├── BufferedReader / BufferedWriter
│ └── InputStreamReader / OutputStreamWriter(字节→字符桥梁)
├── NIO(New IO,非阻塞式)
│ ├── Channel(通道,双向)
│ │ ├── FileChannel
│ │ ├── SocketChannel / ServerSocketChannel
│ │ └── DatagramChannel
│ ├── Buffer(缓冲区)
│ │ ├── ByteBuffer
│ │ ├── CharBuffer
│ │ └── MappedByteBuffer(内存映射)
│ └── Selector(多路复用器)
└── AIO(NIO 2.0,异步 IO)
├── AsynchronousFileChannel
└── AsynchronousSocketChannel三、BIO(Blocking IO)
3.1 原理
BIO 是同步阻塞模型:读数据时,线程阻塞等待数据准备好,数据从内核拷贝到用户空间时线程继续阻塞。一个连接一个线程。
java
// BIO 服务端:每个连接一个线程
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept(); // 阻塞等待连接
new Thread(() -> {
InputStream in = client.getInputStream();
byte[] buf = new byte[1024];
int len = in.read(buf); // 阻塞等待数据
// ... 处理数据
}).start();
}问题: 多少连接就多少线程,C10K 问题(10000 并发连接)下线程数爆炸,线程切换开销极大。大量线程阻塞在 IO 上,CPU 利用率低。
3.2 字节流 vs 字符流
为什么需要字符流? 字节流按字节读写,中文(UTF-8 3 字节)会被拆散。字符流内部包装了字节流 + 编码解码器,自动处理字符边界。
java
// 字节流:读出来是字节,需要手动转码
InputStream in = new FileInputStream("file.txt");
byte[] bytes = in.readAllBytes();
String content = new String(bytes, StandardCharsets.UTF_8);
// 字符流:读出来就是字符
BufferedReader reader = new BufferedReader(new FileReader("file.txt"));
String line = reader.readLine(); // 直接读一行3.3 缓冲流为什么快?
原理: 无缓冲时,每次 read() 都触发一次系统调用(内核态→用户态→内核态),系统调用开销大。缓冲流内部维护一个 byte[] 缓冲区,一次 read() 从内核读一大块数据到缓冲区,后续 read() 直接从缓冲区取,大幅减少系统调用次数。
java
// 无缓冲:每次 read() 一次系统调用
FileInputStream fis = new FileInputStream("file.txt");
// 有缓冲:一次系统调用读 8192 字节到缓冲区
BufferedInputStream bis = new BufferedInputStream(new FileInputStream("file.txt"));四、NIO(Non-blocking IO)
4.1 原理
NIO 是同步非阻塞模型:读数据时,线程不阻塞等待,没有数据立即返回。通过 Selector 多路复用,一个线程管理多个 Channel。
java
// NIO 服务端:一个线程管理多个连接
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有事件就绪
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
// 处理新连接
} else if (key.isReadable()) {
// 处理读事件
}
}
keys.clear();
}4.2 三大核心组件
Channel(通道): 双向的,可读可写。与流(Stream)的区别:流是单向的(InputStream 只读,OutputStream 只写),Channel 是双向的。
Buffer(缓冲区): NIO 中所有数据都通过 Buffer 读写。Buffer 本质是一块内存,维护三个指针:
position limit capacity
↓ ↓ ↓
[X][X][X][ ][ ][ ][ ][ ] ← 数组
↑
已写入的数据
flip() 切换为读模式:limit = position, position = 0
clear() 切换为写模式:position = 0, limit = capacitySelector(多路复用器): 底层基于操作系统的 I/O 多路复用机制(epoll/kqueue),一个线程监听多个 Channel 的事件。只有当 Channel 真正就绪时才处理,避免轮询造成的 CPU 浪费。
4.3 epoll 原理
Linux 下的 epoll 是 Selector 的高效实现:
- epoll_create:创建 epoll 实例,在内核中建立红黑树(存储 fd)和就绪链表
- epoll_ctl:向红黑树中添加/删除/修改 fd 和事件
- epoll_wait:检查就绪链表,有事件就返回,没有就阻塞
为什么 epoll 比 select/poll 快?
- select/poll 每次调用需要把整个 fd 集合从用户态拷贝到内核态,epoll 只需拷贝一次
- select/poll 需要遍历所有 fd 检查事件,O(n);epoll 通过回调将就绪 fd 加入链表,epoll_wait 直接返回,O(1)
五、AIO(Asynchronous IO)
5.1 原理
AIO 是异步非阻塞模型:发起读操作后立即返回,内核完成数据拷贝后回调通知。读写操作完全异步,不需要轮询。
java
AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
client.connect(new InetSocketAddress("localhost", 8080), null,
new CompletionHandler<Void, Void>() {
@Override
public void completed(Void result, Void attachment) {
// 连接成功
}
@Override
public void failed(Throwable exc, Void attachment) {
// 连接失败
}
});5.2 三种 IO 模型对比
| BIO | NIO | AIO | |
|---|---|---|---|
| 模型 | 同步阻塞 | 同步非阻塞 | 异步非阻塞 |
| 线程模型 | 一连接一线程 | 一线程多连接 | 回调通知 |
| 触发方式 | 阻塞等待 | Selector 轮询就绪 | 内核回调 |
| 适用场景 | 连接少、数据量大 | 连接多、数据量小(聊天、推送) | 连接多、数据量大 |
| 复杂度 | 简单 | 复杂(Buffer 操作繁琐) | 中等 |
| 框架 | Tomcat 默认 | Netty | 较少使用 |
六、零拷贝
6.1 原理
传统 IO 需要 4 次数据拷贝和 4 次上下文切换:
磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡sendfile(Linux 2.1+): 数据不经过用户空间,直接从内核缓冲区到 Socket 缓冲区:
磁盘 → 内核缓冲区 → Socket 缓冲区 → 网卡sendfile + DMA gather(Linux 2.4+): 只拷贝文件描述符和数据长度,不拷贝数据本身:
磁盘 → 内核缓冲区(DMA)→ 文件描述符 → Socket 缓冲区 → 网卡(DMA)6.2 Java 中的零拷贝
java
// FileChannel.transferTo:底层调用 sendfile
FileChannel source = FileChannel.open(Paths.get("source.txt"));
FileChannel target = FileChannel.open(Paths.get("target.txt"));
source.transferTo(0, source.size(), target);
// MappedByteBuffer:内存映射,直接操作内核缓冲区
MappedByteBuffer buffer = FileChannel.open(Paths.get("file.txt"))
.map(FileChannel.MapMode.READ_WRITE, 0, fileSize);七、序列化
7.1 为什么需要序列化?
原理: 对象在内存中的结构是复杂的(有引用、指针),无法直接写入磁盘或网络传输。序列化将对象转换为字节流,反序列化将字节流还原为对象。
7.2 Java 原生序列化 vs 第三方
| Java 原生 | JSON/XML | Protobuf | |
|---|---|---|---|
| 格式 | 二进制 | 文本 | 二进制 |
| 体积 | 大 | 中 | 极小 |
| 性能 | 慢 | 中 | 快 |
| 跨语言 | 否 | 是 | 是 |
| 可读性 | 否 | 好 | 否 |
| 适用场景 | Java 内部缓存 | Web API | 高性能 RPC |
7.3 transient 关键字
被 transient 修饰的字段不参与序列化。用于密码、token 等敏感数据,或可从其他字段推导的值。
java
class User implements Serializable {
private String name;
private transient String password; // 不序列化
}八、面试要点
Q:BIO、NIO、AIO 的区别?
BIO 同步阻塞,一连接一线程,适合连接少、数据量大的场景;NIO 同步非阻塞,一个线程通过 Selector 管理多个连接,适合连接多的场景;AIO 异步非阻塞,内核回调通知,适合连接多、数据量大的场景。
Q:NIO 的核心组件?
Channel(双向通道)、Buffer(缓冲区,position/limit/capacity)、Selector(多路复用器,底层 epoll,一个线程管理多个 Channel)。
Q:字节流和字符流的区别?
字节流按字节读写(InputStream/OutputStream),适合二进制数据;字符流内部封装了字节流和编码解码器(Reader/Writer),适合文本数据。字符流 = 字节流 + 编码。
Q:什么是零拷贝?
传统 IO 需要 4 次拷贝(磁盘→内核→用户→Socket→网卡)。零拷贝通过 sendfile 系统调用,数据不经过用户空间,直接从内核缓冲区到 Socket 缓冲区,减少拷贝次数和上下文切换。Java 中 FileChannel.transferTo() 和 MappedByteBuffer 都利用了零拷贝。
