Skip to content

Java IO与NIO

Java IO 与 NIO 完整技术笔记


一、概述

一句话定义:Java IO 是 Java 语言中用于处理数据输入/输出的核心模块,涵盖传统阻塞 IO(BIO)、新 IO(NIO)和异步 IO(AIO)三代演进。

解决什么问题:程序与外部世界(文件系统、网络、设备)之间的数据交换。

适用场景对照

模型适用场景典型框架/应用
BIO连接数少、逻辑简单的场景(文件读写、小型服务)传统 Servlet(Tomcat BIO Connector)
NIO高并发、长连接、海量连接场景Netty、Mina、Tomcat NIO Connector、Kafka
AIO(NIO.2)连接数多且 IO 操作耗时长的场景理论适合,但实际 Linux 下收益有限,使用较少

主体约定:IO 中的「输入/输出」以 计算机内存 为主体——数据进入内存叫输入(Input),从内存出去叫输出(Output)。

整体知识脉络

mermaid
graph TB
    A[Java IO 体系] --> B[传统 IO - BIO]
    A --> C[New IO - NIO]
    A --> D[NIO.2 / AIO - Java 7+]

    B --> B1[字节流 InputStream/OutputStream]
    B --> B2[字符流 Reader/Writer]
    B --> B3[缓冲流 Buffered*]
    B --> B4[对象序列化]

    C --> C1[Channel 通道]
    C --> C2[Buffer 缓冲区]
    C --> C3[Selector 多路复用]

    D --> D1[Path & Files API]
    D --> D2[AsynchronousChannel]
    D --> D3[WatchService]

二、核心概念与原理

2.1 五种 UNIX IO 模型

在深入 Java IO 之前,必须理解操作系统层面的 IO 模型(以网络 IO 为例,一次 read 涉及两个阶段:①等待数据就绪 ②将数据从内核缓冲区复制到用户空间):

