【Java IO】Java IO 流完全剖析:十三个问题,从 API 使用到底层内核,从单线程到并发控制,彻底梳理完整知识体系
作者简介
大家好,我是 CodeStats。一个在底层技术上“考古”了四年的硬核爱好者,也是 WWAIC(全周项目AI编程)范式 的提出者和实践者。我曾手写过一个完整的 Java Web 框架(从 IoC 容器到嵌入式 Tomcat,代码全开源),也喜欢用通俗的语言拆解 CPU、JVM、操作系统的运行本质。
我的技术信条:所有高深的技术,最后都能用大白话讲清楚。如果讲不清楚,说明还没真正理解。
📌 本文获取(你将从这篇文章中学到什么?)
读完这篇 2 万字的长文,你将彻底搞懂以下 13 个核心问题,从此 Java IO 面试对你不设防:
| 序号 | 你将掌握的知识点 | 解决的核心困惑 |
|---|---|---|
| ① | IO 四大抽象基类(InputStream / OutputStream / Reader / Writer)的 API 设计精髓 |
为什么会有这么多流?它们的根在哪里? |
| ② | 节点流、装饰流、缓冲流 的本质区别与设计模式(装饰器模式) | new BufferedInputStream(new FileInputStream()) 为什么要套娃? |
| ③ | 字节流与字符流 的完整继承体系与核心子类清单 | 什么时候用字节流,什么时候用字符流?乱码是怎么产生的? |
| ④ | 磁盘、内存、Socket、控制台 四大数据源分别用哪个 IO 类操作 | 面对不同数据源,我到底该 new 哪个类? |
| ⑤ | 从 JVM 用户空间 → 操作系统内核 的完整数据搬运流程(DMA、Page Cache、CPU 拷贝) | 一个 read() 调用,底层到底发生了多少次拷贝? |
| ⑥ | 缓冲流 提速 100 倍的底层原理(系统调用次数从 8192 次降为 1 次) | 为什么加了 Buffered 性能就飞起? |
| ⑦ | 哪些数据源有 文件描述符(FD),哪些没有 | ByteArrayInputStream 为什么不消耗 OS 资源? |
| ⑧ | 文件描述符对应的内核数据结构(fd → struct file → inode)及 Too many open files 根源 |
操作系统为什么要限制文件打开数量? |
| ⑨ | flush() 和物理磁盘刷盘(sync()) 的本质区别 |
调用 flush() 后断电,数据还在不在? |
| ⑩ | RandomAccessFile 随机访问的底层秘密(lseek 系统调用)与适用场景 |
为什么 FileInputStream 不能随意跳着读? |
| ⑪ | System.out / System.in 默认指向控制台的底层原理(标准流 FD 0/1/2) |
为什么 JVM 一启动就能用 System.out.println()? |
| ⑫ | 四种数据源判断读结束(返回 -1) 的语义完全不同 | 为什么文件流读到 -1 还能再读,Socket 读到 -1 就不行了? |
| ⑬ | 多线程/多进程同时读写同一个文件的并发问题与解决方案(FileLock) |
多个流一起写文件会不会乱?如何加锁? |
📖 目录
-
第一问:拆解 Java IO 流核心设计:读数据和写数据的完整 API 抽象是什么?
-
第二问:节点流、缓冲流、装饰流到底有什么区别?
-
第三问:字节流和字符流的完整体系是什么?核心总结?
-
第四问:磁盘、内存、Socket、控制台分别用什么 IO 流操作?
-
第五问:从 JVM 用户空间到操作系统内核,完整数据流程是什么?
-
第六问:缓冲流的作用是什么?为什么设计缓冲流?
-
第七问:四种数据源(磁盘、内存、Socket、控制台)哪些有文件描述符?
-
第八问:文件描述符对应操作系统内核什么数据结构?为什么有限制?
-
第九问:哪些 IO 流写完数据需要 flush?磁盘刷盘原理是什么?
-
第十问:RandomAccessFile 为什么能随机访问?应用场景?为什么一般流不支持?
-
第十一问:System.out 和 System.in 为什么默认指向控制台?原理?
-
第十二问:四种数据源如何判断读结束?区别是什么?
-
第十三问:如何同时读写文件?会怎样?如何控制并发?
第一问:拆解 Java IO 流核心设计:读数据和写数据的完整 API 抽象是什么?
Java I/O 的设计遵循 “抽象层与实现层分离” 的原则。四个抽象基类定义了所有流的规范,它们是整个 java.io 包的根基。
1.1 四大抽象基类总览
| 基类 | 数据单位 | 方向 | 核心读/写方法 | 辅助方法 |
|---|---|---|---|---|
InputStream |
字节(8-bit) | 输入 | read(), read(byte[]), read(byte[], int, int) |
skip(), available(), close() |
OutputStream |
字节(8-bit) | 输出 | write(int), write(byte[]), write(byte[], int, int) |
flush(), close() |
Reader |
字符(16-bit Unicode) | 输入 | read(), read(char[]), read(char[], int, int) |
ready(), close() |
Writer |
字符(16-bit Unicode) | 输出 | write(int), write(char[]), write(String) |
flush(), close() |
1.2 核心方法深度解析
-
int read()(输入流):读取一个字节/字符,返回 0~255(字节)或 0~65535(字符)的整数,返回 -1 表示流结束(文件末尾、Socket 关闭等)。这是最基础的方法,其余read重载最终都调用它。 -
int read(byte[] b)(输入流):尽可能填满字节数组,返回实际读取长度,减少 Java 与内核的交互次数。 -
void write(int b)(输出流):写入一个字节/字符(只取低 8 位或低 16 位)。 -
void flush()(输出流):强制将 JVM 缓冲区的数据推送到操作系统内核。注意:这不等同于物理落盘! -
void close()(所有流):释放底层系统资源(文件描述符、Socket 连接等),必须调用!
1.3 设计哲学:流式处理 + 装饰器模式
-
流式处理:数据被抽象为连续流动的序列,从源头(文件/内存/Socket)流向目的地,像水管中的水一样顺序通过。
-
装饰器模式:通过嵌套包装,动态给流叠加新功能(缓冲、数据类型读写、加密、压缩等),实现“开闭原则”(对扩展开放,对修改关闭)。
第二问:节点流、缓冲流、装饰流到底有什么区别?
按功能角色,Java IO 流分为三个层次,很多初学者容易混淆。
2.1 节点流(Node Stream)
-
定义:直接与数据源(磁盘文件、内存数组、Socket、管道)建立连接的流。它是整个 IO 体系的基础,负责真正的底层数据读写。
-
特点:独立使用,功能单一,只提供最基础的
read()/write()方法。 -
构造参数:数据源本身(如
File对象、byte[]数组、Socket对象)。 -
典型代表:
FileInputStream,ByteArrayInputStream,Socket.getInputStream(),PipedInputStream。
2.2 装饰流(Processing / Decorator Stream)
-
定义:不直接连接数据源,而是包装(Wrapper) 一个已有的流,为它增加新的功能。
-
特点:不能独立存在(必须依赖被包装的流),可以多层嵌套形成处理管道。
-
构造参数:另一个
InputStream/OutputStream/Reader/Writer对象。 -
典型代表:
BufferedInputStream(缓冲),DataInputStream(基本类型读写),InputStreamReader(字节转字符)。
2.3 缓冲流(Buffered Stream)
-
本质:是装饰流的一种特例,专门提供内部缓冲区(默认 8KB),通过批量读写大幅减少系统调用次数。
-
典型代表:
BufferedInputStream,BufferedOutputStream,BufferedReader,BufferedWriter。
2.4 核心区别对比表
| 维度 | 节点流 | 装饰流(含缓冲流) |
|---|---|---|
| 数据源连接 | 直接连接(文件、数组等) | 间接连接(通过被包装的流) |
| 能否独立使用 | ✅ 可以 | ❌ 不能(必须包装其他流) |
| 构造参数 | 数据源(路径、数组、Socket) | 另一个流对象 |
| 功能 | 单一的基础读写 | 增强功能(缓冲、类型转换、编码) |
| 设计模式角色 | 被装饰的原始组件(Component) | 装饰器(Decorator) |
| 典型代码 | new FileInputStream("a.txt") |
new BufferedInputStream(new FileInputStream("a.txt")) |
第三问:字节流和字符流的完整体系是什么?核心总结?
3.1 字节流体系(Byte Streams)—— 一切二进制数据的基础
输入字节流继承树:
text
java.io.InputStream (抽象) ├── java.io.FileInputStream // 从磁盘文件读取 ├── java.io.ByteArrayInputStream // 从内存 byte[] 读取 ├── java.io.FilterInputStream // 装饰器父类 │ ├── BufferedInputStream // 缓冲(8KB) │ ├── DataInputStream // 读取基本类型(int/double等) │ ├── PushbackInputStream // 可推回已读字节 │ └── LineNumberInputStream (已弃用) ├── java.io.ObjectInputStream // 反序列化对象 ├── java.io.PipedInputStream // 线程间管道通信 ├── java.io.SequenceInputStream // 合并多个输入流 └── java.io.StringBufferInputStream (已弃用)
输出字节流继承树:
text
java.io.OutputStream (抽象) ├── java.io.FileOutputStream ├── java.io.ByteArrayOutputStream ├── java.io.FilterOutputStream │ ├── BufferedOutputStream │ ├── DataOutputStream │ └── PrintStream // 打印方法(System.out 就是这个) ├── java.io.ObjectOutputStream // 序列化对象 └── java.io.PipedOutputStream
3.2 字符流体系(Character Streams)—— 专门处理文本,自动编解码
输入字符流继承树:
text
java.io.Reader (抽象) ├── java.io.BufferedReader // 缓冲 + readLine() │ └── LineNumberReader // 带行号追踪 ├── java.io.CharArrayReader // 从 char[] 读取 ├── java.io.FilterReader │ └── PushbackReader // 可推回字符 ├── java.io.InputStreamReader // ★ 字节→字符的桥梁(指定编码) │ └── FileReader // 文件(默认编码,不推荐) ├── java.io.PipedReader └── java.io.StringReader
输出字符流继承树:
text
java.io.Writer (抽象) ├── java.io.BufferedWriter // 缓冲 + newLine() ├── java.io.CharArrayWriter ├── java.io.FilterWriter ├── java.io.OutputStreamWriter // ★ 字符→字节的桥梁(指定编码) │ └── FileWriter ├── java.io.PipedWriter ├── java.io.PrintWriter // 打印方法,推荐替代 PrintStream └── java.io.StringWriter
3.3 核心总结与选型铁律
| 场景 | 推荐使用 | 禁止/不推荐 |
|---|---|---|
| 图片、视频、音频、压缩包等二进制文件 | FileInputStream / FileOutputStream |
FileReader / FileWriter(会乱码) |
| 文本文件(.txt、.csv、.json) | FileReader / FileWriter(但必须指定编码,建议用 InputStreamReader 包装) |
不指定编码(依赖系统编码,跨平台乱码) |
| 网络传输二进制数据 | Socket.getInputStream() / getOutputStream() |
直接用字符流(网络传输的是字节) |
| 网络传输文本(如 HTTP 协议) | new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8) |
不指定编码 |
| 读写 Java 基本类型(int、double) | DataInputStream / DataOutputStream |
手动拼接字节 |
| 读写 Java 对象 | ObjectInputStream / ObjectOutputStream |
手动序列化 |
记忆口诀:二进制用字节流,文本用字符流;字符流=字节流+编码表;无论何时,指定 UTF-8!
第四问:磁盘、内存、Socket、控制台分别用什么 IO 流操作?
以“内存”为中心看数据流向:Input 是从外部读入内存,Output 是从内存写出到外部。
4.1 磁盘文件(File)
| 数据单位 | 输入流 | 输出流 | 示例 |
|---|---|---|---|
| 字节 | FileInputStream |
FileOutputStream |
图片、视频 |
| 字符 | FileReader(或 InputStreamReader 包装) |
FileWriter(或 OutputStreamWriter 包装) |
文本文件 |
4.2 内存(Memory)—— 纯 JVM 操作,无系统调用
| 底层载体 | 输入流 | 输出流 | 特点 |
|---|---|---|---|
byte[] |
ByteArrayInputStream |
ByteArrayOutputStream |
极快,临时缓存 |
char[] |
CharArrayReader |
CharArrayWriter |
极快,字符数组 |
String |
StringReader |
StringWriter |
方便字符串处理 |
4.3 网络 Socket
-
输入:
Socket.getInputStream()返回InputStream -
输出:
Socket.getOutputStream()返回OutputStream -
文本处理:必须用
InputStreamReader/OutputStreamWriter包装并指定编码(如 UTF-8)
4.4 控制台(Console)
| 方向 | 流对象 | 类型 | 对应文件描述符 |
|---|---|---|---|
| 标准输入(键盘) | System.in |
InputStream |
FD 0 |
| 标准输出(屏幕) | System.out |
PrintStream |
FD 1 |
| 标准错误 | System.err |
PrintStream |
FD 2 |
第五问:从 JVM 用户空间到操作系统内核,完整数据流程是什么?
这是理解 IO 性能的“底层圣经”,面试必问。
5.1 用户空间与内核空间
-
用户空间:JVM 运行的地方,Java 代码只能操作 JVM 堆内存,无权直接访问硬件(磁盘、网卡)。
-
内核空间:操作系统内核运行的地方,拥有硬件访问权限。
关键事实:Java 程序必须通过系统调用(System Call) 请求内核代为操作硬件,CPU 需要从用户态切换为内核态,这个切换是有性能开销的。
5.2 读取文件(read())的完整 6 步流程
text
Java代码: byte[] buf = new byte[1024]; int len = fis.read(buf);
↓
① JVM 发起 read() 系统调用,CPU 从用户态切换到内核态
② 内核通过文件描述符找到对应的 struct file,检查 Page Cache(页缓存)
③ 如果 Page Cache 有数据(缓存命中)→ 跳到第⑤步
④ 如果 Page Cache 无数据(缓存未命中)→ 内核向磁盘控制器发 DMA 命令
⑤ 磁盘通过 DMA(直接内存访问)将数据写入内核的 Page Cache(无需 CPU 参与!)
⑥ CPU 将 Page Cache 中的数据拷贝到 JVM 的 byte[] 数组(这一步 CPU 参与)
⑦ CPU 从内核态切回用户态,read() 返回实际读取字节数
5.3 写入文件(write())的完整流程
text
Java代码: fos.write(buf);
↓
① JVM 发起 write() 系统调用,CPU 切换到内核态
② CPU 将 JVM 的 byte[] 数据拷贝到内核的 Page Cache
③ CPU 切回用户态,write() 返回(此时数据在内存中,不一定落盘!)
④ (异步)内核在合适时机,通过 DMA 将 Page Cache 数据写入物理磁盘
5.4 关键结论
-
DMA 拷贝(硬件→内核,或内核→硬件)不占用 CPU,由 DMA 控制器完成。
-
CPU 拷贝(内核→用户空间,或用户空间→内核)占用 CPU,是性能的主要消耗点。
-
read()/write()返回时,数据可能在 Page Cache(内存)中,不一定在物理磁盘上。 -
系统调用(用户态↔内核态切换)本身有开销,减少系统调用次数是 IO 性能优化的核心。
第六问:缓冲流的作用是什么?为什么设计缓冲流?
6.1 缓冲流的作用
缓冲流(BufferedInputStream / BufferedOutputStream / BufferedReader / BufferedWriter)通过在 JVM 内存中开辟一块缓冲区(默认 8192 字节,即 8KB),将多次零散的读写合并为少数几次批量读写,从而大幅减少系统调用次数,提升 IO 性能。
6.2 工作原理深度剖析
BufferedInputStream 读流程:
-
内部持有一个
byte[] buf数组作为缓冲区。 -
第一次调用
read()时,触发fill()方法,通过一次系统调用从底层流读取 最多 8KB 数据填充缓冲区。 -
后续的
read()调用直接从内存缓冲区取数据,不再触发系统调用。 -
当缓冲区数据被耗尽,再次触发
fill(),读取下一批 8KB 数据。
BufferedOutputStream 写流程:
-
内部持有一个
byte[] buf数组。 -
write()调用将数据先写入内存缓冲区。 -
当缓冲区写满时,触发一次系统调用,将 8KB 数据一次性刷入底层流。
-
必须手动调用
flush()或close()将缓冲区剩余数据强制刷出,否则数据可能滞留内存而丢失。
6.3 为什么需要缓冲流?(性能实测对比)
| 操作方式 | 系统调用次数(读取 8192 字节) | 相对耗时 |
|---|---|---|
无缓冲(单字节循环 read()) |
8192 次 | 极慢(约 100倍+) |
有缓冲(BufferedInputStream) |
1 次 | 极快 |
系统调用开销:每次系统调用涉及用户态↔内核态切换,耗时约 1~10 微秒。8192 次切换的总耗时远大于一次批量拷贝 8KB 数据的时间。
6.4 设计模式
缓冲流是 装饰器模式(Decorator Pattern) 的经典实现,它不直接连接数据源,而是包装(Wrapper)一个已有的流对象,在运行时动态叠加缓冲功能,体现了“组合优于继承”的设计原则。
第七问:四种数据源(磁盘、内存、Socket、控制台)哪些有文件描述符?
文件描述符(File Descriptor,FD) 是操作系统内核分配给进程打开资源(文件、Socket、管道等)的一个非负整数索引。
| 数据源 | 是否有文件描述符 | 底层数据结构 | 说明 |
|---|---|---|---|
| 磁盘文件 | ✅ 有 | struct file + inode |
FileInputStream 内部持有 FileDescriptor,对应 OS 资源 |
| Socket(网络) | ✅ 有 | struct socket |
Socket.getInputStream() 底层对应 Socket FD |
控制台(System.in/out) |
✅ 有 | struct file(指向 TTY/终端设备) |
对应 FD 0、1、2(标准输入/输出/错误) |
内存流(ByteArrayInputStream 等) |
❌ 没有 | 纯 JVM 堆内存 byte[] |
不涉及系统调用,操作系统根本不知道这个“流”存在 |
7.1 为什么内存流没有 FD?
因为内存流的数据源就是 JVM 内存中的一个数组,所有读写操作都是内存地址的读写,完全不经过操作系统内核,不产生任何系统调用,自然不会有文件描述符。
7.2 如何获取文件描述符?
java
try (FileInputStream fis = new FileInputStream("test.txt")) {
FileDescriptor fd = fis.getFD();
System.out.println("文件描述符有效: " + fd.valid());
// Linux 下通过反射可获取 fd 的 int 值(通常是 3 或 4)
}
第八问:文件描述符对应操作系统内核什么数据结构?为什么有限制?
8.1 内核中的三层数据结构
当一个进程打开文件时,操作系统内核维护三层结构:
| 层级 | 数据结构 | 作用域 | 作用 |
|---|---|---|---|
| 进程级 | 文件描述符表(整数数组) | 每个进程独有 | 数组下标就是 FD(0、1、2...),指向系统级打开文件表 |
| 系统级 | 打开文件表(struct file) |
全局内核 | 记录文件偏移量(f_pos)、访问模式、引用计数 |
| 文件系统级 | i-node 表(struct inode) |
全局内核 | 记录文件元数据(大小、权限、磁盘块位置) |
关键点:多个进程打开同一个文件时,每个进程有独立的 struct file(独立偏移量),但共享同一个 inode(同一份文件数据在 Page Cache 中只有一份)。
8.2 为什么操作系统限制文件描述符数量?
-
内核内存有限:每个
struct file占用内核内存(约几百字节)。 -
防止资源耗尽攻击:防止恶意进程打开海量文件,耗尽系统资源。
-
进程隔离与安全:限制单个进程可占用的系统资源上限。
Linux 查看与修改:
bash
ulimit -n # 查看当前限制(通常 1024 或 65535) ulimit -n 4096 # 临时修改为 4096
8.3 经典异常:Too many open files
当进程打开的文件描述符总数超过系统限制时,后续 open() / accept() 等系统调用会失败,Java 抛出 IOException: Too many open files。
原因:代码中忘记调用 close() 释放流,导致 FD 泄露。
第九问:哪些 IO 流写完数据需要 flush?磁盘刷盘原理是什么?
9.1 flush() 与“物理刷盘”是两件不同的事
| 操作 | 层级 | 作用 | 数据最终位置 | 断电后数据安全? |
|---|---|---|---|---|
flush() |
应用层(JVM) | 将 JVM 缓冲区的数据推到操作系统内核 Page Cache | 内存(OS 缓存) | ❌ 不安全(断电丢失) |
sync() / force() |
内核层 | 强制将 OS Page Cache 的数据写入物理磁盘 | 物理硬盘扇区 | ✅ 安全(已落盘) |
9.2 哪些流必须调用 flush()?
| 流类型 | 是否需要 flush() |
原因 |
|---|---|---|
BufferedOutputStream / BufferedWriter |
✅ 必须 | 内部有 JVM 缓冲区,数据可能滞留内存,不 flush 会丢数据 |
PrintWriter |
✅ 必须(默认不自动刷新) | 除非构造时传入 autoFlush 参数为 true |
OutputStreamWriter / FileWriter |
⚠️ 建议 | 内部有编码缓冲区,少量字符可能滞留 |
FileOutputStream(无包装) |
❌ 无效(空实现) | 无 JVM 缓冲区,每次 write 直接系统调用 |
ByteArrayOutputStream |
❌ 不需要 | 写向内存,不涉及 OS |
Socket.getOutputStream() |
✅ 必须 | 网络流需要把数据推送到网卡发送队列,否则对方收不到 |
9.3 如何真正做到物理磁盘刷盘?
java
try (FileOutputStream fos = new FileOutputStream("critical.dat")) {
fos.write("重要数据".getBytes(StandardCharsets.UTF_8));
fos.flush(); // ① 到 OS 缓存(可选,对于 FileOutputStream 无效)
fos.getFD().sync(); // ② ★ 真正落盘!强制写入物理磁盘
} catch (IOException e) { ... }
其他刷盘方式:
-
FileChannel.force(boolean metaData)(NIO) -
RandomAccessFile构造参数"rwd"/"rws"(每次写入自动同步)
第十问:RandomAccessFile 为什么能随机访问?应用场景?为什么一般流不支持?
10.1 随机访问的底层原理
RandomAccessFile 直接继承 Object,完全独立于 InputStream / OutputStream 体系。它的核心机制:
-
文件指针(File Pointer):内部维护一个
long类型的指针,记录当前操作位置。 -
seek(long pos):依赖操作系统lseek()系统调用(Linux)或SetFilePointer(Windows),将文件指针移动到任意位置。 -
读写操作:从指针当前位置开始读写,操作完成后指针自动后移。
10.2 为什么一般 IO 流不支持随机访问?
-
抽象一致性:
FileInputStream设计为“流式”,需要统一处理文件、Socket、管道等所有数据源,而 Socket 和管道本身不支持随机访问。 -
简单性:流式 API 简单易懂,顺序读写能满足 90% 的场景。
-
适用性:网络流是单向顺序的,强行增加
seek()会破坏抽象。
10.3 应用场景
-
断点续传 / 多线程下载:不同线程负责文件的不同分段,各自
seek到指定位置写入。 -
大文件分块处理:无需加载整个文件到内存,直接跳到特定偏移量读取。
-
简易数据库 / 嵌入式存储:固定长度记录,通过计算偏移量实现 O(1) 随机访问。
10.4 类似的随机访问功能(NIO)
java
// 使用 FileChannel(NIO)
try (RandomAccessFile raf = new RandomAccessFile("file.bin", "rw");
FileChannel channel = raf.getChannel()) {
channel.position(1024); // 移到第 1024 字节
ByteBuffer buffer = ByteBuffer.allocate(4);
buffer.putInt(999);
buffer.flip();
channel.write(buffer); // 在 1024 处写入 int
}
第十一问:System.out 和 System.in 为什么默认指向控制台?原理?
11.1 标准流模型(Standard Streams)
Java 继承了 Unix/Linux 的 “标准 I/O” 设计,每个进程启动时操作系统自动分配三个文件描述符:
| 标准流 | Java 对象 | 文件描述符 | 默认目标 |
|---|---|---|---|
| 标准输入 | System.in |
FD 0 | 键盘 |
| 标准输出 | System.out |
FD 1 | 屏幕(控制台) |
| 标准错误 | System.err |
FD 2 | 屏幕(控制台) |
11.2 底层初始化流程
-
JVM 启动时,操作系统已为进程分配好 FD 0、1、2。
-
System类的静态初始化块中,调用 native 方法,将这三个 FD 封装为对应的 Java 流对象。 -
System.out是PrintStream类型,System.in是InputStream类型。
11.3 重定向能力
Shell 重定向:
bash
java MyApp < input.txt > output.log 2> error.log
Java API 重定向:
java
System.setIn(new FileInputStream("input.txt"));
System.setOut(new PrintStream(new FileOutputStream("output.txt")));
System.setErr(new PrintStream(new FileOutputStream("error.log")));
11.4 ⚠️ 致命警告
绝对不要调用 System.in.close() 或 System.out.close()!
因为它们是 JVM 全局单例,关闭后整个程序将无法再读取输入或输出,且无法重新打开,只能重启 JVM。
第十二问:四种数据源如何判断读结束?区别是什么?
这是最容易被忽视、却最致命的知识点,不同数据源的 -1 语义完全不同!
12.1 四种数据源读结束判断总表
| 数据源 | 判断方式 | 返回 -1 的语义 |
返回 -1 后能否恢复? |
|---|---|---|---|
| 磁盘文件(静态) | read() 返回 -1 |
指针到达文件物理末尾 | ❌ 不可恢复(文件大小不变) |
| 磁盘文件(动态日志) | read() 返回 -1 |
“暂时追上了尾巴” | ✅ 可以恢复(文件被追加新数据后) |
| 内存流(ByteArray) | read() 返回 -1 |
数组数据全部读完 | ❌ 不可恢复(数组长度固定) |
| Socket 网络流 | read() 返回 -1 |
“对方关闭了连接”(EOF) | ❌ 绝对不可恢复(连接已死) |
| 控制台(键盘) | read() 返回 -1 |
用户发送 EOF(Ctrl+D / Ctrl+Z) | ❌ 不可恢复(流被标记结束) |
12.2 深度解析
磁盘文件(动态增长):
-
场景:监控正在写入的日志文件。
-
这一刻
read()返回 -1,因为文件大小是 1000 字节,指针在 1000。 -
下一秒日志框架追加了 100 字节,文件大小变为 1100。
-
再次调用
read(),不会返回 -1,而是能读到新追加的 100 字节! -
结论:文件流的
-1是 “当前时刻的物理末尾”,不是永久终结。
Socket 网络流:
-
对方调用
socket.close()或shutdownOutput()时,Socket 流返回 -1。 -
这代表 TCP 连接已经关闭(收到 FIN 包),是永久性、不可逆的终结。
-
工程铁律:在 Socket 编程中,绝对不要仅凭
-1来判断应用层消息读取完毕!必须依赖应用层协议(定长消息、分隔符、长度字段)。
内存流:
-
数据源是固定长度的数组,读完了就是真的完了,无法追加。
控制台:
-
交互式模式下,只有用户主动发送 EOF 信号(Linux/Mac 按
Ctrl+D,Windows 按Ctrl+Z回车)才会返回 -1。 -
一旦发送,流结束,无法恢复。
第十三问:如何同时读写文件?会怎样?如何控制并发?
13.1 多个流同时读同一个文件
-
文件描述符:每个流拥有独立的 FD,指向内核中不同的
struct file对象。 -
文件指针:每个流各自维护
f_pos(读写位置),互不干扰。 -
内核数据:Page Cache 中只有一份文件数据,所有流共享这块物理内存。
-
结论:多线程/多进程同时读文件是绝对安全的,无需任何同步。
13.2 多个流同时写同一个文件(危险!)
会出现以下三种问题:
-
数据覆盖:两个流同时 seek 到相同位置,后写的覆盖先写的。
-
数据交错:写入大块数据不是原子操作,两个流交替写入可能导致字节混乱。
-
指针竞争:
seek()+write()不是原子组合,多线程下指针位置不可预测。
13.3 并发控制方案
| 方案 | 适用场景 | 原理 | 推荐度 |
|---|---|---|---|
| 每个线程独立创建流 | 多线程写不同区域 | 每个线程有自己的 FD 和指针,天然隔离 | ⭐⭐⭐⭐⭐ |
synchronized 同步块 |
同 JVM 内多线程 | 保证 seek + write 原子执行 |
⭐⭐⭐⭐ |
FileChannel.lock() |
跨进程同步 | 操作系统级文件锁(advisory lock),可锁定指定区域 | ⭐⭐⭐⭐ |
ReentrantReadWriteLock |
读多写少场景 | 允许多个读操作并发,写操作互斥 | ⭐⭐⭐ |
13.4 代码实战
方案一:每个线程独立流(最推荐)
java
// 线程 1:写文件开头
new Thread(() -> {
try (RandomAccessFile raf = new RandomAccessFile("data.bin", "rw")) {
raf.seek(0);
raf.writeInt(100);
}
}).start();
// 线程 2:写文件第 1000 字节处
new Thread(() -> {
try (RandomAccessFile raf = new RandomAccessFile("data.bin", "rw")) {
raf.seek(1000);
raf.writeInt(200);
}
}).start();
方案二:FileLock(跨进程安全)
java
try (FileChannel channel = FileChannel.open(
Paths.get("data.bin"),
StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
// 锁定文件的 0~4 字节(独占锁)
FileLock lock = channel.lock(0, 4, false);
try {
ByteBuffer buffer = ByteBuffer.allocate(4);
buffer.putInt(999);
buffer.flip();
channel.position(0);
channel.write(buffer);
} finally {
lock.release(); // 释放锁
}
}
🏁 终极总结
本文通过 十三个层层递进的问题,从 API 使用到底层内核,从单线程到并发控制,彻底梳理了 Java IO 流的完整知识体系:
| 知识维度 | 核心收获 |
|---|---|
| API 设计 | 四大抽象基类 InputStream / OutputStream / Reader / Writer 定义了读写规范 |
| 流分类 | 节点流(直接连数据源) vs 装饰流(增强功能) vs 缓冲流(装饰流的特例) |
| 字节 vs 字符 | 二进制用字节流,文本用字符流(务必指定编码 UTF-8) |
| 四大数据源 | 文件用 File 系列,内存用 ByteArray 系列,Socket 用 getStream,控制台用 System |
| 内核数据流程 | 用户态 ↔ 内核态切换,DMA 搬运(不占 CPU)+ CPU 拷贝(占 CPU) |
| 缓冲流原理 | 8KB 缓冲区将 8192 次系统调用降为 1 次,性能提升 100 倍 |
| 文件描述符 | 文件/Socket/控制台有 FD(消耗内核资源),内存流没有(纯 JVM 操作) |
| 内核数据结构 | 进程 FD 表 → struct file(偏移量) → inode(元数据),限制源于内核资源 |
| flush vs 刷盘 | flush() 到 OS 缓存(内存),sync() 才真正落盘(物理磁盘) |
| 随机访问 | RandomAccessFile 依赖 lseek 系统调用,独立于流体系 |
| 标准流 | System.in/out/err 对应 FD 0/1/2,继承 Unix 标准模型 |
| 读结束语义 | 文件可恢复、Socket 不可恢复——这是本质区别! |
| 并发读写 | 多读安全,多写需同步(独立流 / FileLock) |
理解 Java IO,不仅是学会调 API,更是理解操作系统如何管理资源、数据如何在软硬件之间流转的过程。
📌 本文获取(再放送)
Q:想获得本文完整思维导图和高清 PDF 版本?
A: 点赞 👍 + 收藏 ⭐ + 留言 💬 “已收藏”,我会私信发送配套的 《Java IO 流十三问 · 知识地图》 高清大图(含所有类继承关系、数据流程图、对比表格,一张图搞定 IO 流)。
Q:如何持续收到这类“从底层到源码”的硬核技术文章?
A: 关注我 ✅,每周一篇深度好文,用大白话带你拆解 Java 底层、JVM、操作系统、网络编程的核心原理。
Q:有疑问或想讨论文中某个点?
A: 评论区直接留言,我会逐一回复。技术问题不怕钻牛角尖,越辩越明。
如果觉得这篇文章对你有帮助,请点赞、收藏、关注,支持我输出更多底层硬核内容!下期见! 🚀
更多推荐

所有评论(0)