Java 从入门到精通(十):IO 与 NIO——字节流、字符流、序列化与 NIO 三大组件

Java 从入门到精通(十):IO 与 NIO——字节流、字符流、序列化与 NIO 三大组件
神经蛙很多人的 Java IO 学习止步于”复制粘贴一段 try-catch 模板”:能读文件、能写文件,但说不清 new BufferedReader(new InputStreamReader(new FileInputStream(f), UTF_8)) 为什么要套三层;遇到中文乱码只会全局替换成 UTF-8;听说 NIO 快却不知道快在哪;背过 Buffer 的 flip 却总在读写切换时踩坑。根本原因在于:IO 从来不是纯 Java 的语法问题,它是应用程序、JVM、操作系统内核、磁盘与网卡四者协同的结果。不把内核态/用户态、系统调用、数据拷贝这些底层环节打通,上层 API 就永远是玄学。本篇的目标就是把这条链路从硬件一路讲到 API。
一、IO 基础概念:流、阻塞模型与内核/用户态的数据旅程
1.1 什么是”流”
流(Stream)是对有序、连续、有方向的数据序列的抽象。它有三个关键特征:
- 有序:先写入的字节先被读出,不存在”跳到中间读第 100 个字节”这种随机访问语义(RandomAccessFile 与 Channel 是例外)。
- 连续:数据像水流一样从一端流向另一端,读写操作是游标式的,每读一次游标自动前进,不可回退(BufferedReader 的 mark/reset 是受限的例外)。
- 有方向:要么读、要么写,绝不会既是输入又是输出。
流的价值在于屏蔽数据源与数据汇的差异。无论是磁盘文件、网络套接字、内存数组还是键盘输入,对上层代码来说都是同一套 read() / write()。这就是为什么 InputStream 体系既能读文件也能读网络——IO 的设计把”数据是什么”和”数据怎么传”彻底解耦了。
1.2 输入与输出的对称性:参照系永远是内存
新手最容易搞混的一件事:什么叫”输入”?答案很简单——参照系是内存(也就是你的程序)。
- 输入(Input / Read):数据从外部设备流入内存。外部 → 程序,是”读”。
- 输出(Output / Write):数据从内存流出到外部设备。程序 → 外部,是”写”。
所以 FileInputStream 是”从文件里读数据到我的程序”,FileOutputStream 是”把程序里的数据写到文件”。这套术语在任何语言里都一致,永远以程序自身为中心,不要以文件为中心去理解。
1.3 字节流与字符流的根本分歧:编码
Java 的 IO 体系分裂成两棵大树,根源只有一个:编解码(Charset)。
- 字节流:
InputStream/OutputStream,最小单位是 byte(8 bit)。它只搬运字节,不关心这些字节代表什么字符,因此可以无损处理任何二进制数据——图片、音频、视频、zip 包、class 文件。 - 字符流:
Reader/Writer,最小单位是 char(16 bit,UTF-16 码元)。它在读写过程中会自动做一次字节↔字符的解码/编码转换,只适合处理文本。
关键结论:字符流本质是”字节流 + 字符集编解码器”。InputStreamReader 就是那个编解码器(底层是 CharsetDecoder),OutputStreamWriter 同理。这也解释了为什么所有二进制文件都必须用字节流——你用字符流读一张 PNG,解码器会把任意字节序列往字符集里套,遇到非法字节序列直接替换成 ? 或抛异常,文件就永久损坏了。
1.4 一次 read() 背后的内核态与用户态
这是理解 IO 性能问题的地基。操作系统把虚拟地址空间划分为两块:
| 维度 | 用户态(User Mode) | 内核态(Kernel Mode) |
|---|---|---|
| 特权级别 | Ring 3,受限指令 | Ring 0,可执行所有 CPU 指令 |
| 可访问内存 | 仅本进程用户空间 | 全部内存 + 硬件寄存器 |
| 典型居民 | JVM、你的 Java 代码 | 内核、设备驱动、页缓存(Page Cache) |
| 能否直接操作磁盘/网卡 | 不能,必须委托内核 | 能 |
| 崩溃影响 | 只杀掉当前进程 | 整个系统宕机 |
你的 Java 代码运行在用户态,无权直接读写磁盘。要读文件必须发起系统调用(system call,如 Linux 的 read()),CPU 通过 syscall 指令从 Ring 3 陷入 Ring 0,这就是一次上下文切换——需要保存/恢复寄存器、栈指针、页表基址,还要跑一遍内核的安全检查,代价在微秒级。
一次典型的 fileInputStream.read(buf) 完整旅程:
- JVM 发起
read(fd, buf, len)系统调用,用户态 → 内核态(第 1 次上下文切换); - 内核检查页缓存(Page Cache)。未命中则向块设备驱动发请求,DMA(Direct Memory Access)控制器把磁盘数据直接搬到内核缓冲区,全程不占用 CPU;
- 内核把数据从内核缓冲区拷贝到用户缓冲区(你传入的
buf),这是第 1 次 CPU 拷贝; - 系统调用返回,内核态 → 用户态(第 2 次上下文切换)。
再 write() 出去,又是同样的 2 次上下文切换 + 2 次 CPU 拷贝。“读一个文件再写出去”总计 4 次上下文切换、4 次数据拷贝(其中 2 次是 CPU 拷贝、2 次是 DMA 拷贝),这就是第八章零拷贝要优化掉的开销。
1.5 为什么必须缓冲
理解上面那张表之后,缓冲的意义就一目了然了:每一次 read() 都是一次系统调用 + 两次上下文切换。如果读一个 1MB 的文件每次只读 1 字节,那就是 100 万次系统调用,光上下文切换就能把 CPU 跑满,而真正搬运数据的时间占比可能不到 1%。
缓冲的本质是把 N 次小系统调用合并成 1 次大系统调用。BufferedInputStream 内部维护一个默认 8192 字节的数组,第一次 read() 时直接向内核要 8KB 填满,之后的一百次 read() 都只在内存数组里挪游标,系统调用次数降到 1/8192。
缓冲的黄金法则:缓冲区太小则系统调用过多;太大则浪费内存、破坏 CPU 缓存局部性(L1/L2 cache 装不下)。实践上 8KB~64KB 是甜点区,且与磁盘块大小、网卡 MTU 成整数倍关系时最划算。JDK 默认 8192 就是这么来的。
二、IO 家族全景:四大基类、节点流与装饰器模式
2.1 四大抽象基类核心方法
| 抽象类 | 方向 | 最小单位 | 核心方法 | 说明 |
|---|---|---|---|---|
InputStream |
读 | byte | int read() / int read(byte[] b, int off, int len) / long skip(long) / int available() / void close() |
单字节读返回 0~255 的 int,返回 -1 表示流结束 |
OutputStream |
写 | byte | void write(int b) / void write(byte[] b, int off, int len) / void flush() / void close() |
write(int) 只取低 8 位写入 |
Reader |
读 | char | int read() / int read(char[] cbuf, int off, int len) / long skip(long) / boolean ready() / void close() |
单字符读返回 0~65535,同样以 -1 收尾 |
Writer |
写 | char | void write(int c) / void write(char[] cbuf, int off, int len) / void write(String str) / abstract void flush() |
可直接写 String,这是字符流的便利之处 |
注意 int read() 返回 int 而非 byte:既是为了用 -1 表示 EOF,也是为了避免 byte 的符号位干扰(byte 0xFF 是 -1,会与 EOF 冲突)。这是面试常问的细节。
2.2 节点流与处理流
| 分类 | 别名 | 定义 | 是否直接对接数据源 | 典型实现 |
|---|---|---|---|---|
| 节点流 | 低级流 | 直接连接到具体数据源/数据汇,负责真正搬运字节 | 是 | FileInputStream、FileOutputStream、ByteArrayInputStream、PipedInputStream、FileReader |
| 处理流 | 高级流 / 包装流 | 不接触数据源,包裹在已有流之上增强功能 | 否 | BufferedInputStream、DataInputStream、ObjectInputStream、InputStreamReader、PrintWriter |
判断标准极其简单:看构造方法能不能直接接一个文件路径或文件描述符。能接 File/String path 的就是节点流;只能接另一个 InputStream 的就是处理流。
2.3 装饰器模式:那句”套娃”为什么要这样写
BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8));
这行代码是装饰器模式(Decorator Pattern)最经典的教科书案例。拆开看责任划分:
| 层级 | 类型 | 职责 | 为什么必须有它 |
|---|---|---|---|
| 内层 | FileInputStream(节点流) |
从磁盘读取原始字节 | 唯一真正触碰数据源的一层 |
| 中层 | InputStreamReader(处理流) |
字节 → 字符的解码 | 没有它就没有字符集,中文必然乱码 |
| 外层 | BufferedReader(处理流) |
提供 8192 字符缓冲 + readLine() |
没有它就是逐字符系统调用,慢两个数量级 |
装饰器的精髓在于:每一层都实现同一个抽象接口(这里 Reader 体系里是 Reader),并且持有一个被装饰对象的引用,在调用前后附加自己的逻辑。所以你可以任意组合、任意层数——想要缓冲就加 Buffered,想要行号就加 LineNumberReader,想要推回就加 PushbackReader,而调用方代码 br.readLine() 完全不变。这就是”对扩展开放、对修改关闭”。
对比继承方案:如果用继承实现”缓冲+解码+文件”的组合,需要 BufferedFileReader、BufferedStringReader、BufferedSocketReader……类数量爆炸。装饰器把 N 种功能 × M 种数据源的笛卡尔积从 N×M 个类压缩成 N+M 个类。
关闭流只需要关最外层。因为
close()会沿装饰器链向内逐层委托(BufferedReader.close → InputStreamReader.close → FileInputStream.close)。但反过来不绝对成立,所以最稳妥的写法是只用 try-with-resources 声明最外层那一个对象。
2.4 常用实现类分类速查
| 功能诉求 | 字节流 | 字符流 |
|---|---|---|
| 文件读写 | FileInputStream / FileOutputStream |
FileReader / FileWriter |
| 缓冲加速 | BufferedInputStream / BufferedOutputStream |
BufferedReader / BufferedWriter |
| 字节↔字符桥接 | — | InputStreamReader / OutputStreamWriter |
| 基本数据类型 | DataInputStream / DataOutputStream |
— |
| 对象序列化 | ObjectInputStream / ObjectOutputStream |
— |
| 内存数组 | ByteArrayInputStream / ByteArrayOutputStream |
CharArrayReader / CharArrayWriter / StringReader |
| 线程间管道 | PipedInputStream / PipedOutputStream |
PipedReader / PipedWriter |
| 格式化输出 | PrintStream |
PrintWriter |
| 合并多个流 | SequenceInputStream |
— |
| 行号/回退 | — | LineNumberReader / PushbackReader |
三、常用流实战:从文件读写到编码陷阱
3.1 FileInputStream / FileOutputStream 标准模板
这是唯一正确的打开方式——try-with-resources(JDK 7+),它编译后会自动生成 finally { close() } 并且正确压制异常:
1 | import java.io.*; |
最常见的两个坑:一是用 out.write(buf) 而不是 write(buf, 0, len),最后一次读不满时会把上一轮的残留字节重复写出,导致目标文件比源文件大;二是忘记判 -1 导致死循环。
3.2 Buffered 系列与缓冲区大小选择
1 | import java.io.*; |
选型建议:小文件(< 1MB)直接用 Files.copy(JDK 9 后内部走 transferTo,效率最高);大文件用 32KB~1MB 缓冲;不要盲目追求超大缓冲——超过 L2 cache(通常几 MB)后,每次 System.arraycopy 的缓存未命中惩罚会抵消减少系统调用的收益。
3.3 字符流与字符集的致命细节
FileReader / FileWriter 有个隐藏的巨大陷阱:它们不能指定字符集,永远使用 JVM 默认编码(JDK 18 起默认 UTF-8,之前跟随操作系统,Windows 中文环境是 GBK)。这意味着同一份代码在 Windows 上写出的文件,放到 Linux 上读就是乱码。
1 | import java.io.*; |
乱码根因与检测:乱码绝不是”文件内容坏了”,而是写入时的编码与读出时的解码不是同一套字符集。比如”你”的 UTF-8 编码是 E4 BD A0,用 GBK 去解会被当成三个字符,通常显示成”浣犲”之类。检测手段有三种:一是用 file -i filename(Linux)或 chardet 探测;二是用十六进制查看器看字节序列是否符合 UTF-8 的多字节规则(首字节前缀 110xxxxx/1110xxxx/11110xxx);三是 Java 侧直接用 CharsetDecoder 带 CodingErrorAction.REPORT 解码,非法序列会抛 CharacterCodingException 而不是静默替换成 ?:
1 | import java.nio.*; |
3.4 DataInputStream:读写基本类型
DataOutputStream 把 int/long/double/boolean 按固定字节数、大端序(Big Endian)写入,DataInputStream 对称读回。它常用于自定义二进制协议,注意读写顺序必须严格对称。
1 | import java.io.*; |
3.5 对象序列化与 Print 系列
1 | import java.io.*; |
PrintStream 与 PrintWriter 的区别:PrintStream 是字节流(OutputStream 子类),System.out 就是它;PrintWriter 是字符流(Writer subclass),支持指定字符集。JDK 1.4 之前 PrintStream 无法指定编码,所以引入了 PrintWriter。关键差异还有:PrintWriter 的 println/printf 永不抛 IOException,错误被吞进内部 flag,需用 checkError() 主动检查——这在写文件时是隐患。
3.6 PipedInputStream:线程间管道通信
管道流让一个线程的输出直接成为另一个线程的输入,常用于生产者-消费者场景(生产中更推荐 BlockingQueue,但管道在对接既有流式 API 时很有用)。两个管道流必须 connect,且读写要在不同线程,否则极易死锁(管道缓冲区默认 1024 字节,写满后写线程阻塞)。
1 | import java.io.*; |
四、文件与 NIO.2:从 File 到 Path / Files
4.1 File 类的三大局限
java.io.File 从 JDK 1.0 活到今天,但它有三个硬伤:
- 路径分隔符与平台耦合:硬编码
\\在 Linux 上直接失效;File只提供静态常量separator,没有真正的路径解析能力。 - 失败只返回 boolean,不给原因:
delete()返回 false 时你不知道是”文件不存在”、”无权限”还是”文件被占用”,排查全靠猜。 - 元数据能力残缺:不支持符号链接识别、文件 owner、ACL、POSIX 权限;
list()返回String[]且面对超大目录无法流式处理;没有原子移动、没有文件系统级遍历 API。
4.2 Path / Paths / Files
JDK 7 的 NIO.2 引入了三件套:Path 是路径对象(不是文件对象,路径可以不存在),Paths 是工厂,Files 是静态工具类,所有操作都在这里,且失败抛具体异常(NoSuchFileException、AccessDeniedException、FileAlreadyExistsException)。
| 方法 | 作用 | 备注 |
|---|---|---|
Files.readAllBytes(Path) |
一次性读全部字节 | 仅适合小文件,大文件会 OOM |
Files.readAllLines(Path, Charset) |
按行读成 List<String> |
同样只适合小文件 |
Files.lines(Path, Charset) |
返回 Stream<String>,惰性 |
大文件首选;必须用 try-with-resources 关闭 |
Files.write(Path, byte[], options...) |
写字节,支持 CREATE/APPEND/TRUNCATE | 默认覆盖 |
Files.copy(src, dst, REPLACE_EXISTING) |
复制文件 | 内部优化,大文件也高效 |
Files.move(src, dst, ATOMIC_MOVE) |
移动/重命名 | ATOMIC_MOVE 同一文件系统内原子 |
Files.delete / deleteIfExists |
删除 | 目录非空会抛 DirectoryNotEmptyException |
Files.walk(Path, maxDepth, options) |
深度优先遍历目录树,返回 Stream<Path> |
默认不跟随符号链接 |
Files.find(Path, maxDepth, matcher, options) |
walk + 谓词过滤 | 比先 walk 再 filter 高效 |
Files.exists / notExists / isDirectory / size |
元数据查询 | 注意 exists 与后续操作间的 TOCTOU 竞态 |
Files.createTempFile(prefix, suffix, attrs) |
创建临时文件 | 配合 deleteOnExit 或手动清理 |
4.3 walk + Stream 遍历目录树
1 | import java.io.IOException; |
4.4 WatchService 监听目录变化
1 | import java.nio.file.*; |
注意:WatchService 只监听直接子目录,不会递归;递归需要为每个子目录单独 register。另外 Linux inotify 有事件队列上限,高频写入会触发 OVERFLOW,此时必须全量重新扫描目录以保证一致性。
五、序列化机制:从 serialVersionUID 到反序列化安全
5.1 用途与基本原理
序列化(Serialization)把内存中的对象图转换成字节序列,用于持久化或网络传输;反序列化是其逆过程。Java 原生序列化写入的不只是字段值,还包括类元数据、字段类型签名、对象引用关系图,因此能还原完整对象图(含循环引用)。
要点:被序列化的类必须实现 Serializable(标记接口,无方法);其所有非 transient 字段也必须可序列化,否则抛 NotSerializableException;static 字段属于类不属于对象,不参与序列化。
5.2 serialVersionUID 与 InvalidClassException
serialVersionUID 是类的”版本指纹”。反序列化时 JVM 会比对字节流里的 UID 与当前类的 UID,不一致直接抛 InvalidClassException。如果你不显式声明,JVM 会按类名、字段名、方法签名等用 SHA 算法自动生成一个——这意味着你加一个字段、改一个方法名,UID 就变了,历史数据全部反序列化失败。
因此强烈建议所有 Serializable 类显式声明 private static final long serialVersionUID = 1L;,然后用 serialver 工具或 IDE 插件生成实际值。生成策略:serialver com.xxx.User 命令行工具,或 IDEA 中开启 “Serializable class without serialVersionUID” 检查后 Alt+Enter 自动生成。
5.3 transient 与自定义读写
transient 标记的字段不参与默认序列化,反序列化后是默认值(对象为 null,int 为 0)。典型用途:密码、Token、缓存派生字段、不可序列化的资源句柄(如 Socket)。
但 transient 不等于”彻底丢失”——你可以在类里定义 private void writeObject(ObjectOutputStream) 与 private void readObject(ObjectInputStream),JVM 序列化机制会通过反射自动回调它们(注意是 private,靠反射调用,不是重写):
1 | import java.io.*; |
Externalizable 是更彻底的方案:它继承 Serializable,要求实现 writeExternal/readExternal,序列化完全由你控制,不再自动写字段,并且必须提供 public 无参构造器(反序列化时先 new 再填充,而 Serializable 是绕过构造器直接分配内存)。性能略好,但侵入性强,实际很少用。
5.4 序列化破坏单例与 readResolve
序列化会绕过构造器创建对象,因此单例的私有构造器约束形同虚设:反序列化 Singleton.INSTANCE 得到的会是一个全新对象,== 比较为 false。解决方案是实现 readResolve() 方法——JVM 在反序列化完成后会调用它,用返回值替换掉刚创建的对象:
1 | import java.io.*; |
更优雅的方案是枚举单例——《Effective Java》推荐:JVM 保证枚举的序列化只写 name(),反序列化通过 Enum.valueOf 拿到同一实例,天然免疫反射与序列化攻击。
5.5 反序列化漏洞与 ObjectInputFilter
Java 原生序列化是极度危险的入口:字节流里携带类名,反序列化时会触发该类的 readObject,而某些类(如 Commons-Collections 里的 InvokerTransformer)的 readObject 链可被构造成任意代码执行——这就是著名的 Apache Commons Collections 反序列化 RCE(影响 WebLogic、JBoss、Jenkins 等)。
防护手段:不要反序列化不可信数据;升级依赖;从 JDK 9 起使用 ObjectInputFilter 做白名单:
1 | import java.io.*; |
现代项目的结论:原生序列化只在”可信、同构、短生命周期”的场景(如 JVM 内部缓存、RMI)使用。对外接口请一律用 JSON(Jackson/Fastjson2)或 Protobuf:跨语言、可读、体积小(Protobuf 通常是原生序列化的 1/5~1/10)、无代码执行风险、schema 演进友好。这也是 Dubbo、gRPC、Kafka 早已弃用原生序列化的原因。
六、NIO 三大组件:Buffer、Channel、Selector
6.1 Buffer 的状态机:position / limit / capacity
Buffer 是 NIO 的数据容器,核心是三个游标和一个标记:
- capacity:容量,创建时固定,永不改变。
- position:下一个待读/待写的索引。写模式下表示写到哪了,读模式下表示读到哪了。
- limit:可读/可写的上界(不含)。写模式下 limit == capacity;切到读模式后 limit == 之前写入的数据量。
- mark:一个备忘位置,
mark()记录、reset()回退。
1 | import java.nio.ByteBuffer; |
四个核心操作的语义差异是面试必考:
| 方法 | position | limit | mark | 语义与典型场景 |
|---|---|---|---|---|
flip() |
→ 0 | → 原 position | 丢弃 | 写模式切读模式。写完数据准备读之前必调 |
rewind() |
→ 0 | 不变 | 丢弃 | 重读。把已有数据从头再读一遍(如重发报文) |
clear() |
→ 0 | → capacity | 丢弃 | 切回写模式。注意不清空数据,只是让游标可覆盖,旧数据成”垃圾” |
compact() |
→ 剩余数据末尾 | → capacity | 丢弃 | 半包处理。把未读完的字节搬到开头,接着从后面写,专为拆包设计 |
经典错误:写完直接 get()(读到的是未写入区域);忘了 flip()(limit 还是 capacity,读到一堆 0);读完直接 put()(position 在末尾,抛 BufferOverflowException)。记住口诀:写→读 flip,读→写 clear(或 compact 保留残留)。
HeapByteBuffer vs DirectByteBuffer:
| 维度 | HeapByteBuffer(allocate) |
DirectByteBuffer(allocateDirect) |
|---|---|---|
| 内存位置 | JVM 堆内,受 GC 管理 | 堆外本机内存(malloc) |
| 分配/释放成本 | 低 | 高(系统调用 malloc + Cleaner 虚引用回收) |
| IO 读写性能 | 较低,多一次中间堆内存拷贝 | 高,内核可 DMA 直接访问 |
是否受 -Xmx 限制 |
是 | 否,受 -XX:MaxDirectMemorySize 限制(默认与 Xmx 相同) |
| 适用场景 | 短生命周期、业务编解码 | 长期复用、高频网络/文件 IO(Netty 的池化直接内存) |
6.2 Channel:双向的管道
Channel 与 Stream 的根本区别有三:Channel 是双向的(同一个对象既能 read 又能 write,而流必须成对);Channel 读写必须经过 Buffer;Channel 支持非阻塞模式(配合 Selector)。
| Channel | 用途 | 是否支持非阻塞 | 关键能力 |
|---|---|---|---|
FileChannel |
文件读写 | 否(永远阻塞,文件 IO 无需非阻塞) | transferTo/transferFrom、map() 内存映射、force() 强制落盘、lock() 文件锁、position() 随机访问 |
SocketChannel |
TCP 客户端/已连接套接字 | 是 | connect/read/write,配合 Selector 用 OP_CONNECT/OP_READ/OP_WRITE |
ServerSocketChannel |
TCP 服务端监听 | 是 | accept() 返回 SocketChannel,注册 OP_ACCEPT |
DatagramChannel |
UDP 收发 | 是 | send/receive,面向无连接数据报 |
1 | import java.io.IOException; |
6.3 Selector 与 epoll 多路复用
Selector 是 NIO 的灵魂:一个线程监听成千上万个 Channel 的就绪事件。它的底层在 Linux 上是 epoll。三种多路复用机制的演进:
| 机制 | 数据结构 | 时间复杂度 | 连接数上限 | 拷贝开销 | 核心缺陷 |
|---|---|---|---|---|---|
select |
fd_set 位图 | O(n) 轮询 | 1024(FD_SETSIZE) | 每次调用都要把 fd 集合从用户态拷到内核态 | 数量限制 + 全量轮询 |
poll |
链表数组 | O(n) 轮询 | 无硬限制 | 同样全量拷贝 | 连接越多越慢,仍要遍历全部 |
epoll |
红黑树 + 就绪链表 | O(1)(回调驱动) | 仅受系统 fd 上限(数十万) | epoll_ctl 只注册一次,之后零拷贝 |
无;JDK 在 Linux 2.6+ 默认走 epoll |
epoll 的三个系统调用:epoll_create 创建实例(红黑树 + 就绪链表);epoll_ctl 增删改监听的 fd;epoll_wait 只返回已就绪的 fd 链表——不需要遍历全部连接,这就是 C10K 问题的解法。JDK 通过 EPollSelectorProvider 封装,Selector.open() 在 Linux 上拿到的就是 EPollSelectorImpl(可用 -Djdk.nio.selector 观察,Windows 上是 WEPoll/IOCP 变体)。
事件类型(定义在 SelectionKey 上,位掩码可组合):
| 常量 | 值 | 含义 | 注册方 |
|---|---|---|---|
OP_ACCEPT |
16 | 有新的 TCP 连接进来,可以 accept | ServerSocketChannel |
OP_CONNECT |
8 | 连接建立完成 | SocketChannel |
OP_READ |
1 | 有数据可读(或对端关闭,read 返回 -1) | SocketChannel |
OP_WRITE |
4 | 内核发送缓冲区有空闲,可以写 | SocketChannel |
SelectionKey 的关键区分:interestOps 是你”关心”的事件集合(注册时设定),readyOps 是这次 select 真正”就绪”的事件集合。处理完必须把已处理的事件从 interestOps 移除或重新赋值,尤其 OP_WRITE——写缓冲几乎总是空闲,若不及时取消关注会 100% 触发,导致 CPU 空转 100%(经典 bug)。
下面是单线程 Echo 服务器完整可运行代码:
1 | import java.io.IOException; |
粘包/拆包的思路:TCP 是字节流协议,没有消息边界,上面这段代码在高速写入时必然出现”一次读到半条消息”或”一次读到两条消息”。标准解法有四种:一是固定长度(每条消息补齐到定长,浪费带宽);二是分隔符(如 \n,需转义处理);三是长度前缀(最常用:4 字节 int 表示 body 长度,先读满 4 字节再按长度读 body,配合 compact() 处理半包);四是应用层协议(HTTP、Protobuf 自带 framing)。Netty 的 LengthFieldBasedFrameDecoder 就是第三种的工业实现。
七、BIO / NIO / AIO 对比与选型
7.1 三种模型
| 维度 | BIO(同步阻塞) | NIO(同步非阻塞) | AIO(异步非阻塞) |
|---|---|---|---|
| 阻塞性 | read/accept/write 全部阻塞 |
IO 调用立即返回,就绪判断交给 Selector | 调用立即返回,完成后回调通知 |
| 线程模型 | 一连接一线程 | 一线程(或少量线程)管多连接 | 回调驱动,无需等待线程 |
| 谁去做 IO | 应用线程自己读写,同步 | 应用线程自己读写,同步 | 内核做完再通知,异步 |
| 连接数上限 | 受线程数限制,数千即崩溃 | 数万~数十万 | 理论很高 |
| 编程复杂度 | 低 | 高(状态机、半包、事件循环) | 中(回调地狱) |
| 典型 API | Socket / ServerSocket |
SocketChannel + Selector |
AsynchronousSocketChannel + CompletionHandler |
| JDK 版本 | 1.0 | 1.4 | 1.7(NIO.2) |
“同步/异步”与”阻塞/非阻塞”是两个正交维度,别混为一谈。同步/异步关注的是”谁去执行 IO、结果怎么拿到“:同步是应用自己读、等结果;异步是内核做完主动通知。阻塞/非阻塞关注的是”调用后线程能不能干别的“。NIO 之所以叫”同步非阻塞”:线程不用卡住(非阻塞),但真正的数据拷贝仍是线程自己调 read() 完成的(同步)。
7.2 Java AIO 的现状
JDK 7 引入 AsynchronousSocketChannel / AsynchronousFileChannel,Windows 上基于 IOCP(真异步,性能极好),但 Linux 上因为缺乏成熟的原生 AIO 支持,JDK 用线程池模拟:AsynchronousChannelGroup 内部维护一个线程池,本质上还是”多线程阻塞 IO + 回调封装”,性能并不比 NIO 好,反而增加了线程切换与回调开销。
这就是为什么 Netty 明确选择 NIO 而非 AIO——Netty 创始人 Trustin Lee 在邮件列表里给出过解释:Linux 上 AIO 没有性能优势,且连接数大时回调模型难以做精细的流量整形与背压控制。Netty 的 NIO 模型 + Reactor 线程模型 + 零拷贝 + 内存池化,才是工业级的事实标准。
7.3 选型建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| 传统 Web 应用(Spring Boot + Tomcat) | Tomcat 默认 NIO 连接器 | 框架已封装,业务代码仍是阻塞式,开发效率最优 |
| 内部工具、连接数 < 1000 | BIO + 线程池 | 简单直接,可维护性第一 |
| 高并发网关、IM、推送、RPC 框架 | Netty(NIO) | 单机数十万连接,丰富的编解码与流控 |
| 文件传输、静态资源服务 | NIO + transferTo/sendfile |
零拷贝收益最大 |
| 大文件随机读写 | MappedByteBuffer |
省去系统调用,见第八章 |
| 简单异步文件写 | AsynchronousFileChannel |
日志/落盘场景偶有使用 |
八、零拷贝、内存映射与性能优化
8.1 传统 IO 的代价
回顾第一章:一次”读文件再发给网络”(read() + write())的完整开销:
read()系统调用,用户态→内核态(切换 1);- DMA 把磁盘数据拷到内核读缓冲区(拷贝 1,CPU 不参与);
- CPU 把数据从内核缓冲区拷到用户缓冲区(拷贝 2);
read()返回,内核态→用户态(切换 2);write()系统调用,用户态→内核态(切换 3);- CPU 把用户缓冲区拷到内核 socket 缓冲区(拷贝 3);
- DMA 把 socket 缓冲区拷到网卡(拷贝 4,CPU 不参与);
write()返回,内核态→用户态(切换 4)。
4 次上下文切换、4 次数据拷贝。其中第 3、6 步是 CPU 在内存之间搬运同样的字节——纯粹浪费,因为应用根本没修改数据。
8.2 mmap、sendfile 与 transferTo
| 方案 | 原理 | 拷贝次数 | 上下文切换 | 适用场景 |
|---|---|---|---|---|
| 传统 read+write | 内核↔用户↔内核 | 4(2 次 CPU) | 4 | 需在用户态修改数据 |
mmap + write |
用户空间共享映射内核页缓存,省掉一次 CPU 拷贝 | 3(1 次 CPU) | 4 | 需要随机访问/修改文件内容 |
sendfile / transferTo |
数据完全不进用户态,内核内部直接转发 | 2(0 次 CPU,全是 DMA) | 2 | 文件→网络,数据不加工(静态服务器、MQ 落盘转发) |
sendfile + SG-DMA(Linux 2.4+) |
socket 缓冲区只存描述符,DMA 直接从页缓存 gather 到网卡 | 2(0 次 CPU) | 2 | 同上,最优 |
FileChannel.transferTo() 在 Linux 上就是 sendfile() 系统调用的封装(macOS 上是 sendfile 的变体,Windows 上是 TransmitFile)。关键限制:目标必须是可写的 Channel(通常是 SocketChannel),且受单次传输大小限制(Linux 2GB),需要循环。
MappedByteBuffer 是 mmap 的封装:fileChannel.map(READ_WRITE, 0, size) 把文件区域映射到进程的虚拟地址空间,之后读写这块内存就是读写文件,完全没有 read/write 系统调用,由 OS 缺页中断自动换页。注意映射大小受 Integer.MAX_VALUE(2GB)限制,超大文件要分段映射。
1 | import java.io.IOException; |
8.3 直接内存的生命周期
DirectByteBuffer 分配在堆外,不受 GC 直接管理。JDK 的实现是:DirectByteBuffer 对象本身在堆内(很小),持有一个内存地址 address;当这个堆内对象被 GC 回收时,其关联的 Cleaner(PhantomReference 虚引用子类) 会被加入 ReferenceQueue,由 ReferenceHandler 线程调用 Unsafe.freeMemory() 释放堆外内存。
风险在于:堆内对象很小,GC 不敏感,可能堆外内存已经耗尽而 Full GC 迟迟不触发,导致 OutOfMemoryError: Direct buffer memory。应对:显式控制(Netty 用引用计数 + 池化,主动 release());设置 -XX:MaxDirectMemorySize=1g 限制上限;或直接 System.gc()(不推荐,会触发 Full GC)。JDK 9+ 可用 Unsafe.invokeCleaner(buffer) 主动释放,但需 --add-opens。
大文件分块策略:不要用 Files.readAllBytes 读大文件(一次性全进堆,必 OOM);< 100MB 用 Files.copy;100MB~2GB 用 Buffered 分块(64KB~1MB);> 2GB 或需随机访问用 MappedByteBuffer 分段;纯转发用 transferTo。
8.4 高频面试题八条
- 字节流和字符流的区别?什么时候用哪个? 字节流单位是 byte、不做编解码,处理一切二进制;字符流单位是 char、内部做字符集转换,只处理文本。判断标准:能用记事本打开看懂的用字符流,否则一律字节流。
flush()和close()的区别?flush()只把用户缓冲区的数据推给操作系统(不保证落盘);close()会先flush()再释放文件描述符等系统资源。只 flush 不 close 会导致文件句柄泄漏,最终Too many open files。BIO、NIO、AIO的区别? 见第七章表格。一句话:BIO 阻塞、NIO 同步非阻塞多路复用、AIO 异步回调。- NIO 一定比 BIO 快吗? 不一定。连接数少(几百)、每个连接都很繁忙的场景,BIO 一线程一连接的模型没有 Selector 轮询与状态机的开销,反而更快。NIO 的优势在海量空闲连接(如 IM 长连接)——用少量线程 hold 住大量连接。
flip()和clear()的区别?flip()是写转读(limit=position, position=0);clear()是转回写(position=0, limit=capacity),不清空数据。半包场景要用compact()保留未处理字节。select、poll、epoll的区别? 见第六章表格。核心:select/poll 每次全量拷贝 + O(n) 遍历且有/无 fd 上限;epoll 只注册一次、回调驱动、只返回就绪 fd,复杂度 O(1)。- 什么是零拷贝?有几种实现? 零拷贝指避免 CPU 在内存之间搬运数据(严格说是减少 CPU 拷贝次数)。Java 侧三种:
MappedByteBuffer(mmap)、FileChannel.transferTo/transferFrom(sendfile)、Netty 的CompositeByteBuf(逻辑聚合,避免合并拷贝)。 - 序列化时
serialVersionUID有什么用?不加会怎样? 它是类版本一致性的校验值。不加则由 JVM 根据类结构自动生成,一旦类结构变化(增字段、改方法)UID 就变,历史数据反序列化抛InvalidClassException。 transient修饰的字段能被序列化吗? 默认不能,反序列化后是类型默认值。但可以在writeObject/readObject中手动写入还原(如加密后存储)。- 为什么
FileReader会导致乱码? 因为它不能指定字符集,使用平台默认编码。跨平台的正确做法是new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)。
最后给一条贯穿始终的工程建议:IO 代码的第一原则是”资源一定被关闭”,请用 try-with-resources 而非手写 finally;第二原则是永远显式指定字符集,不要依赖平台默认值;第三原则是大文件绝不一次性读进内存。把这三条刻进肌肉记忆,能避开 90% 的 IO 线上事故。下一篇我们将进入网络编程与 TCP/HTTP 实战,把本篇的 NIO 与零拷贝真正用在一次完整的请求里。

