IO 模型阶段一(等待数据)阶段二(数据复制)Java 对应
同步阻塞 IO(Blocking IO)阻塞阻塞BIO(java.io
同步非阻塞 IO(Non-blocking IO)非阻塞(轮询)阻塞NIO(configureBlocking(false))
IO 多路复用(Multiplexing)阻塞在 select/epoll阻塞NIO + Selector
信号驱动 IO非阻塞(信号通知)阻塞Java 未直接支持
异步 IO(AIO)非阻塞非阻塞(内核完成后回调)AIO(java.nio.channels.Asynchronous*

关键理解:BIO 和 NIO 的本质区别在于阶段一是否阻塞。NIO 的 Selector 虽然 select() 本身阻塞,但它可以用一个线程同时等待多个 Channel 的事件,这就是多路复用的价值。

2.2 BIO 的流模型 vs NIO 的 Channel/Buffer 模型

mermaid
graph LR
    subgraph BIO - 基于流
        A1[程序] -->|OutputStream 写| A2[外部设备]
        A2 -->|InputStream 读| A1
    end

    subgraph NIO - 基于 Channel + Buffer
        B1[程序] -->|写入 Buffer| B2[Buffer]
        B2 -->|write| B3[Channel]
        B3 -->|read| B4[Buffer]
        B4 -->|读取 Buffer| B1
    end
对比维度BIO(Stream)NIO(Channel + Buffer)
数据方向单向(InputStream 只读,OutputStream 只写)双向(Channel 可读可写)
数据单位逐字节 / 逐字符面向缓冲区(Block)
阻塞性阻塞可配置为非阻塞
多路复用不支持(一个线程一个连接)支持(Selector)
API 复杂度简单直观较复杂(Buffer 状态管理)

三、传统 IO(BIO)详解

3.1 四大基类

mermaid
graph TB
    subgraph 字节流
        IS[InputStream] --> FIS[FileInputStream]
        IS --> BIS[BufferedInputStream]
        IS --> DIS[DataInputStream]
        IS --> OIS[ObjectInputStream]
        IS --> BAIS[ByteArrayInputStream]
        OS[OutputStream] --> FOS[FileOutputStream]
        OS --> BOS[BufferedOutputStream]
        OS --> DOS[DataOutputStream]
        OS --> OOS[ObjectOutputStream]
    end

    subgraph 字符流
        R[Reader] --> FR[FileReader]
        R --> BR[BufferedReader]
        R --> ISR[InputStreamReader]
        W[Writer] --> FW[FileWriter]
        W --> BW[BufferedWriter]
        W --> OSW[OutputStreamWriter]
    end

选择原则

  • 文本数据.txt.csv.json.xml)→ 字符流(Reader/Writer)
  • 二进制数据(图片、音频、视频、.class 文件)→ 字节流(InputStream/OutputStream)

3.2 字节流体系

3.2.1 核心实现类速查

类名用途关键点
FileInputStream / FileOutputStream文件读写务必用 BufferedStream 包装
ByteArrayInputStream / ByteArrayOutputStream内存中的字节数组读写适合测试、内存缓冲
BufferedInputStream / BufferedOutputStream带缓冲的装饰流默认 8KB 缓冲区
DataInputStream / DataOutputStream读写 Java 基本类型按固定字节数读写 int/long/double 等
ObjectInputStream / ObjectOutputStream对象序列化/反序列化需实现 Serializable

3.2.2 BufferedInputStream 缓冲机制(重点)

核心思想:将多次系统调用(read())合并为一次批量读取,减少用户态/内核态切换。

java
// ❌ 错误用法:逐字节读取,每次 read() 触发一次系统调用
try (FileInputStream fis = new FileInputStream("large_file.dat")) {
    int b;
    while ((b = fis.read()) != -1) {
        // 处理字节 —— 极慢!
    }
}

// ✅ 正确用法:用 BufferedInputStream 包装
try (BufferedInputStream bis = new BufferedInputStream(
        new FileInputStream("large_file.dat"))) {
    int b;
    while ((b = bis.read()) != -1) {
        // 实际上大多数 read() 从内存缓冲区取数据,不触发系统调用
    }
}

// ✅✅ 更佳用法:批量读取到自定义数组
try (BufferedInputStream bis = new BufferedInputStream(
        new FileInputStream("large_file.dat"))) {
    byte[] buffer = new byte[4096];
    int bytesRead;
    while ((bytesRead = bis.read(buffer)) != -1) {
        // 处理 buffer[0..bytesRead-1]
    }
}

源码关键点(JDK 8):

java
public class BufferedInputStream extends FilterInputStream {
    protected volatile byte buf[];                // 内部缓冲区
    private static int DEFAULT_BUFFER_SIZE = 8192; // 默认 8KB

    public synchronized int read() throws IOException {
        if (pos >= count) {       // 缓冲区已读完
            fill();               // 从底层流批量填充缓冲区
            if (pos >= count)
                return -1;
        }
        return getBufIfOpen()[pos++] & 0xff; // 从缓冲区取一个字节
    }
}

性能对比(读取 100MB 文件):

方式耗时(参考值)
FileInputStream.read()(逐字节)~40s
BufferedInputStream.read()(逐字节但有缓冲)~0.6s
FileInputStream.read(byte[8192])(批量读)~0.3s
BufferedInputStream.read(byte[8192])~0.3s

结论:如果你已经自己用 byte[] 批量读取,BufferedInputStream 的提升不明显;但如果代码逐字节读取,必须包装 BufferedInputStream

3.2.3 装饰器模式(Decorator Pattern)

Java IO 的设计是经典的装饰器模式范例:

mermaid
classDiagram
    class InputStream {
        <<abstract>>
        +read() int
        +read(byte[]) int
        +close()
    }
    class FileInputStream {
        +read() int
    }
    class FilterInputStream {
        #InputStream in
        +read() int
    }
    class BufferedInputStream {
        -byte[] buf
        +read() int
    }
    class DataInputStream {
        +readInt() int
        +readUTF() String
    }

    InputStream <|-- FileInputStream
    InputStream <|-- FilterInputStream
    FilterInputStream <|-- BufferedInputStream
    FilterInputStream <|-- DataInputStream
    FilterInputStream o-- InputStream : wraps

层层包装示例

java
// FileInputStream → BufferedInputStream → DataInputStream
// 底层数据源     →  添加缓冲能力        →  添加类型化读取能力
DataInputStream dis = new DataInputStream(
    new BufferedInputStream(
        new FileInputStream("data.bin")
    )
);
int value = dis.readInt();    // 读取一个 int(4字节)
String text = dis.readUTF();  // 读取 UTF-8 字符串

设计优势

  • 各层职责单一,可自由组合
  • 扩展新功能无需修改已有类(如 GZIPInputStreamCipherInputStream
  • 符合开闭原则

3.3 字符流与编码转换

3.3.1 桥梁类:InputStreamReader / OutputStreamWriter

java
// ✅ 显式指定 UTF-8 编码(推荐)
try (BufferedReader br = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("data.txt"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
}

// ❌ 不指定编码 —— 使用 JVM 默认编码(平台相关,可能产生乱码)
new InputStreamReader(new FileInputStream("data.txt")); // 危险!

3.3.2 UTF-8 编码原理

UTF-8 是变长编码,使用 1~4 个字节表示一个 Unicode 码点:

Unicode 范围UTF-8 字节数二进制格式示例
U+0000 ~ U+007F1 字节0xxxxxxxA0x41
U+0080 ~ U+07FF2 字节110xxxxx 10xxxxxxé0xC3 0xA9
U+0800 ~ U+FFFF3 字节1110xxxx 10xxxxxx 10xxxxxx0xE4 0xB8 0xAD
U+10000 ~ U+10FFFF4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx😀 → 0xF0 0x9F 0x98 0x80

常用字符编码对比

编码英文中文特点
UTF-81 字节3 字节互联网标准,兼容 ASCII
Unicode(UTF-16)2 字节2 字节Java char 内部编码
GBK1 字节2 字节Windows 中文系统常用

Java String 内部编码:JDK 8 使用 char[](UTF-16),JDK 9+ 引入 Compact Stringsbyte[] + coder 标记),Latin-1 字符只用 1 字节,节省约 50% 内存。

3.3.3 字符集乱码排查清单

mermaid
flowchart TD
    A[出现乱码] --> B{确认文件实际编码}
    B -->|使用 file 命令或 Hex 查看器| C{代码中是否显式指定编码?}
    C -->|否| D[添加 StandardCharsets.UTF_8]
    C -->|是| E{编码与文件实际编码一致?}
    E -->|否| F[修正为一致的编码]
    E -->|是| G{检查 JVM 默认编码}
    G --> H[设置 -Dfile.encoding=UTF-8]
    G --> I{检查数据库连接字符集}
    I --> J[URL 加 characterEncoding=utf8]

黄金法则:在所有 IO 边界(文件读写、网络传输、数据库连接)显式指定 StandardCharsets.UTF_8

3.4 对象序列化与反序列化

3.4.1 基本用法

java
// 实体类
public class User implements Serializable {
    private static final long serialVersionUID = 1L; // ⚠️ 必须显式声明
    
    private String name;
    private int age;
    private transient String password; // transient:不参与序列化
    
    // 构造方法、getter/setter 省略
}

// 序列化(写入)
try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("user.ser"))) {
    oos.writeObject(new User("张三", 25, "secret123"));
}

