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 读 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.*;

public class FileCopyBasic {
public static void main(String[] args) {
File src = new File("/tmp/source.bin");
File dst = new File("/tmp/target.bin");
// try-with-resources:无论是否异常,in/out 都会被自动关闭(实现 AutoCloseable 即可)
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[8192]; // 8KB 缓冲数组
int len; // 每次实际读到的字节数
while ((len = in.read(buf)) != -1) { // -1 代表流结束;务必用 len 而非 buf.length
out.write(buf, 0, len); // 只写出实际读到的部分,最后一次通常不满
}
out.flush(); // 刷出用户缓冲区(close 也会 flush,显式写更清晰)
} catch (IOException e) {
e.printStackTrace();
}
}
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.*;

public class BufferedCopy {
// 第二个参数是缓冲区大小;不传则默认 8192
private static final int BUF_SIZE = 64 * 1024; // 64KB:与常见磁盘块/页大小成整数倍

public static void main(String[] args) throws IOException {
long start = System.currentTimeMillis();
// 处理流包裹节点流,只声明最外层即可
try (BufferedInputStream bis =
new BufferedInputStream(new FileInputStream("/tmp/big.zip"), BUF_SIZE);
BufferedOutputStream bos =
new BufferedOutputStream(new FileOutputStream("/tmp/big-copy.zip"), BUF_SIZE)) {
byte[] buf = new byte[BUF_SIZE];
int len;
while ((len = bis.read(buf)) != -1) {
bos.write(buf, 0, len);
}
}
System.out.println("耗时: " + (System.currentTimeMillis() - start) + " ms");
}
}

选型建议:小文件(< 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.*;
import java.nio.charset.StandardCharsets;

public class CharsetDemo {
public static void main(String[] args) throws IOException {
String text = "你好,Java IO!";
File f = new File("/tmp/utf8.txt");

// 正确姿势:字节流 + 显式指定 UTF-8 的桥接流,行为与平台无关
try (Writer w = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8))) {
w.write(text);
}

// 等价的读取方式
try (BufferedReader r = new BufferedReader(
new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8))) {
System.out.println(r.readLine());
}
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.nio.*;
import java.nio.charset.*;

public class EncodingDetect {
public static void main(String[] args) {
byte[] data = {(byte) 0xE4, (byte) 0xBD, (byte) 0xA0}; // "你" 的 UTF-8 字节
// REPORT 模式:遇到非法字节序列抛异常,而不是偷偷替换成 U+FFFD
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
CharBuffer cb = decoder.decode(ByteBuffer.wrap(data));
System.out.println("UTF-8 解码成功: " + cb.toString());
} catch (CharacterCodingException e) {
System.out.println("不是合法的 UTF-8,可能是 GBK 或其它编码");
}
}
}

3.4 DataInputStream:读写基本类型

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.io.*;

public class DataStreamDemo {
public static void main(String[] args) throws IOException {
File f = new File("/tmp/data.bin");
// 写入:注意用 Buffered 包裹,否则每个 writeInt 都是一次系统调用
try (DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(f)))) {
dos.writeInt(2026); // 4 字节
dos.writeLong(123456789L); // 8 字节
dos.writeDouble(3.14159); // 8 字节
dos.writeBoolean(true); // 1 字节
dos.writeUTF("张三"); // 2 字节长度前缀 + 改良 UTF-8 字节
}
// 读取:顺序必须与写入完全一致,否则数据错位
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(f)))) {
System.out.println(dis.readInt());
System.out.println(dis.readLong());
System.out.println(dis.readDouble());
System.out.println(dis.readBoolean());
System.out.println(dis.readUTF());
}
}
}

3.5 对象序列化与 Print 系列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.io.*;

class User implements Serializable {
// 显式声明版本号:类结构变化时保证兼容性,第五章详述
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // transient:不参与序列化
private int age;

public User(String name, String password, int age) {
this.name = name; this.password = password; this.age = age;
}
@Override public String toString() {
return "User{name='" + name + "', password='" + password + "', age=" + age + "}";
}
}

