IO · NIO · Okio — 三种 I/O 模型对比

从传统 Stream 到 NIO Channel+Buffer,再到 Okio 的优雅封装,理解 Java I/O 的演进脉络

传统 IO (Stream) NIO (Channel+Buffer) Okio (Source/Sink) Buffer 三指针 阻塞 vs 非阻塞 Selector

目录导航(点击跳转)

一、传统 IO:Stream 模型回顾

1 Stream 的核心思想:单向流动

Java 传统 I/O(java.io包)的核心抽象是流(Stream)。流是一个单向的数据管道,数据像水流一样从一端流向另一端:

  • InputStream / OutputStream:字节流,以 byte为单位读写
  • Reader / Writer:字符流,以 char为单位读写,自动处理编码

流的特点是单向:你要么在读(InputStream),要么在写(OutputStream),一个流不能同时做两件事。

2 传统 IO 代码示例
// 传统 IO:读取文件
try (FileInputStream fis = new FileInputStream("test.txt");
     BufferedInputStream bis = new BufferedInputStream(fis)) {

    byte[] buffer = new byte[1024];
    int bytesRead;
    while ((bytesRead = bis.read(buffer)) != -1) {
        // 处理读取到的数据
        System.out.write(buffer, 0, bytesRead);
    }

} catch (IOException e) {
    e.printStackTrace();
}

// 传统 IO:写入文件
try (FileOutputStream fos = new FileOutputStream("output.txt");
     BufferedOutputStream bos = new BufferedOutputStream(fos)) {

    bos.write("Hello World".getBytes());
    bos.flush();  // 缓冲流需要手动 flush

} catch (IOException e) {
    e.printStackTrace();
}
3 Stream 的装饰器模式

传统 IO 大量使用装饰器模式来叠加功能:基础流负责原始读写,装饰流负责增强(缓冲、转换、过滤等)。

文件
FileInputStream
原始读取
BufferedInputStream
加缓冲
内存

每个装饰器都是一层包装,像洋葱皮一样层层包裹,每层增加一种能力。优点是灵活组合,缺点是类爆炸——想加个缓冲就得套一层。

二、NIO:Channel 登场,Buffer 成为核心

1 Channel 替代 Stream

NIO(java.nio包,Java 1.4 引入)用 Channel(通道)替代了 Stream。Channel 和 Stream 最大的区别:

  • Channel 是双向的:同一个 Channel 可以同时读和写(比如 SocketChannel
  • Channel 必须通过 Buffer 读写:你不能直接从 Channel 读数据到字节数组,必须经过 Buffer 中转
  • Channel 支持非阻塞模式(仅限网络 Channel)
2 NIO 代码示例
// NIO:读取文件(必须通过 Buffer)
try (FileChannel channel = FileChannel.open(
        Path.of("test.txt"),
        StandardOpenOption.READ)) {

    // 1. 分配一个 Buffer(必须!)
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    // 2. 从 Channel 读 → 数据写入 Buffer
    int bytesRead = channel.read(buffer);

    // 3. 翻转 Buffer:写模式 → 读模式
    buffer.flip();

    // 4. 从 Buffer 中取出数据
    byte[] data = new byte[buffer.remaining()];
    buffer.get(data);
    System.out.write(data);

} catch (IOException e) {
    e.printStackTrace();
}

// NIO:写入文件
try (FileChannel channel = FileChannel.open(
        Path.of("output.txt"),
        StandardOpenOption.WRITE,
        StandardOpenOption.CREATE)) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello World".getBytes());
    channel.write(buffer);  // Buffer → Channel → 文件

} catch (IOException e) {
    e.printStackTrace();
}
3 Channel + Buffer 的工作流程

NIO 的 I/O 操作永远是Channel ↔ Buffer的双向交互。没有 Buffer,Channel 什么也做不了:

文件/网络
Channel
通道(双向)
Buffer
缓冲区(必须)
内存/程序
关键结论:在 NIO 中,Buffer 是强制性的。你不能绕过 Buffer 直接读写 Channel。Buffer 是 NIO 工作流中不可分割的一部分

三、NIO Buffer 三指针:capacity / limit / position

1 三个核心属性

每个 NIO Buffer 内部都维护了三个关键指针,它们控制着 Buffer 的读写行为。理解这三个属性是用好 NIO 的前提

属性含义类比
capacity 缓冲区的总容量,创建时指定,不可变 仓库的总面积
limit 当前可以读/写的最大位置(指针最大能指到哪) 仓库里的隔离带,"到此为止"
position 当前读/写指针的实际位置(指针读/写到哪了) 你现在站在仓库的哪个位置