// 反序列化(读取)
try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("user.ser"))) {
    User user = (User) ois.readObject();
    System.out.println(user.getName());     // "张三"
    System.out.println(user.getPassword()); // null(transient 字段)
}

3.4.2 安全风险与替代方案

方案安全性跨语言数据大小推荐度
Java 原生序列化❌ 存在反序列化漏洞
JSON(Jackson/Gson)⭐⭐⭐⭐⭐
Protocol Buffers⭐⭐⭐⭐⭐
Hessian部分⭐⭐⭐

安全建议:如果必须使用 Java 序列化,配合 JEP 290(JDK 9+)的反序列化过滤器(ObjectInputFilter)进行白名单控制。


四、NIO(New IO)详解

4.1 三大核心组件

mermaid
graph LR
    subgraph NIO 三大组件
        S[Selector 选择器] -->|管理多个| C[Channel 通道]
        C -->|数据读写通过| B[Buffer 缓冲区]
    end
    
    C1[SocketChannel] -.->|注册| S
    C2[SocketChannel] -.->|注册| S
    C3[ServerSocketChannel] -.->|注册| S

4.2 Channel(通道)

Channel 是双向的数据传输通道,主要实现类:

Channel 类型用途是否支持非阻塞
FileChannel文件读写❌(始终阻塞)
SocketChannelTCP 客户端
ServerSocketChannelTCP 服务端
DatagramChannelUDP 通信

