Java IO知识面总复盘
Java IO 的知识点很多,容易学成一堆类名。真正用于开发和面试时,需要把“数据来源、数据单位、访问方式、资源生命周期、性能瓶颈、异常边界”串成一张图。
这一节用于复盘整个 Java IO 章节:新手可以用它建立全局地图,高级开发者可以用它做选型清单,资深开发者可以用它审查线上 IO 代码是否可靠。
# 1. IO 选型总图
先问六个问题:
数据是什么
├─ 二进制:InputStream / OutputStream
└─ 文本:Reader / Writer + Charset
数据在哪里
├─ 文件:FileInputStream / Files / FileChannel
├─ 内存:ByteArrayInputStream / ByteBuffer
├─ 网络:Socket / SocketChannel
└─ 进程间:PipedInputStream / PipedOutputStream
数据多大
├─ 小文件:Files.readString/readAllBytes
├─ 中等文件:缓冲流分块读写
└─ 大文件:流式处理、FileChannel、MappedByteBuffer
访问方式
├─ 顺序读写:普通流
├─ 随机访问:RandomAccessFile / FileChannel position
└─ 多路复用:Selector
是否要高性能
├─ 减少系统调用:BufferedInputStream
├─ 减少拷贝:transferTo / transferFrom
└─ 减少线程阻塞:NIO / Reactor
失败后怎么办
├─ 资源关闭
├─ 临时文件清理
├─ 重试与幂等
└─ 半写入恢复
IO 选型不是记 API,而是先识别场景,再选择最小够用且边界清晰的工具。
# 2. 字节流与字符流
字节流直接处理原始字节,字符流负责把字节按字符集解码为字符。
| 场景 | 推荐 | 原因 |
|---|---|---|
| 图片、音频、视频、压缩包 | 字节流 | 数据不是文本,不能按字符解码 |
| 网络协议原始报文 | 字节流 | 协议通常以字节定义字段 |
| 配置文件、CSV、日志文本 | 字符流 | 需要按字符集处理文本 |
| 文件编码转换 | 转换流 | 明确指定源编码和目标编码 |
| 混合二进制和文本 | 字节流 + 协议解析 | 避免字符流破坏字节结构 |
典型错误:
try (Reader reader = new FileReader("image.png")) {
// 错误:图片不是文本,字符解码会破坏内容
}
正确做法:
try (InputStream input = Files.newInputStream(Path.of("image.png"))) {
byte[] buffer = new byte[8192];
while (input.read(buffer) != -1) {
process(buffer);
}
}
文本处理必须显式指定字符集。
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
handle(line);
}
}
不要依赖平台默认编码,否则同一份代码在开发机、Linux 服务器、容器镜像中可能表现不同。
# 3. 缓冲的作用
缓冲流的价值是减少频繁系统调用和磁盘/网络访问。
无缓冲:
程序每读 1 个字节 -> 调用底层 IO -> 成本高
有缓冲:
一次读取一批数据到内存 -> 程序从缓冲区消费 -> 成本低
| API | 适合场景 | 注意点 |
|---|---|---|
BufferedInputStream | 二进制顺序读取 | 缓冲区大小结合数据量调整 |
BufferedOutputStream | 二进制顺序写入 | 写完要 flush 或关闭 |
BufferedReader | 文本按行读取 | 适合日志、CSV、配置 |
BufferedWriter | 文本批量写入 | 关闭前会刷新 |
缓冲不是越大越好。过大的缓冲区会占用更多内存,过小则系统调用频繁。常见起点是 8KB、16KB、64KB,再通过压测调整。
# 4. File、Path、Files 怎么选
File 是早期 API,既表示路径,也提供文件操作。NIO.2 引入 Path 和 Files 后,路径表达和文件操作被拆得更清晰。
| 能力 | File | Path / Files |
|---|---|---|
| 路径表达 | 支持 | 更现代,和文件系统更贴合 |
| 符号链接 | 支持有限 | 支持更完整 |
| 文件属性 | 较弱 | 支持 POSIX、owner、ACL 等 |
| 遍历目录 | listFiles | walkFileTree、Files.walk |
| 异常信息 | 很多方法返回 boolean | 抛出具体异常 |
| 推荐程度 | 维护旧代码会遇到 | 新代码优先使用 |
新代码推荐:
Path path = Path.of("/data/app/config.yml");
if (Files.exists(path) && Files.isRegularFile(path)) {
String content = Files.readString(path, StandardCharsets.UTF_8);
parseConfig(content);
}
处理文件操作时,不要只看 exists。文件系统是共享资源,检查和使用之间可能被其他进程修改,所以关键操作仍要处理异常。
# 5. NIO 三件套
NIO 的核心是 Buffer、Channel、Selector。
Channel 负责连接数据源
Buffer 负责暂存数据
Selector 负责监听多个 Channel 的就绪事件
| 组件 | 作用 | 类比 |
|---|---|---|
Buffer | 一块可读写的内存区域 | 货物暂存区 |
Channel | 数据传输通道,可读可写 | 运输通道 |
Selector | 监听多个通道事件 | 调度中心 |
Buffer 状态转换:
写入数据
│ put/read from channel
▼
position 增加,limit 为 capacity
│ flip
▼
读取数据
│ get/write to channel
▼
position 增加,limit 为原写入位置
│ clear/compact
▼
继续写入
NIO 常见坑:
- 忘记
flip,导致读不到数据。 - 忘记处理半包、粘包。
- 以为
write一次一定写完。 OP_WRITE长期开启导致 CPU 空转。- Selector 空轮询没有退避和重建策略。
# 6. BIO、NIO、AIO 怎么理解
| 模型 | 线程模型 | 适合场景 | 风险 |
|---|---|---|---|
| BIO | 一个连接通常占用一个线程 | 连接少、逻辑简单 | 高连接数下线程成本高 |
| NIO | 少量线程监听多个连接 | 高并发网络服务 | 编码复杂,需要处理事件和缓冲 |
| AIO | 操作系统完成后回调 | 异步文件/网络场景 | 平台差异和生态使用较少 |
BIO 不等于低性能,NIO 也不等于一定高性能。真正的性能取决于连接数、请求耗时、数据量、线程模型、协议设计和业务处理能力。
Netty 等框架的价值在于把 NIO 的复杂细节封装成稳定的事件模型,让业务代码关注协议和处理逻辑。
# 7. 大文件处理
大文件最重要的原则是不要一次性加载到内存。
错误示例:
byte[] bytes = Files.readAllBytes(path);
如果文件很大,这会直接带来内存压力甚至 OOM。
流式处理:
try (InputStream input = Files.newInputStream(path);
OutputStream output = Files.newOutputStream(target)) {
byte[] buffer = new byte[1024 * 64];
int len;
while ((len = input.read(buffer)) != -1) {
output.write(buffer, 0, len);
}
}
按行处理大文本:
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
lines
.filter(line -> !line.isBlank())
.forEach(this::handleLine);
}
注意 Files.lines 返回的是惰性流,必须关闭。用 try-with-resources 可以确保底层文件句柄释放。
# 8. 可靠写文件
可靠写文件要考虑“写到一半失败”的情况。
推荐流程:
写入临时文件
│
▼
flush / close
│
▼
必要时 fsync
│
▼
原子移动到目标路径
│
▼
清理旧文件或临时文件
示例:
Path target = Path.of("/data/config.json");
Path temp = Files.createTempFile(target.getParent(), "config-", ".tmp");
try {
Files.writeString(temp, content, StandardCharsets.UTF_8);
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Files.deleteIfExists(temp);
throw e;
}
如果文件系统不支持 ATOMIC_MOVE,需要捕获异常并设计降级策略。
# 9. 序列化安全
Java 原生序列化可以把对象图写成字节,但生产环境要谨慎。
风险包括:
- 反序列化安全漏洞。
serialVersionUID兼容性问题。- 对象图过大导致内存压力。
- 跨语言能力弱。
- 数据格式不透明,不利于长期演进。
常见替代方案:
| 场景 | 推荐 |
|---|---|
| Web API | JSON |
| 高性能内部 RPC | Protobuf、Thrift、FlatBuffers |
| 配置文件 | YAML、JSON、Properties |
| 本地缓存 | JSON、二进制协议、数据库 |
| Java 内部短期传输 | 谨慎使用原生序列化,并设置过滤器 |
如果必须使用 Java 序列化,应限制反序列化类型,设置 JEP 290 对象输入过滤器,并避免反序列化不可信数据。
# 10. IO 性能排查
IO 慢时不要只盯 Java 代码,还要看系统和下游。
排查路径:
确认慢在哪里
├─ 磁盘读写慢
├─ 网络传输慢
├─ 下游服务慢
├─ 文件句柄耗尽
├─ 线程池阻塞
└─ GC / 内存压力
常见指标:
| 指标 | 含义 |
|---|---|
| 文件大小 | 决定是否能一次性读取 |
| 读写耗时 | 判断是否需要缓冲或分块 |
| 吞吐量 | MB/s 或 requests/s |
| P95/P99 | 判断尾延迟 |
| 打开文件数 | 是否接近系统限制 |
| 线程状态 | 是否大量阻塞在 IO |
| GC 日志 | 大量 byte[] 分配会触发 GC 压力 |
优化顺序:
- 先确认瓶颈是磁盘、网络、CPU、锁还是下游。
- 再减少不必要的数据复制和对象分配。
- 然后调整缓冲区、线程池、批量大小。
- 最后考虑 NIO、零拷贝、内存映射等高级方案。
# 11. 开发检查清单
写 IO 代码前后检查:
- 文件是文本还是二进制。
- 文本是否显式指定字符集。
- 文件大小是否可能超过内存承受范围。
- 是否使用 try-with-resources 关闭资源。
- 写文件失败时是否会留下半文件。
- 是否处理路径不存在、权限不足、磁盘满。
- 上传文件是否校验大小、类型和路径穿越。
- 网络 IO 是否设置超时。
- NIO 写出是否处理未写完。
- 序列化数据是否可信。
- 是否需要缓冲、分块、限流或背压。
- 是否有日志、指标和异常上下文。
# 12. 面试表达
如何系统回答 Java IO?
可以这样回答:
我会先从方向和数据单位讲起:输入/输出、字节流/字符流。字节流适合二进制,字符流适合文本但必须关注编码。然后说明 Java IO 通过装饰器模式组合缓冲、转换、数据、对象等能力。文件操作上,新代码优先使用
Path和Files。进阶部分要讲 NIO 的 Buffer、Channel、Selector,以及 BIO/NIO/AIO 的线程模型差异。最后补充生产实践:try-with-resources、显式字符集、大文件流式处理、可靠写文件、序列化安全和性能排查。
# Tips 快问快答
Q:字节流和字符流怎么选? A:二进制用字节流,文本用字符流,并且文本必须明确字符集。
Q:为什么不建议使用 FileReader?
A:它依赖平台默认编码,跨环境容易乱码;推荐 Files.newBufferedReader(path, charset)。
Q:缓冲流解决什么问题? A:减少频繁底层 IO 调用,提高顺序读写效率。
Q:read 返回值为什么必须判断?
A:返回值表示实际读取字节数,不能假设缓冲区每次都会被填满。
Q:大文件为什么不能 readAllBytes?
A:它会把整个文件加载到内存,文件过大时容易造成 OOM。
Q:NIO 的 Buffer 为什么要 flip?
A:写入后要切换到读取模式,flip 会把 limit 设为写入位置并把 position 归零。
Q:Selector 适合解决什么问题? A:用少量线程管理大量连接的就绪事件,适合高并发网络 IO。
Q:零拷贝是不是完全没有拷贝? A:不是。它通常指减少用户态和内核态之间的数据复制,并不表示硬件层面零次复制。
Q:Java 原生序列化为什么谨慎使用? A:安全风险高、格式不透明、兼容性复杂,反序列化不可信数据尤其危险。
Q:可靠写文件的关键是什么? A:先写临时文件,成功后再原子移动到目标路径,避免半写入污染正式文件。
Q:IO 慢一定要换 NIO 吗? A:不一定。先定位瓶颈,很多问题通过缓冲、分块、线程池隔离、超时和下游优化就能解决。
Q:IO 代码最容易漏掉什么? A:资源关闭、字符集、异常上下文、大文件内存风险和失败后的清理逻辑。