Skip to content

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 = capacity

Selector(多路复用器): 底层基于操作系统的 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 快?

  1. select/poll 每次调用需要把整个 fd 集合从用户态拷贝到内核态,epoll 只需拷贝一次
  2. 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 模型对比

BIONIOAIO
模型同步阻塞同步非阻塞异步非阻塞
线程模型一连接一线程一线程多连接回调通知
触发方式阻塞等待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/XMLProtobuf
格式二进制文本二进制
体积极小
性能
跨语言
可读性
适用场景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 都利用了零拷贝。