4.3 Buffer(缓冲区)

4.3.1 三个核心属性

capacity(容量):缓冲区总大小,创建后不可变
position(位置):下一个要读/写的位置
limit(限制):可读/写的边界

不变式:0 ≤ position ≤ limit ≤ capacity

4.3.2 Buffer 状态切换(重中之重)

mermaid
stateDiagram-v2
    [*] --> 写模式: allocate()
    写模式 --> 读模式: flip()
    读模式 --> 写模式: clear() / compact()
    
    note right of 写模式
        position: 当前写入位置
        limit = capacity
    end note
    
    note right of 读模式
        position: 0(flip 后)
        limit: 之前的 position
    end note

详细图解

1. 初始状态 (allocate(10)):
   position=0, limit=10, capacity=10
   [_|_|_|_|_|_|_|_|_|_]
    ↑pos                ↑limit=capacity

2. 写入 4 个字节 (put()):
   position=4, limit=10, capacity=10
   [A|B|C|D|_|_|_|_|_|_]
              ↑pos      ↑limit

3. flip() 切换为读模式:
   position=0, limit=4, capacity=10
   [A|B|C|D|_|_|_|_|_|_]
    ↑pos      ↑limit

4. 读取 2 个字节 (get()):
   position=2, limit=4, capacity=10
   [A|B|C|D|_|_|_|_|_|_]
        ↑pos  ↑limit

5. compact() 压缩未读数据,切回写模式:
   position=2, limit=10, capacity=10
   [C|D|_|_|_|_|_|_|_|_]
        ↑pos              ↑limit

代码示例

java
// ByteBuffer 基本操作
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 写入数据
buffer.put("Hello NIO".getBytes(StandardCharsets.UTF_8));

// 切换为读模式(重要!忘记 flip 是最常见的 NIO Bug)
buffer.flip();

// 读取数据
byte[] bytes = new byte[buffer.remaining()]; // remaining() = limit - position
buffer.get(bytes);
System.out.println(new String(bytes, StandardCharsets.UTF_8)); // "Hello NIO"

// 清空,准备下次写入
buffer.clear();    // position=0, limit=capacity(数据未清除,只是标记重置)
// 或
buffer.compact();  // 将未读数据移到开头,position=remaining, limit=capacity

4.3.3 堆内 Buffer vs 直接内存(Direct Buffer)

java
// 堆内缓冲区(JVM 堆上分配)
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

// 直接内存缓冲区(堆外内存,OS 本地内存)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
对比维度Heap BufferDirect Buffer
内存位置JVM 堆OS 本地内存(堆外)
分配速度慢(涉及 OS 系统调用)
GC 管理✅ 受 GC 管理❌ 不受 GC 直接管理
IO 性能较低(需内核→堆拷贝)较高(减少一次拷贝)
适用场景临时、小量数据长期存活、大量 IO(如网络框架)

Direct Buffer IO 优势原理

Heap Buffer IO 路径(4 次拷贝):
  磁盘 → 内核缓冲区 → JVM 堆外临时区 → JVM 堆 Buffer → 应用程序

Direct Buffer IO 路径(3 次拷贝):
  磁盘 → 内核缓冲区 → Direct Buffer(堆外)→ 应用程序
  
减少了「堆外临时区 → JVM 堆」这次拷贝

最佳实践:Direct Buffer 分配昂贵,应池化复用(Netty 的 PooledByteBufAllocator 就是这么做的)。通过 -XX:MaxDirectMemorySize 控制上限,避免堆外 OOM。

4.3.4 零拷贝:FileChannel.transferTo()

