Java IO 与 NIO

Java IO 与 NIO:从阻塞流到 Selector 多路复用

引言

Java 的 IO 体系经历了三个阶段:JDK 1.0 的 BIO(阻塞 IO)、JDK 1.4 的 NIO(非阻塞 + 多路复用)、JDK 7 的 AIO(异步 IO,NIO.2)。理解它们的区别,是理解 Netty、Tomcat、Kafka 等一切高并发网络框架的基础。

这篇文章从 BIO 的瓶颈讲起,重点拆解 NIO 的三大件(Buffer / Channel / Selector),介绍文件 IO 的实用 API(Files、零拷贝),最后给出选型建议。


一、BIO:一连接一线程

传统 IO(java.io 包)基于,读写都是阻塞的:

java 复制代码
// 经典 BIO 服务器:每个连接一个线程
try (ServerSocket server = new ServerSocket(8080)) {
    while (true) {
        Socket socket = server.accept();       // 阻塞等待连接
        new Thread(() -> {
            try (InputStream in = socket.getInputStream()) {
                byte[] buf = new byte[1024];
                int len;
                while ((len = in.read(buf)) != -1) { // 阻塞等待数据
                    // 处理数据
                }
            } catch (IOException e) { ... }
        }).start();
    }
}

BIO 的瓶颈:

  • 一连接一线程:1 万连接 = 1 万线程 ≈ 10GB 栈内存(每线程 1MB),还没算上下文切换开销
  • 线程大部分时间在 read 上阻塞等待,CPU 利用率极低

改进版是"线程池 + BIO"(伪异步 IO),但连接数远超线程数时,请求会排队等待,本质没解决问题。


二、NIO 三大件:Buffer、Channel、Selector

NIO(java.nio 包)的核心思想:一个线程管理多个连接

2.1 Buffer:数据容器

NIO 的数据读写都经过 Buffer。核心四要素:

makefile 复制代码
capacity: 容量上限
position: 当前读写位置
limit:    可读写的边界
mark:     标记位置(备份 position)
java 复制代码
ByteBuffer buf = ByteBuffer.allocate(1024);
channel.read(buf);    // 数据写入 buffer,position 前进
buf.flip();           // 切换为读模式:limit = position, position = 0
while (buf.hasRemaining()) {
    byte b = buf.get();
}
buf.clear();          // 切换回写模式(数据未清除,只是重置指针)

flip/clear 是 NIO 新手的第一道坎:忘了 flip 就读不到数据(limit 还在 capacity,position 在末尾)。

2.2 Channel:双向通道

Channel 用途
FileChannel 文件读写
SocketChannel TCP 客户端
ServerSocketChannel TCP 服务端
DatagramChannel UDP

Channel 是双向的(可读可写),流是单向的(InputStream 只读)。

2.3 Selector:多路复用的核心

java 复制代码
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();                    // 阻塞直到有事件就绪
    Iterator<SelectionKey> it = selector.selectedKeys().iterator();
    while (it.hasNext()) {
        SelectionKey key = it.next();
        it.remove();                      // 必须手动移除,否则重复处理

        if (key.isAcceptable()) {
            SocketChannel client = server.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buf = ByteBuffer.allocate(1024);
            int len = client.read(buf);
            if (len == -1) {
                client.close();
            } else {
                // 处理数据
            }
        }
    }
}

Selector 底层是操作系统的 IO 多路复用:Linux 的 epoll、macOS 的 kqueue。一个线程监听成千上万个连接,只有连接真正有数据时才处理------这就是 Redis、Netty 单线程模型的基础。

NIO 的代价:API 极其繁琐(上面 40 行才干完 BIO 10 行的事),所以实际项目几乎不直接写 NIO,而是用 Netty 封装。


三、BIO vs NIO vs AIO

维度 BIO NIO AIO (NIO.2)
模型 同步阻塞 同步非阻塞 + 多路复用 异步非阻塞
线程模型 一连接一线程 一线程多连接 回调通知,无需轮询
面向 流(Stream) 块(Buffer) 块(Buffer)
适用场景 连接数少且固定 连接数多、短请求(聊天、推送) 连接数多、长请求(相册服务器)
代表应用 早期 Tomcat Netty、Redis、Kafka Linux 支持不完善,用得少

AIO 的现实:Linux 的 epoll 太成功了,AIO(基于 io_uring 之前的实现)收益不明显,Netty 曾支持后又移除。JDK 21+ 的虚拟线程则给出了另一条路:用同步阻塞的写法获得异步的吞吐量(见多线程篇)。


四、文件 IO:java.nio.file(NIO.2)

日常开发用得最多的是 Files 工具类,比老的 File + Stream 简洁得多:

java 复制代码
Path path = Path.of("/data/logs/app.log");

// 读写(小文件一把梭)
String content = Files.readString(path);                    // Java 11+
List<String> lines = Files.readAllLines(path);
Files.writeString(Path.of("/tmp/out.txt"), content);        // Java 11+
byte[] bytes = Files.readAllBytes(path);

// 流式读写(大文件,不会撑爆内存)
try (BufferedReader r = Files.newBufferedReader(path);
     BufferedWriter w = Files.newBufferedWriter(Path.of("/tmp/copy.log"))) {
    String line;
    while ((line = r.readLine()) != null) {
        w.write(line);
        w.newLine();
    }
}