三者关系恒满足:0 ≤ position ≤ limit ≤ capacity

2 写模式 vs 读模式:flip() 的神奇翻转

Buffer 在写模式读模式之间切换,关键操作就是 flip()

写模式(初始状态)

  • capacity= 1024
  • limit= 1024(和 capacity 一样,能写满)
  • position= 0 → 随着写入递增

含义:我可以从第 0 位开始写,最多写到 capacity 为止

读模式(flip() 之后)

  • capacity= 1024(不变)
  • limit= 写入时的 position(只读到有数据的位置)
  • position= 0(从头开始读)

含义:我只能读到之前写入的位置,不会读出脏数据

// flip() 的等价逻辑
// 写模式 → 读模式
buffer.flip();
// 等价于:
// limit = position;   // 记录写了多少数据
// position = 0;       // 从头开始读

// 读模式 → 写模式(清空重置)
buffer.clear();
// 等价于:
// position = 0;
// limit = capacity;   // 重新允许写满

// 读模式 → 写模式(保留未读数据,继续写)
buffer.compact();
// 把未读数据移到开头,position 指向未读数据末尾
3 Buffer 三指针状态图
初始状态
pos=0, lim=cap=1024
写入 N 字节
pos=N, lim=1024
flip()
pos=0, lim=N
读取 N 字节
pos=N, lim=N
clear()
pos=0, lim=1024
常见坑:忘记 flip()就直接读,会从 position=0读到 limit=capacity,读出全是 0(脏数据)。忘记 clear()就继续写,position还在末尾,写入会越界或写不进去。

四、NIO 的非阻塞模式与 Selector

1 非阻塞 ≠ NIO 的默认行为

一个常见的误解是"NIO 就是非阻塞 IO"。实际上:

NIO 只是支持非阻塞模式,默认仍然是阻塞的。你必须显式调用 channel.configureBlocking(false)才能启用非阻塞。

更关键的限制是:

Channel 类型支持非阻塞?说明
SocketChannel支持网络 TCP 通道,NIO 的核心使用场景
ServerSocketChannel支持服务端监听通道,配合 Selector 使用
DatagramChannel支持UDP 通道
FileChannel 不支持文件 I/O 永远是阻塞的,操作系统层面就不支持非阻塞文件操作
为什么文件不支持非阻塞?这是操作系统层面的限制。Linux 的 epoll对普通文件永远返回"可读",因为磁盘 I/O 在 OS 看来不存在"数据还没到"的情况——要么能读,要么报错。只有网络和管道才存在"等数据到达"的场景。
2 Selector 多路复用

非阻塞模式必须配合 Selector(选择器)使用才有意义。Selector 像一个大管家,同时监听多个 Channel,哪个 Channel 就绪了就处理哪个:

Selector 线程(1个)
1注册 Channel 到 Selector
2select() 阻塞等待事件
3遍历就绪的 Channel
4处理读/写/连接事件
注册
事件通知
多个 Channel
C1SocketChannel #1
C2SocketChannel #2
C3SocketChannel #3
...成千上万个连接
核心优势:1 个线程 + 1 个 Selector = 管理 成千上万个连接。这正是 Netty、Tomcat NIO、Redis、Nginx 能支撑高并发的底层原理。

五、Okio:回归 Stream,但更优雅

1 Okio 的哲学:回到 Stream,但做得更好

Okio 是 Square 公司开源的 I/O 库,现在也是 OkHttp 的底层 I/O 引擎。与 NIO 另起炉灶不同,Okio 回归了 Stream 模型,但重新设计了 API:

  • Source(源):对应"输入",负责读取数据,等价于 InputStream
  • Sink(汇):对应"输出",负责写入数据,等价于 OutputStream
  • 单向设计:Source 只管读,Sink 只管写,和传统 IO 一样单向
文件/网络
Source
(输入源)
程序
程序
Sink
(输出汇)
文件/网络
2 Okio 代码示例(两种风格)

Okio 提供了两种使用方式——需要 Buffer 时手动操作,不需要时直接用便捷方法:

// 风格一:手动操作 Buffer(需要精细控制时)
private static void io() {
    try (Source source = Okio.source(new File(path))) {
        Buffer buffer = new Buffer();
        // 从 Source → Buffer(读 1024 字节到 Buffer)
        source.read(buffer, 1024);
        // 从 Buffer → 目标(读一行 UTF-8 文本)
        String line = buffer.readUtf8Line();
        System.out.println(line);

    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// 风格二:用 BufferedSource 包装(更简洁,推荐)
private static void io() {
    try (BufferedSource source = Okio.buffer(
            Okio.source(new File(path)))) {
        // 一行代码搞定,Buffer 被封装在内部
        System.out.println(source.readUtf8Line());

    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}
注意:上面第一种方式虽然手动创建了 Buffer,但它是可选的。第二种方式用 Okio.buffer()包装一下,Buffer 就自动管理了。这和 NIO 的"必须用 Buffer"完全不同。
3 Okio 是基于 Stream 的

Okio 底层仍然基于 Java 的 InputStream / OutputStream。你可以随时在 Okio 和传统 IO 之间转换:

// InputStream → Okio Source
Source source = Okio.source(inputStream);

// OutputStream → Okio Sink
Sink sink = Okio.sink(outputStream);

// Okio Source → InputStream
InputStream is = Okio.buffer(source).inputStream();

// Okio Sink → OutputStream
OutputStream os = Okio.buffer(sink).outputStream();

这意味着 Okio 不是一个全新的 I/O 框架,而是对传统 IO 的上层封装和增强

六、Okio 的 Buffer:好用且可选的外部工具

1 Okio Buffer 的三个特征

和 NIO 的 ByteBuffer 相比,Okio 的 Buffer 在设计哲学上有三个本质不同:

特征Okio BufferNIO ByteBuffer
是否强制使用 不强制,可以直接调 Source/Sink 的高级方法 强制,所有 Channel 操作必须经过 Buffer
易用性 很好用:提供了 readUtf8Line()readByteString()等高级 API 不好用:只有原始 get()/ put(),需要手动 flip/clear
操控性 可以被操控:你可以手动创建、操作、传递 Buffer 也可以操控,但 API 繁琐
2 Okio Buffer 的便捷 API

Okio Buffer 提供了非常丰富的高级方法,不需要手动 flip/clear

Buffer buffer = new Buffer();

// 写入数据
buffer.writeUtf8("Hello World");
buffer.writeInt(42);
buffer.writeByte(0x0A);
buffer.write(ByteString.encodeUtf8("更多数据"));

// 读取数据
String line = buffer.readUtf8Line();    // 读一行
int num = buffer.readInt();             // 读一个 int
byte b = buffer.readByte();             // 读一个字节
ByteString bs = buffer.readByteString();// 读一个 ByteString

// 无需 flip()!Buffer 内部自动管理读写位置
// 也无需 clear()!读完的数据自动消费掉
Okio Buffer 的设计哲学:Buffer 是一个双向队列(Segmented ByteQueue),写操作在队尾追加,读操作从队头消费。读过的数据自动移出,空间自动回收——完全不需要 flip/clear/compact

七、Buffer 的本质差异:NIO(内部)vs Okio(外部)

1 最关键的一层理解:内外之别

这是 NIO 和 Okio 最容易被忽略但最重要的区别:

NIO:Buffer 是 I/O 内部的组件

  • Buffer 是 NIO 工作流中不可分割的一部分
  • 没有 Buffer,Channel 无法工作
  • 从 Buffer 中拿出数据 → 读操作(buffer.get())
  • 往 Buffer 中放入数据 → 写操作(buffer.put())
  • 你站在Buffer 内部看世界

Okio:Buffer 是 I/O 外部的对象

  • Buffer 是一个独立的外部工具,可有可无
  • Source/Sink 可以直接工作,不依赖 Buffer
  • 从 Source 读到 Buffer → 写操作(source.read(buffer))
  • 从 Buffer 写到 Sink → 读操作(sink.write(buffer))
  • 你站在Buffer 外部看世界
2 为什么"读""写"语义是反的?

这看起来让人困惑,但其实取决于你的视角——你是站在 Buffer 的立场,还是站在程序的立场:

NIO 视角:Buffer 是内部
Channel
channel.read(buffer)
Buffer
数据流入Buffer
buffer.get()
程序

数据进 Buffer = read(Channel 读给 Buffer)
数据出 Buffer = get(程序从 Buffer 取)

Okio 视角:Buffer 是外部
Source
source.read(buffer)
Buffer
外部临时容器
buffer.readUtf8Line()
程序

从 Source 搬到 Buffer = source.read(buffer)
从 Buffer 取数据 = buffer.readXxx()

一句话总结:
在 NIO 中,Buffer 是你的一部分(内部的),你把数据放入Buffer 就是写,取出就是读。
在 Okio 中,Buffer 是一个外部容器,你把数据倒入Buffer 就是"从 Source 读"(source.read),从 Buffer 倒出就是"读 Buffer"(buffer.read)。

八、Okio 是不是 IO 的上层封装?

1 答案:是的,但不是简单的"包装"

Okio 确实是 Java IO 的上层封装,但它不只是"包了一层皮",而是做了很多实质性的改进:

Okio API(Source / Sink / Buffer / ByteString)
提供便捷、安全、高性能的高级 I/O 接口
Okio 内部封装层(Okio.source / Okio.sink)
将 InputStream/OutputStream 适配为 Source/Sink
java.io(InputStream / OutputStream)
Java 标准库的传统 I/O API
操作系统 I/O 系统调用(read / write)
真正的底层:内核态文件/网络操作
2 Okio 相比传统 IO 的改进点
改进维度传统 IOOkio
缓冲管理 手动 byte[],或套 BufferedInputStream 内置 Segment 分段缓冲池,自动管理
数据类型 只有 byte 和 char ByteString(不可变字节序列)、丰富的读写方法
超时控制 Socket.setSoTimeout(),不易用 Timeout 机制,支持 deadline 和 timeout
资源管理 try-with-resources + Closeable 同样支持 try-with-resources,更安全的关闭语义
内存效率 固定大小 byte[] 缓冲区 Segment 链表,动态扩容,零拷贝
API 设计 装饰器模式,类爆炸 fluent API,链式调用
3 Okio 和 NIO 的关系

Okio 也可以基于 NIO 的 Channel 工作吗?不能直接基于 Channel,但可以间接适配:

// Okio 不能直接包装 Channel,但可以这样桥接
// 方式一:通过 InputStream 桥接
SocketChannel channel = ...;
InputStream is = Channels.newInputStream(channel);
Source source = Okio.source(is);

// 方式二:Okio 直接支持 Socket(底层还是 Stream)
Socket socket = ...;
Source source = Okio.source(socket);
Sink sink = Okio.sink(socket);
结论:Okio 的设计原点就是基于 Stream 模型,它和 NIO 是两条不同的路线。Okio 追求的是 API 的好用和正确性,NIO 追求的是非阻塞和高并发。它们解决的是不同层面的问题。

九、三种 I/O 模型完整对比

1 核心特性对比表
维度传统 IO(Stream)NIO(Channel)Okio(Source/Sink)
核心抽象 InputStream / OutputStream Channel + Buffer Source / Sink
方向性 单向(读 or 写) 双向(同一 Channel 可读写) 单向(读 or 写)
Buffer 角色 可选,手动 byte[] 强制,内部组件 可选,外部工具
非阻塞支持 不支持 支持(仅网络) 不支持(基于 Stream)
多路复用 Selector
易用性 中等(装饰器模式繁琐) 差(flip/clear 易出错) (高级 API,自动管理)
底层依赖 操作系统 I/O 操作系统 I/O + 多路复用 java.io(Stream)
适用场景 简单文件读写 高并发网络服务 需要高性能、好 API 的场景
2 选型决策流程图
需要 I/O 操作
高并发网络?
万级连接
是 → NIO + Selector
(或用 Netty)
追求 API 好用?
开发效率优先
是 → Okio
(特别是 Android 开发)
简单文件/小规模?
是 → 传统 IO
(或 Okio 也行)
3 三者关系图
操作系统 I/O 系统调用
java.io
Stream 模型
↙ ↘
│ │
java.nio
Channel+Buffer
Okio
Source+Sink
Netty
(基于 NIO 的上层框架)

NIO 和 Okio 都建立在传统 IO 之上,但走向了不同的方向

十、核心思想总结

核心要点回顾

Buffer 的内外之别——最重要的区分
对比维度NIO Buffer(内部)Okio Buffer(外部)
定位 I/O 工作流中不可分割的组成部分 独立的外部容器,可自由创建和传递
强制性 强制使用,没有 Buffer 就没法工作 可选,Source/Sink 有很多无需 Buffer 的方法
"读"的含义 从 Buffer 中取出数据(buffer.get()) 从 Buffer 中取出数据(buffer.readXxx())
"写"的含义 往 Buffer 中放入数据(buffer.put()) 往 Buffer 中倒入数据(source.read(buffer))
管理方式 手动 flip() / clear() / compact() 自动管理,读过的数据自动消费

一句话记住三种模型

Okio 是不是 IO 的上层封装?

是的。Okio 底层用 Okio.source(InputStream)/ Okio.sink(OutputStream)桥接到传统 IO,本质上是对 java.io的增强封装。它提供了更好的 Buffer 管理、更丰富的 API、更高的内存效率,但没有改变"基于 Stream"的底层模型。它和 NIO 走的是两条不同路线——Okio 追求好用,NIO 追求高并发