java
// 传统方式:文件 → 网络(4 次数据拷贝 + 4 次上下文切换)
FileInputStream fis = new FileInputStream("large_file.dat");
SocketOutputStream sos = socket.getOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = fis.read(buffer)) != -1) {
    sos.write(buffer, 0, len); // 数据经过用户空间中转
}

// 零拷贝方式:数据不经过用户空间(2 次拷贝 + 2 次上下文切换)
FileChannel fileChannel = new FileInputStream("large_file.dat").getChannel();
SocketChannel socketChannel = SocketChannel.open(
    new InetSocketAddress("localhost", 8080));
// 利用 OS 的 sendfile 系统调用
fileChannel.transferTo(0, fileChannel.size(), socketChannel);

零拷贝数据流向对比

传统拷贝(4 次):
  磁盘 → 内核页缓存 → 用户空间 buffer → socket 内核缓冲区 → 网卡

零拷贝 sendfile(2 次):
  磁盘 → 内核页缓存 → 网卡(DMA 直传,跳过用户空间)

应用场景:Kafka 消费消息时使用 FileChannel.transferTo() 将日志文件直接发送到网络,是其高吞吐的关键技术之一。

4.4 Selector 多路复用

4.4.1 工作流程

mermaid
sequenceDiagram
    participant App as 应用程序
    participant Sel as Selector
    participant CH1 as Channel 1
    participant CH2 as Channel 2
    participant CH3 as Channel 3

    App->>Sel: Selector.open()
    CH1->>Sel: register(selector, OP_ACCEPT)
    CH2->>Sel: register(selector, OP_READ)
    CH3->>Sel: register(selector, OP_READ)
    
    loop 事件循环
        App->>Sel: select() [阻塞等待就绪事件]
        Sel-->>App: 返回就绪的 SelectionKey 集合
        App->>App: 遍历 keys,处理就绪的 Channel
    end

4.4.2 NIO Server 完整示例

java
public class NioEchoServer {
    public static void main(String[] args) throws IOException {
        // 1. 创建 Selector
        Selector selector = Selector.open();
        
        // 2. 创建 ServerSocketChannel 并配置非阻塞
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);
        serverChannel.bind(new InetSocketAddress(8080));
        
        // 3. 注册 ACCEPT 事件到 Selector
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("NIO Echo Server started on port 8080");
        
        // 4. 事件循环
        while (true) {
            selector.select(); // 阻塞直到至少一个事件就绪
            
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectedKeys.iterator();
            
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove(); // ⚠️ 必须手动移除,否则下次还会处理
                
                if (key.isAcceptable()) {
                    // 处理新连接
                    handleAccept(key, selector);
                } else if (key.isReadable()) {
                    // 处理读事件
                    handleRead(key);
                }
            }
        }
    }
    
    private static void handleAccept(SelectionKey key, Selector selector) 
            throws IOException {
        ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
        SocketChannel clientChannel = serverChannel.accept();
        clientChannel.configureBlocking(false);
        // 新连接注册 READ 事件
        clientChannel.register(selector, SelectionKey.OP_READ);
        System.out.println("New connection: " + clientChannel.getRemoteAddress());
    }
    
    private static void handleRead(SelectionKey key) throws IOException {
        SocketChannel clientChannel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        
        int bytesRead = clientChannel.read(buffer);
        if (bytesRead == -1) {
            // 客户端断开连接
            key.cancel();
            clientChannel.close();
            return;
        }
        
        buffer.flip(); // ⚠️ 切换为读模式
        // Echo: 将收到的数据原样写回
        clientChannel.write(buffer);
    }
}

4.4.3 epoll 原理与触发模式

特性selectpollepoll
最大连接数1024(FD_SETSIZE)无限制无限制
时间复杂度$O(n)$ 轮询$O(n)$ 轮询$O(1)$ 事件通知
数据结构bitmap链表红黑树 + 就绪链表
内存拷贝每次全量拷贝 fd 集合每次全量拷贝epoll_ctl 只增量修改
触发模式LTLTLT + ET

两种触发模式