// 常用操作
Files.exists(path);
Files.createDirectories(Path.of("/data/new/dir"));
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
Files.copy(in, dst, StandardCopyOption.REPLACE_EXISTING);
Files.size(path);
Files.list(dir);      // 惰性 Stream,记得 try-with-resources 关闭

// 遍历目录树
try (Stream<Path> walk = Files.walk(Path.of("/data"))) {
    walk.filter(Files::isRegularFile)
        .filter(p -> p.toString().endsWith(".log"))
        .forEach(System.out::println);
}

注意Files.readAllLines / readString 会把整个文件读进内存,GB 级文件必须用流式 API。


五、零拷贝

传统文件传输(磁盘 → 网卡)要经过 4 次拷贝 + 4 次用户态/内核态切换:

复制代码
磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡

零拷贝绕过用户缓冲区,数据不出内核:

java 复制代码
// FileChannel.transferTo:底层是 Linux sendfile
try (FileChannel file = FileChannel.open(Path.of("/data/video.mp4"), READ);
     SocketChannel socket = SocketChannel.open(serverAddr)) {
    long size = file.size();
    long sent = 0;
    while (sent < size) {
        sent += file.transferTo(sent, size - sent, socket);
    }
}

// MappedByteBuffer:内存映射文件(大文件随机读写)
try (FileChannel ch = FileChannel.open(path, READ)) {
    MappedByteBuffer mbb = ch.map(FileChannel.MapMode.READ_ONLY, 0, ch.size());
    // 像访问内存一样访问文件,缺页由 OS 处理
}

应用案例:Kafka 用 sendfile 把磁盘消息直接推给网卡;RocketMQ 用 mmap 读写 CommitLog。


六、序列化:IO 的隐形陷阱

网络传输和文件存储都涉及对象序列化:

方案 特点
Java Serializable JDK 原生,体积大、慢、安全漏洞多,不推荐
JSON(Jackson/Gson) 可读性好,通用,体积中等
Protobuf 二进制,体积小速度快,需要 IDL
Kryo Java 生态最快的二进制序列化之一

Java 原生序列化的坑

java 复制代码
// 1. 反序列化 = 执行任意代码(ysoserial 攻击链),永远不要反序列化不可信数据
Object obj = new ObjectInputStream(untrustedInput).readObject(); // 危险!

// 2. serialVersionUID 不显式声明,类结构一改就无法反序列化旧数据
private static final long serialVersionUID = 1L;

// 3. static 和 transient 字段不会被序列化

新项目对外接口一律 JSON/Protobuf,Java 原生序列化只在框架内部出现。


七、常见陷阱

7.1 Buffer 忘记 flip

读模式下 limit 还停在 capacity,hasRemaining() 直接 false,"读不到数据"。flip 之后再读,读完 clear/compact。

7.2 Selector 的 selectedKeys 不移除

处理完不 iterator.remove(),下一轮 select 会重复处理同一个 key,导致重复响应甚至 CPU 空转。

7.3 Files.readAllBytes 读大文件

整个文件进堆内存,512MB 的文件直接 OOM。大文件用 newBufferedReader / newInputStream 流式处理。

7.4 FileChannel 与 Files 流混用不关闭

FileChannel 持有 OS 文件描述符,不 close 会泄漏。Files.list / Files.walk 返回的 Stream 也持有目录句柄,必须 try-with-resources。

7.5 用 BIO 硬扛高并发

"先跑起来再优化"可以,但要心里有数:BIO 模型在连接数 > 线程池大小时开始排队。长连接场景(WebSocket、推送)直接上 Netty 或虚拟线程。

7.6 反序列化不可信数据

Java 原生反序列化是 RCE 漏洞重灾区。对外接口禁用 Serializable,改用 JSON。


八、总结

需求 方案
简单文件读写 Files.readString / writeString / newBufferedReader
大文件处理 流式 API 或 MappedByteBuffer
高并发网络 Netty(NIO)或 JDK 21 虚拟线程(BIO 写法)
文件直传网络 FileChannel.transferTo(sendfile)
对象传输 JSON / Protobuf,不用 Java 原生序列化

IO 选型的关键问题:连接数多少?连接存活多久?数据量多大? 短连接低并发用 BIO 足够;海量长连接用 NIO/Netty;JDK 21 之后,虚拟线程让"同步阻塞写法 + 高并发"重新成为可行选项。


参考资料


作者 :eralong

个人网站eralong.com

相关推荐
步行cgn1 小时前
Spring Cache 详解:Spring 框架的缓存抽象
后端
量化小c1 小时前
从数据到策略:QuantDash + DuckDB 搭建 5 分钟 K 线本地量化数据仓库
后端·github
马可家的菠萝1 小时前
AI Agent Demo 10 分钟就能跑,真正难的是让它 200 个场景都别乱来
前端·后端·aigc
菜鸟谢1 小时前
Rust 数据类型 完整超详细知识点
后端
菜鸟谢1 小时前
Rust let / static / const 完整详解
后端
超超不吵吵1 小时前
Java AI转型实战(三):第一次调用大模型API,拿到AI回复
后端
Jesse_EC1 小时前
我给消息推送加了 Publisher Confirm,然后亲手引入了一个竞态
java
名字还没想好☜1 小时前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器
S3rein1 小时前
JAVA多线程手撕
java·多线程