public class SerializeDemo {
public static void main(String[] args) throws Exception {
File f = new File("/tmp/user.obj");
try (ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream(f))) {
oos.writeObject(new User("张三", "secret123", 28));
}
try (ObjectInputStream ois =
new ObjectInputStream(new FileInputStream(f))) {
User u = (User) ois.readObject();
System.out.println(u); // password 为 null,因为被 transient 修饰
}
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.io.*;
import java.nio.charset.StandardCharsets;

public class PipedDemo {
public static void main(String[] args) throws Exception {
PipedInputStream pin = new PipedInputStream();
PipedOutputStream pout = new PipedOutputStream();
pout.connect(pin); // 或用 new PipedOutputStream(pin)

Thread producer = new Thread(() -> {
try (Writer w = new OutputStreamWriter(pout, StandardCharsets.UTF_8)) {
for (int i = 1; i <= 5; i++) {
w.write("消息-" + i + "\n");
w.flush(); // 必须 flush,否则数据留在缓冲区
Thread.sleep(200);
}
} catch (Exception e) { e.printStackTrace(); }
}, "producer");

Thread consumer = new Thread(() -> {
try (BufferedReader r = new BufferedReader(
new InputStreamReader(pin, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) { // 写端关闭后 readLine 返回 null
System.out.println("收到: " + line);
}
} catch (Exception e) { e.printStackTrace(); }
}, "consumer");

producer.start();
consumer.start();
}
}

四、文件与 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<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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.stream.Stream;

public class WalkDemo {
public static void main(String[] args) throws IOException {
Path root = Paths.get("/tmp/project");

// 1) Stream 是惰性的,必须 close;不跟随符号链接防止死循环
try (Stream<Path> paths = Files.walk(root, 5, FileVisitOption.FOLLOW_LINKS)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(p -> System.out.println(p.getFileName()));
}

// 2) 统计目录总大小:BasicFileAttributes 一次 stat 拿到所有元数据
try (Stream<Path> paths = Files.walk(root)) {
long total = paths.filter(Files::isRegularFile)
.mapToLong(p -> {
try { return Files.readAttributes(p, BasicFileAttributes.class).size(); }
catch (IOException e) { return 0L; }
}).sum();
System.out.println("总大小: " + total + " bytes");
}

// 3) 与 File 互转:file.toPath() / path.toFile()
File old = new File("/tmp/a.txt");
Path p = old.toPath();
System.out.println(p.toFile().equals(old));
}
}

4.4 WatchService 监听目录变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.nio.file.*;

public class WatchDemo {
public static void main(String[] args) throws Exception {
// 每个 WatchService 背后通常是一个 OS 原生线程(macOS 走 kqueue,Linux 走 inotify)
WatchService ws = FileSystems.getDefault().newWatchService();
Path dir = Paths.get("/tmp/watch");
// register 返回 WatchKey;三种标准事件
dir.register(ws,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);

System.out.println("监听中: " + dir);
while (true) {
WatchKey key = ws.take(); // 阻塞等待事件,也可用 poll() 非阻塞
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) continue; // 事件溢出,需全量重新扫描
Path file = ((WatchEvent<Path>) event).context();
System.out.println(kind.name() + " -> " + file);
}
// 必须 reset,否则 key 失效后不会再收到事件
if (!key.reset()) { break; }
}
}
}

注意: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.*;

class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String user;
private transient String password; // 不直接序列化明文

public Account(String user, String password) {
this.user = user; this.password = password;
}
// JVM 通过反射回调;签名必须严格一致(private void + 参数类型)
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject(); // 先写非 transient 字段
oos.writeUTF(Base64.getEncoder().encodeToString(password.getBytes())); // 自定义加密写
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // 读非 transient 字段
this.password = new String(Base64.getDecoder().decode(ois.readUTF())); // 对称还原
}
@Override public String toString() { return user + "/" + password; }
}

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

5.4 序列化破坏单例与 readResolve

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.*;

class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }

// 反序列化时返回已有实例,新创建的对象被丢弃(可被 GC)
private Object readResolve() { return INSTANCE; }
}

public class SingletonTest {
public static void main(String[] args) throws Exception {
Singleton s = Singleton.getInstance();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
new ObjectOutputStream(bos).writeObject(s);
Object o = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())).readObject();
System.out.println("同一实例? " + (o == s)); // true
}
}

更优雅的方案是枚举单例——《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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.*;

public class SafeDeserialize {
public static void main(String[] args) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(args[0].getBytes());
try (ObjectInputStream ois = new ObjectInputStream(bis)) {
// 白名单:只允许本包下的 User/Order,限制数组长度、引用数、字节数、递归深度
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;com.example.Order;!*;" +
"maxarray=1000;maxrefs=500;maxbytes=8192;maxdepth=5");
ois.setObjectInputFilter(filter);
Object obj = ois.readObject(); // 非法类会在反序列化前被拒绝,抛 InvalidClassException
System.out.println(obj);
}
}
}