模式行为优点缺点
LT(水平触发)只要缓冲区有数据就持续通知编程简单,不易丢数据可能重复通知,性能略低
ET(边缘触发)仅在数据到达瞬间通知一次减少通知次数,性能更高必须一次读完所有数据(循环 read 直到 EAGAIN)

Netty 默认使用 ET 模式EpollSocketChannel),并在内部处理了 ET 模式的复杂性。Java 原生 NIO 的 Selector 在 Linux 上使用 LT 模式的 epoll

4.4.4 Reactor 模式与 Netty

mermaid
graph TB
    subgraph "主从 Reactor 模型(Netty 架构)"
        C[客户端连接] --> MR[MainReactor<br/>Boss EventLoopGroup<br/>处理 ACCEPT]
        MR -->|新连接分发| SR1[SubReactor 1<br/>Worker EventLoop<br/>处理 READ/WRITE]
        MR -->|新连接分发| SR2[SubReactor 2<br/>Worker EventLoop<br/>处理 READ/WRITE]
        MR -->|新连接分发| SR3[SubReactor N<br/>Worker EventLoop<br/>处理 READ/WRITE]
        SR1 --> H1[ChannelHandler<br/>Pipeline]
        SR2 --> H2[ChannelHandler<br/>Pipeline]
        SR3 --> H3[ChannelHandler<br/>Pipeline]
    end

Reactor 模式 → Netty 组件映射

Reactor 概念Netty 实现
MainReactorBossEventLoopGroup(通常 1 个线程)
SubReactorWorkerEventLoopGroup(通常 CPU 核数 × 2 个线程)
AcceptorServerBootstrap.bind()
HandlerChannelHandlerChannelInboundHandler / ChannelOutboundHandler
事件循环EventLoop(内部封装了 Selector)

4.5 NIO.2:Files 与 Path(Java 7+)

4.5.1 Path vs File

java
// 旧 API(java.io.File)
File file = new File("/home/user/data.txt");

// 新 API(java.nio.file.Path)—— 推荐
Path path = Paths.get("/home/user/data.txt");
// 或 Java 11+
Path path2 = Path.of("/home", "user", "data.txt");

// 路径操作
path.resolve("subdir/file.txt");  // 拼接子路径
path.getParent();                  // 父目录
path.getFileName();                // 文件名
path.normalize();                  // 规范化(消除 ../ 和 ./)
path.toAbsolutePath();             // 转绝对路径

4.5.2 Files 工具类常用操作

java
// 读取文件全部内容
byte[] bytes = Files.readAllBytes(Path.of("data.bin"));
String content = Files.readString(Path.of("data.txt")); // Java 11+
List<String> lines = Files.readAllLines(Path.of("data.txt"), StandardCharsets.UTF_8);

// 写入文件
Files.write(Path.of("output.txt"), "Hello".getBytes(), 
    StandardOpenOption.CREATE, StandardOpenOption.APPEND);
Files.writeString(Path.of("output.txt"), "Hello World"); // Java 11+

// 文件操作
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
Files.delete(path);
Files.createDirectories(Path.of("/a/b/c")); // 递归创建目录

// 文件属性
boolean exists = Files.exists(path);
long size = Files.size(path);
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);

4.5.3 Files.walk() 大目录遍历

java
// ⚠️ 必须用 try-with-resources(Stream 实现了 AutoCloseable)
try (Stream<Path> stream = Files.walk(Path.of("D:\\"), 3)) { // maxDepth=3
    stream.filter(Files::isRegularFile)
          .filter(p -> p.getFileName().toString().equals("target.mp4"))
          .forEach(p -> System.out.println("Found: " + p));
}

// Files.find() 更高效(过滤在遍历过程中执行,而非后过滤)
try (Stream<Path> stream = Files.find(Path.of("D:\\"), Integer.MAX_VALUE,
        (path, attrs) -> attrs.isRegularFile() 
            && path.getFileName().toString().endsWith(".mp4"))) {
    stream.forEach(System.out::println);
}

对比输入内容中的递归遍历方案:输入内容中使用 File.listFiles() 递归遍历 D 盘(约 300GB),耗时约 8.5 秒。使用 Files.walk() 可以达到类似性能,但代码量大幅减少,且懒加载特性不会将所有路径加载到内存。


