作者简介

大家好,我是 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 设计哲学:流式处理 + 装饰器模式

  1. 流式处理:数据被抽象为连续流动的序列,从源头(文件/内存/Socket)流向目的地,像水管中的水一样顺序通过。

  2. 装饰器模式:通过嵌套包装,动态给流叠加新功能(缓冲、数据类型读写、加密、压缩等),实现“开闭原则”(对扩展开放,对修改关闭)。


第二问:节点流、缓冲流、装饰流到底有什么区别?

按功能角色,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 关键结论

  1. DMA 拷贝(硬件→内核,或内核→硬件)不占用 CPU,由 DMA 控制器完成。

  2. CPU 拷贝(内核→用户空间,或用户空间→内核)占用 CPU,是性能的主要消耗点。

  3. read() / write() 返回时,数据可能在 Page Cache(内存)中,不一定在物理磁盘上

  4. 系统调用(用户态↔内核态切换)本身有开销,减少系统调用次数是 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 为什么操作系统限制文件描述符数量?

  1. 内核内存有限:每个 struct file 占用内核内存(约几百字节)。

  2. 防止资源耗尽攻击:防止恶意进程打开海量文件,耗尽系统资源。

  3. 进程隔离与安全:限制单个进程可占用的系统资源上限。

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 体系。它的核心机制:

  1. 文件指针(File Pointer):内部维护一个 long 类型的指针,记录当前操作位置。

  2. seek(long pos):依赖操作系统 lseek() 系统调用(Linux)或 SetFilePointer(Windows),将文件指针移动到任意位置。

  3. 读写操作:从指针当前位置开始读写,操作完成后指针自动后移。

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 底层初始化流程

  1. JVM 启动时,操作系统已为进程分配好 FD 0、1、2。

  2. System 类的静态初始化块中,调用 native 方法,将这三个 FD 封装为对应的 Java 流对象。

  3. 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 多个流同时写同一个文件(危险!)

会出现以下三种问题:

  1. 数据覆盖:两个流同时 seek 到相同位置,后写的覆盖先写的。

  2. 数据交错:写入大块数据不是原子操作,两个流交替写入可能导致字节混乱。

  3. 指针竞争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: 评论区直接留言,我会逐一回复。技术问题不怕钻牛角尖,越辩越明。


如果觉得这篇文章对你有帮助,请点赞、收藏、关注,支持我输出更多底层硬核内容!下期见! 🚀

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