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) 完整旅程:

  1. JVM 发起 read(fd, buf, len) 系统调用,用户态 → 内核态(第 1 次上下文切换);
  2. 内核检查页缓存(Page Cache)。未命中则向块设备驱动发请求,DMA(Direct Memory Access)控制器把磁盘数据直接搬到内核缓冲区,全程不占用 CPU;
  3. 内核把数据从内核缓冲区拷贝到用户缓冲区(你传入的 buf),这是第 1 次 CPU 拷贝;
  4. 系统调用返回,内核态 → 用户态(第 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读byteint read() / int read(byte[] b, int off, int len) / long skip(long) / int available() / void close()单字节读返回 0~255 的 int,返回 -1 表示流结束
OutputStream写bytevoid write(int b) / void write(byte[] b, int off, int len) / void flush() / void close()write(int) 只取低 8 位写入
Reader读charint read() / int read(char[] cbuf, int off, int len) / long skip(long) / boolean ready() / void close()单字符读返回 0~65535,同样以 -1 收尾
Writer写charvoid 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 / FileOutputStreamFileReader / FileWriter
缓冲加速BufferedInputStream / BufferedOutputStreamBufferedReader / BufferedWriter
字节↔字符桥接—InputStreamReader / OutputStreamWriter
基本数据类型DataInputStream / DataOutputStream—
对象序列化ObjectInputStream / ObjectOutputStream—
内存数组ByteArrayInputStream / ByteArrayOutputStreamCharArrayReader / CharArrayWriter / StringReader
线程间管道PipedInputStream / PipedOutputStreamPipedReader / PipedWriter
格式化输出PrintStreamPrintWriter
合并多个流SequenceInputStream—
行号/回退—LineNumberReader / PushbackReader

三、常用流实战:从文件读写到编码陷阱

3.1 FileInputStream / FileOutputStream 标准模板

这是唯一正确的打开方式——try-with-resources(JDK 7+),它编译后会自动生成 finally { close() } 并且正确压制异常:

最常见的两个坑:一是用 out.write(buf) 而不是 write(buf, 0, len),最后一次读不满时会把上一轮的残留字节重复写出,导致目标文件比源文件大;二是忘记判 -1 导致死循环。

3.2 Buffered 系列与缓冲区大小选择

选型建议:小文件(< 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 上读就是乱码。

乱码根因与检测:乱码绝不是”文件内容坏了”,而是写入时的编码与读出时的解码不是同一套字符集。比如”你”的 UTF-8 编码是 E4 BD A0,用 GBK 去解会被当成三个字符,通常显示成”浣犲”之类。检测手段有三种:一是用 file -i filename(Linux)或 chardet 探测;二是用十六进制查看器看字节序列是否符合 UTF-8 的多字节规则(首字节前缀 110xxxxx/1110xxxx/11110xxx);三是 Java 侧直接用 CharsetDecoder 带 CodingErrorAction.REPORT 解码,非法序列会抛 CharacterCodingException 而不是静默替换成 ?:

3.4 DataInputStream:读写基本类型

DataOutputStream 把 int/long/double/boolean 按固定字节数、大端序(Big Endian)写入,DataInputStream 对称读回。它常用于自定义二进制协议,注意读写顺序必须严格对称。

3.5 对象序列化与 Print 系列

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 字节,写满后写线程阻塞)。

四、文件与 NIO.2:从 File 到 Path / Files

4.1 File 类的三大局限

java.io.File 从 JDK 1.0 活到今天,但它有三个硬伤:

  1. 路径分隔符与平台耦合:硬编码 \\ 在 Linux 上直接失效;File 只提供静态常量 separator,没有真正的路径解析能力。
  2. 失败只返回 boolean,不给原因:delete() 返回 false 时你不知道是”文件不存在”、”无权限”还是”文件被占用”,排查全靠猜。
  3. 元数据能力残缺:不支持符号链接识别、文件 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同样只适合小文件
Files.lines(Path, Charset)返回 Stream,惰性大文件首选;必须用 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默认不跟随符号链接
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 遍历目录树

4.4 WatchService 监听目录变化

注意: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,靠反射调用,不是重写):

Externalizable 是更彻底的方案:它继承 Serializable,要求实现 writeExternal/readExternal,序列化完全由你控制,不再自动写字段,并且必须提供 public 无参构造器(反序列化时先 new 再填充,而 Serializable 是绕过构造器直接分配内存)。性能略好,但侵入性强,实际很少用。

5.4 序列化破坏单例与 readResolve

序列化会绕过构造器创建对象,因此单例的私有构造器约束形同虚设:反序列化 Singleton.INSTANCE 得到的会是一个全新对象,== 比较为 false。解决方案是实现 readResolve() 方法——JVM 在反序列化完成后会调用它,用返回值替换掉刚创建的对象:

更优雅的方案是枚举单例——《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 做白名单:

现代项目的结论:原生序列化只在”可信、同构、短生命周期”的场景(如 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() 回退。

四个核心操作的语义差异是面试必考:

方法positionlimitmark语义与典型场景
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() 随机访问
SocketChannelTCP 客户端/已连接套接字是connect/read/write,配合 Selector 用 OP_CONNECT/OP_READ/OP_WRITE
ServerSocketChannelTCP 服务端监听是accept() 返回 SocketChannel,注册 OP_ACCEPT
DatagramChannelUDP 收发是send/receive,面向无连接数据报

6.3 Selector 与 epoll 多路复用

Selector 是 NIO 的灵魂:一个线程监听成千上万个 Channel 的就绪事件。它的底层在 Linux 上是 epoll。三种多路复用机制的演进:

机制数据结构时间复杂度连接数上限拷贝开销核心缺陷
selectfd_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_ACCEPT16有新的 TCP 连接进来,可以 acceptServerSocketChannel
OP_CONNECT8连接建立完成SocketChannel
OP_READ1有数据可读(或对端关闭,read 返回 -1)SocketChannel
OP_WRITE4内核发送缓冲区有空闲,可以写SocketChannel

SelectionKey 的关键区分:interestOps 是你”关心”的事件集合(注册时设定),readyOps 是这次 select 真正”就绪”的事件集合。处理完必须把已处理的事件从 interestOps 移除或重新赋值,尤其 OP_WRITE——写缓冲几乎总是空闲,若不及时取消关注会 100% 触发,导致 CPU 空转 100%(经典 bug)。

下面是单线程 Echo 服务器完整可运行代码:

粘包/拆包的思路: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应用线程自己读写,同步应用线程自己读写,同步内核做完再通知,异步
连接数上限受线程数限制,数千即崩溃数万~数十万理论很高
编程复杂度低高(状态机、半包、事件循环)中(回调地狱)
典型 APISocket / ServerSocketSocketChannel + SelectorAsynchronousSocketChannel + CompletionHandler
JDK 版本1.01.41.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 连接器框架已封装,业务代码仍是阻塞式,开发效率最优
内部工具、连接数 < 1000BIO + 线程池简单直接,可维护性第一
高并发网关、IM、推送、RPC 框架Netty(NIO)单机数十万连接,丰富的编解码与流控
文件传输、静态资源服务NIO + transferTo/sendfile零拷贝收益最大
大文件随机读写MappedByteBuffer省去系统调用,见第八章
简单异步文件写AsynchronousFileChannel日志/落盘场景偶有使用

八、零拷贝、内存映射与性能优化

8.1 传统 IO 的代价

回顾第一章:一次”读文件再发给网络”(read() + write())的完整开销:

  1. read() 系统调用,用户态→内核态(切换 1);
  2. DMA 把磁盘数据拷到内核读缓冲区(拷贝 1,CPU 不参与);
  3. CPU 把数据从内核缓冲区拷到用户缓冲区(拷贝 2);
  4. read() 返回,内核态→用户态(切换 2);
  5. write() 系统调用,用户态→内核态(切换 3);
  6. CPU 把用户缓冲区拷到内核 socket 缓冲区(拷贝 3);
  7. DMA 把 socket 缓冲区拷到网卡(拷贝 4,CPU 不参与);
  8. 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)限制,超大文件要分段映射。

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 高频面试题八条

  1. 字节流和字符流的区别?什么时候用哪个? 字节流单位是 byte、不做编解码,处理一切二进制;字符流单位是 char、内部做字符集转换,只处理文本。判断标准:能用记事本打开看懂的用字符流,否则一律字节流。
  2. flush() 和 close() 的区别? flush() 只把用户缓冲区的数据推给操作系统(不保证落盘);close() 会先 flush() 再释放文件描述符等系统资源。只 flush 不 close 会导致文件句柄泄漏,最终 Too many open files。
  3. BIO、NIO、AIO 的区别? 见第七章表格。一句话:BIO 阻塞、NIO 同步非阻塞多路复用、AIO 异步回调。
  4. NIO 一定比 BIO 快吗? 不一定。连接数少(几百)、每个连接都很繁忙的场景,BIO 一线程一连接的模型没有 Selector 轮询与状态机的开销,反而更快。NIO 的优势在海量空闲连接(如 IM 长连接)——用少量线程 hold 住大量连接。
  5. flip() 和 clear() 的区别? flip() 是写转读(limit=position, position=0);clear() 是转回写(position=0, limit=capacity),不清空数据。半包场景要用 compact() 保留未处理字节。
  6. select、poll、epoll 的区别? 见第六章表格。核心:select/poll 每次全量拷贝 + O(n) 遍历且有/无 fd 上限;epoll 只注册一次、回调驱动、只返回就绪 fd,复杂度 O(1)。
  7. 什么是零拷贝?有几种实现? 零拷贝指避免 CPU 在内存之间搬运数据(严格说是减少 CPU 拷贝次数)。Java 侧三种:MappedByteBuffer(mmap)、FileChannel.transferTo/transferFrom(sendfile)、Netty 的 CompositeByteBuf(逻辑聚合,避免合并拷贝)。
  8. 序列化时 serialVersionUID 有什么用?不加会怎样? 它是类版本一致性的校验值。不加则由 JVM 根据类结构自动生成,一旦类结构变化(增字段、改方法)UID 就变,历史数据反序列化抛 InvalidClassException。
  9. transient 修饰的字段能被序列化吗? 默认不能,反序列化后是类型默认值。但可以在 writeObject/readObject 中手动写入还原(如加密后存储)。
  10. 为什么 FileReader 会导致乱码? 因为它不能指定字符集,使用平台默认编码。跨平台的正确做法是 new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)。

最后给一条贯穿始终的工程建议:IO 代码的第一原则是”资源一定被关闭”,请用 try-with-resources 而非手写 finally;第二原则是永远显式指定字符集,不要依赖平台默认值;第三原则是大文件绝不一次性读进内存。把这三条刻进肌肉记忆,能避开 90% 的 IO 线上事故。下一篇我们将进入网络编程与 TCP/HTTP 实战,把本篇的 NIO 与零拷贝真正用在一次完整的请求里。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论