五、对比与易混淆点

5.1 BIO vs NIO vs AIO 全面对比

维度BIONIOAIO(NIO.2)
Java 版本JDK 1.0JDK 1.4JDK 7
编程模型面向流(Stream)面向缓冲(Buffer)面向回调(CompletionHandler)
阻塞性同步阻塞同步非阻塞(多路复用)异步非阻塞
线程模型1 连接 = 1 线程1 线程管理 N 连接OS 内核完成后回调
吞吐量理论最高
编程复杂度★☆☆★★★★★★★
Linux 底层read()/write()epollio_uring(较新)/ 模拟实现
实际应用小型应用Netty、Kafka、RocketMQ较少(Linux 下 AIO 实现不成熟)

5.2 InputStream vs Channel

对比InputStream(BIO)Channel(NIO)
方向单向(只读或只写)双向(读写均可)
数据处理直接操作字节/字符必须经过 Buffer
阻塞始终阻塞可配置非阻塞
关联 Selector

5.3 clear() vs compact() vs flip()

方法作用positionlimit使用时机
flip()写→读切换0原 position写完数据后准备读取
clear()读→写切换(丢弃所有)0capacity数据全部读完,准备重新写入
compact()读→写切换(保留未读)remainingcapacity数据未读完,保留剩余数据继续写入

六、最佳实践与常见坑

6.1 BIO 最佳实践

  1. 始终在 try-with-resources 中使用流
java
// ✅ 正确
try (InputStream is = new FileInputStream("data.txt");
     BufferedInputStream bis = new BufferedInputStream(is)) {
    // 使用 bis
} // 自动关闭,即使抛异常

// ❌ 错误:手动 close 容易遗漏
InputStream is = new FileInputStream("data.txt");
// ... 如果这里抛异常,is 永远不会被关闭
is.close();
  1. 文件读写始终指定字符编码StandardCharsets.UTF_8

  2. 不要在生产环境使用 Java 原生序列化,改用 JSON 或 Protobuf

  3. 显式声明 serialVersionUID,避免类修改后反序列化失败

6.2 NIO 常见坑

  1. 忘记调用 flip()——这是 NIO 编程中最常见的 Bug
java
buffer.put(data);
// buffer.flip(); // ❌ 忘记 flip,下面 write 将写入空数据
channel.write(buffer);
  1. SelectionKey 未手动移除