现代项目的结论:原生序列化只在”可信、同构、短生命周期”的场景(如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.nio.ByteBuffer;

public class BufferStateMachine {
public static void main(String[] args) {
ByteBuffer buf = ByteBuffer.allocate(10); // capacity=10, position=0, limit=10(写模式)
print("初始化", buf);

buf.put((byte) 'a').put((byte) 'b').put((byte) 'c'); // 写 3 字节,position=3
print("写入3字节", buf);

buf.flip(); // 写模式 -> 读模式:limit=position(3), position=0
print("flip后", buf);

System.out.println("remaining=" + buf.remaining()); // 3
while (buf.hasRemaining()) {
System.out.print((char) buf.get()); // 依次读 a b c,position 递增到 3
}
System.out.println();
print("读完", buf);

buf.rewind(); // position=0,limit 不变,可重复读
print("rewind后", buf);
}
static void print(String tag, ByteBuffer b) {
System.out.printf("%-12s capacity=%d position=%d limit=%d%n",
tag, b.capacity(), b.position(), b.limit());
}
}

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

方法 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ChannelDemo {
public static void main(String[] args) throws IOException {
// transferTo:零拷贝复制文件(第八章详述),底层走 sendfile
try (FileChannel src = FileChannel.open(Paths.get("/tmp/big.zip"), StandardOpenOption.READ);
FileChannel dst = FileChannel.open(Paths.get("/tmp/big-copy.zip"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long pos = 0, size = src.size();
// 单次 transfer 有上限(Linux 约 2GB),必须循环
while (pos < size) {
long transferred = src.transferTo(pos, size - pos, dst);
if (transferred <= 0) break; // 防御性:某些系统上返回 0
pos += transferred;
}
dst.force(true); // 强制把数据与元数据刷到磁盘(fsync),类似数据库的 WAL
}

// 分散读取 Scatter:一次系统调用把数据填进多个 Buffer(适合定长协议头+变长体)
try (FileChannel ch = FileChannel.open(Paths.get("/tmp/data.bin"), StandardOpenOption.READ)) {
ByteBuffer header = ByteBuffer.allocate(8); // 前 8 字节当协议头
ByteBuffer body = ByteBuffer.allocate(1024); // 其余当消息体
ch.read(new ByteBuffer[]{header, body}); // 按顺序自动填充,无需手动切分
header.flip();
System.out.println("header 读到了 " + header.remaining() + " 字节");
}

// 聚集写入 Gather:多个 Buffer 合并成一次系统调用写出,减少 syscall 次数
try (FileChannel ch = FileChannel.open(Paths.get("/tmp/gather.bin"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
ByteBuffer a = ByteBuffer.wrap("HEAD".getBytes());
ByteBuffer b = ByteBuffer.wrap("BODY".getBytes());
ch.write(new ByteBuffer[]{a, b});
}
}
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class NioEchoServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open(); // 底层 epoll 实例
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(9000));
ssc.configureBlocking(false); // 必须非阻塞才能注册到 Selector
ssc.register(selector, SelectionKey.OP_ACCEPT); // 只关心连接事件
System.out.println("Echo 服务器启动,端口 9000");

while (true) {
selector.select(); // 阻塞直到有事件就绪(可传超时)
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须手动移除,否则下次重复处理
try {
if (key.isAcceptable()) {
// 接受新连接,并注册读事件
SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ,
ByteBuffer.allocate(1024)); // 每个连接附一个专属 Buffer
System.out.println("新连接: " + sc.getRemoteAddress());
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
int n = sc.read(buf);
if (n == -1) { // 对端正常关闭
key.cancel();
sc.close();
System.out.println("连接关闭");
continue;
}
if (n > 0) {
buf.flip(); // 切读模式:limit=position, position=0
sc.write(buf); // 回声写回(可能写不完,简化处理)
buf.compact(); // 保留未写完的字节,切回写模式
}
}
} catch (IOException e) {
key.cancel(); // 异常连接必须取消注册并关闭
key.channel().close();
}
}
}
}
}

粘包/拆包的思路: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())的完整开销:

  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)限制,超大文件要分段映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;

public class MappedBigFile {
// 分段映射处理 10GB 超大文件:每段 1GB
private static final long SEGMENT = 1024L * 1024 * 1024;

public static void main(String[] args) throws IOException {
Path path = Paths.get("/tmp/huge.bin");
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
long fileSize = 10L * 1024 * 1024 * 1024; // 10GB
for (long offset = 0; offset < fileSize; offset += SEGMENT) {
long size = Math.min(SEGMENT, fileSize - offset);
// map 的 position/size 是 long,但返回的 Buffer 索引是 int,故单段不能超 2GB
MappedByteBuffer map = ch.map(FileChannel.MapMode.READ_WRITE, offset, size);
// 直接按内存访问,无系统调用;写操作由 OS 异步回写
for (int i = 0; i < 8; i++) {
map.put(i, (byte) 'A');
}
map.force(); // 强制回写(相当于 msync),非必须;不调则由 OS 择机刷盘
// MappedByteBuffer 的生命周期跟随映射,JDK 9+ 可用 Unsafe.invokeCleaner 主动释放
}
}
}
}

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 与零拷贝真正用在一次完整的请求里。