java
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
    SelectionKey key = it.next();
    it.remove(); // ⚠️ 必须移除!否则下一次 select() 仍会返回已处理的 key
    // 处理事件...
}
  1. Direct Buffer 内存泄漏

    • Direct Buffer 不受 GC 直接管理,需要通过 Cleaner 或手动释放
    • 设置 -XX:MaxDirectMemorySize 防止堆外 OOM
    • 生产环境应使用池化 Buffer(如 Netty 的 PooledByteBufAllocator
  2. FileChannel 不支持非阻塞模式——不能注册到 Selector

  3. 半包/粘包问题:TCP 是流式协议,一次 read() 可能读到半个消息或多个消息,需要应用层处理消息边界(长度前缀 / 分隔符 / 固定长度)

6.3 文件操作最佳实践

需求推荐 API说明
简单读写小文件Files.readString() / Files.writeString() (Java 11+)最简洁
读大文件Files.newBufferedReader() + 逐行处理避免 OOM
文件拷贝Files.copy()底层可能使用零拷贝
遍历目录Files.walk() / Files.find()懒加载,注意 try-with-resources
监控文件变化WatchService配置热更新场景

七、面试高频问题

Q1:BIO、NIO、AIO 的区别是什么?

:三者本质区别在于 IO 操作的阻塞方式:

  • BIO:同步阻塞,线程在 read()/accept() 时阻塞,直到有数据/连接。一个连接需要一个线程,并发能力差。
  • NIO:同步非阻塞,基于 Channel/Buffer/Selector。通过 Selector 多路复用,一个线程可监控多个 Channel 的 IO 事件,适合高并发场景。
  • AIO:异步非阻塞,IO 操作由 OS 内核完成后通过回调通知应用程序,应用线程无需等待。但 Linux 下 AIO 实现不成熟,实际使用较少。

Q2:NIO 中 Buffer 的 flip()、clear()、compact() 分别做什么?

  • flip():写模式→读模式。limit = position; position = 0,准备读取之前写入的数据。
  • clear():读模式→写模式。position = 0; limit = capacity,清空标记(数据未擦除)准备重新写入。
  • compact():读模式→写模式。将未读数据移到缓冲区头部,position = remaining; limit = capacity,适合数据未完全读完的场景。

Q3:什么是零拷贝?Java 中如何实现?

:零拷贝是指数据传输过程中避免 CPU 在用户空间与内核空间之间拷贝数据。Java 中通过两种方式实现:

  1. FileChannel.transferTo():底层调用 Linux sendfile() 系统调用,数据直接从内核页缓存传输到网卡缓冲区,不经过用户空间。
  2. MappedByteBuffer(mmap):将文件映射到进程虚拟内存,读写文件如同读写内存。

Kafka 的高吞吐量和 Netty 的高性能都大量使用了零拷贝技术。

Q4:Netty 为什么不直接使用 Java NIO?

:Java 原生 NIO 存在以下问题:

  1. API 复杂,Buffer 状态管理容易出错(如忘记 flip)
  2. Selector 在 Linux 上存在 epoll 空轮询 bug(JDK Bug 6670302),导致 CPU 100%
  3. 没有完善的半包/粘包处理
  4. 没有内置的心跳、重连、超时机制
  5. 没有 Buffer 池化,Direct Buffer 管理困难

Netty 封装了 Reactor 模式,解决了以上所有问题,并提供了 PooledByteBufAllocatorChannelPipelineEventLoop 等高级抽象。

Q5:Java 中如何避免字符编码乱码?

:核心原则——在所有 IO 边界显式指定 StandardCharsets.UTF_8

  1. 文件读写:new InputStreamReader(is, StandardCharsets.UTF_8)
  2. 网络传输:统一约定 UTF-8
  3. 数据库连接:URL 加 characterEncoding=utf8
  4. JVM 参数:-Dfile.encoding=UTF-8(作为兜底)
  5. 确认数据源本身的编码与代码中指定的一致

八、总结

┌─────────────────────────────────────────────────────────────┐
│                    Java IO 知识体系总结                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  BIO(传统 IO)                                              │
│  ├─ 字节流:InputStream / OutputStream                       │
│  ├─ 字符流:Reader / Writer(注意编码,统一 UTF-8)            │
│  ├─ 缓冲流:BufferedXxx(8KB 缓冲,减少系统调用)             │
│  ├─ 设计模式:装饰器模式(层层包装,职责单一)                  │
│  └─ 序列化:优先 JSON/Protobuf,避免 Java 原生序列化           │
│                                                             │
│  NIO(New IO)                                               │
│  ├─ Channel:双向通道(FileChannel / SocketChannel)          │
│  ├─ Buffer:缓冲区(flip/clear/compact 状态切换)             │
│  │   ├─ Heap Buffer vs Direct Buffer                        │
│  │   └─ 零拷贝:transferTo() → sendfile 系统调用              │
│  ├─ Selector:多路复用(一个线程管理 N 连接)                  │
│  │   └─ 底层:epoll (Linux) / kqueue (Mac)                   │
│  └─ Reactor 模式 → Netty(Boss/Worker EventLoopGroup)       │
│                                                             │
│  NIO.2(Java 7+)                                            │
│  ├─ Path + Files:现代文件操作 API                            │
│  ├─ Files.walk():懒加载目录遍历(必须 try-with-resources)    │
│  └─ WatchService:文件变更监控                                │
│                                                             │
│  核心原则                                                    │
│  ├─ IO 边界显式指定 UTF-8 编码                                │
│  ├─ 流必须在 try-with-resources 中使用                        │
│  ├─ 大文件使用 BufferedStream 或 NIO Channel                  │
│  └─ 高并发网络场景使用 NIO(Netty)而非 BIO                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