Java IO(一):IO 基础与流体系
Java IO(一):IO 基础与流体系
导语:IO 是面试的基础高频区,重点考察流体系的设计思想(装饰器模式)、缓冲区与字符编码的底层原因,以及 BIO 的线程模型缺陷。本篇覆盖 IO 基础概念、流体系与常用 API、BIO 与资源管理,共 18 题。
一、IO 基础概念
1. 什么是 IO?Java 的 IO 体系如何划分?
答: IO(Input/Output)指程序与外部设备(磁盘、网络、键盘等)之间的数据读写。Java 以流(Stream)为核心抽象,数据像水流一样单向、顺序地流动。
按处理的数据单位分为两大体系:
- 字节流:
InputStream/OutputStream,以 8 位字节为单位,可处理任意二进制数据; - 字符流:
Reader/Writer,以 16 位字符为单位,专用于文本,内置编解码。
按是否直接连接数据源分为:
- 节点流:直接连数据源,如
FileInputStream、FileReader; - 处理流(包装流):包装其他流做功能增强,如
BufferedInputStream、BufferedReader。
2. 比特、字节、字符三者的区别与长度?
答:
- 比特(bit):最小二进制单位,取值 0/1,是计算机的操作单位。
- 字节(Byte):存储的基本单位,固定 8 位,
byte取值范围 -128~127。 - 字符(Char):人可读写的最小单位,是抽象符号(如
'5'、'中'),Java 中char占 16 位(UTF-16 code unit),取值 0~65535。
一句话:bit 是机器的最小操作单位,byte 是存储单位(给计算机看),char 是符号单位(给人看)。
纠正一个流传很广的说法:「一个字符 = 两个字节」只在 UTF-16 或单字节编码下成立。UTF-8 下一个汉字通常占 3 字节,
char c = '中'是 16 位,但"中".getBytes("UTF-8").length是 3。
3. 内核空间与用户空间的区别?为什么 IO 必须经过内核?
答: 操作系统把内存划分为两块:
- 用户空间:应用程序运行的内存区域,权限受限,不能直接访问磁盘、网卡等硬件;
- 内核空间:内核运行区域,拥有最高权限,可直接操作硬件。
应用发起 IO 时必须通过系统调用陷入内核,由内核先把数据从磁盘读到内核缓冲区,再拷贝到用户缓冲区。这一次「两次拷贝 + 两次模式切换」正是理解 BIO / NIO / 零拷贝的基础。
4. 什么是同步/异步、阻塞/非阻塞?(高频陷阱题)
答: 这是两个不同层面的概念,面试最爱在这里挖坑:
| 概念 | 关注点 | 含义 |
|---|---|---|
| 阻塞 / 非阻塞 | 调用方线程的状态 | 阻塞:调用后线程被挂起直到返回;非阻塞:调用立即返回,未就绪时返回状态标记 |
| 同步 / 异步 | 数据就绪与拷贝由谁完成 | 同步:真正的读写(内核↔用户缓冲区拷贝)仍需调用方参与;异步:OS 完成全流程后通知你 |
一句话记忆:同步/异步看"谁来搬数据",阻塞/非阻塞看"线程要不要等"。
据此推出:BIO = 同步阻塞,NIO = 同步非阻塞,AIO = 异步非阻塞。NIO 明明"不阻塞",却仍属"同步",就是因为它只是避免了线程等待,数据拷贝阶段仍要用户线程参与。
二、流体系与常用 API
5. 字节流与字符流的区别?为什么还需要字符流?
答:
| 维度 | 字节流 | 字符流 |
|---|---|---|
| 基类 | InputStream / OutputStream | Reader / Writer |
| 单位 | 8 位字节 | 16 位字符 |
| 适用 | 任意二进制(图片、视频、文件) | 纯文本 |
| 编码处理 | 不处理,需自行编解码 | 内置 Charset,自动编解码 |
| 缓冲区 | 无内置缓冲 | 内置 char[] 缓冲 |
需要字符流的原因:编码问题是字节流无法解决的。用字节流读中文,需要手动处理 byte[] 到 char 的转换以及多字节字符被截断的问题;字符流封装了这套逻辑,还能按行读取(readLine())。
6. Java 中流的四大抽象基类及常用实现类有哪些?
答: 四大基类都是抽象类,位于 java.io:InputStream、OutputStream、Reader、Writer。
常用实现:
| 分类 | 字节流 | 字符流 |
|---|---|---|
| 文件(节点流) | FileInputStream / FileOutputStream | FileReader / FileWriter |
| 缓冲(处理流) | BufferedInputStream / BufferedOutputStream | BufferedReader / BufferedWriter |
| 转换 | — | InputStreamReader / OutputStreamWriter |
| 数组/内存 | ByteArrayInputStream / ByteArrayOutputStream | CharArrayReader / CharArrayWriter |
| 对象序列化 | ObjectInputStream / ObjectOutputStream | — |
| 数据/打印 | DataInputStream / PrintStream | PrintWriter |
7. 节点流与处理流的区别?装饰器模式体现在哪里?
答: 节点流直接连数据源,是"最底层"的流;处理流包装另一个流,在原有功能上叠加能力(缓冲、字符转换、数据类型转换等)。
装饰器模式的体现:处理流与节点流继承同一个抽象基类,因此可以层层嵌套,且对调用方而言接口一致:
// 节点流 → 缓冲 → 字符转换 → 按行读取,任意组合
BufferedReader br = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(new FileInputStream("a.txt")), "UTF-8"));好处:功能组合灵活、无需为每种组合生成子类,符合"对扩展开放、对修改关闭"。
8. 为什么图片、视频、音频要用字节流读取?
答: 因为这些是二进制数据,不存在"字符"的语义。如果用字符流读取,会按 Charset 对字节序列做有损解码:遇到不合法的字节组合会得到替换字符(\uFFFD,即乱码),再写回时数据已经损坏,无法还原原文件。
9. 缓冲流的原理?为什么能显著提速?
答: 默认的 FileInputStream.read() 每调用一次就触发一次系统调用,读取少量字节却要付出用户态/内核态切换的代价,极慢。
BufferedInputStream 内部维护一个字节数组(默认 8192 字节)作为缓冲区:
- 读时先把一整块数据灌入缓冲区,之后
read()直接从内存返回,攒够一批才再一次系统调用; - 写时先写满缓冲区再批量落盘,减少
write系统调用次数。
结论:IO 操作的速度瓶颈不在内存拷贝,而在系统调用与磁盘交互次数,缓冲区把 N 次小 IO 合并成 1 次大 IO。
注意:
BufferedReader.readLine()依赖行结束符,因此它会吞掉换行符;BufferedOutputStream写完后必须flush()或close(),否则缓冲区里残留的数据不会落盘。
10. flush() 与 close() 的区别?不 flush 会怎样?
答:
flush():把缓冲区中尚未写出的数据强制刷出到目标,但流仍可继续使用;close():先flush()(对于带缓冲的实现),再释放文件句柄等底层资源,之后不能再使用该流。
不 flush 的后果:对于 BufferedOutputStream / BufferedWriter,缓冲区中未满的数据不会自动写出。若程序异常退出且没有 close(),这部分数据就丢失了。
close()内部通常会调用flush(),所以只要正常close()就不会丢数据;但在close()之前需要"立刻可见"(如网络发送、与其他进程共享文件)时,要主动flush()。
11. 转换流 InputStreamReader / OutputStreamWriter 的作用?
答: 它们是字节流与字符流之间的桥梁,负责按指定 Charset 做字节↔字符的转换。
典型用法是包在字节流外层,让字节流也能享受字符流的 API:
// 若系统默认编码不是 UTF-8,直接用 new FileReader("a.txt") 会乱码
BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8));这也是
FileReader/FileWriter的隐患:它们只能使用平台默认编码(JDK 11 起才允许传入 Charset),跨平台时容易出现乱码,生产代码更推荐显式使用转换流。
12. 乱码产生的根本原因?如何避免?
答: 乱码的根因是编码与解码使用的字符集不一致,或字节序列在转换过程中被截断/误解。常见三种场景:
- 写文件用 UTF-8,读文件用 GBK,同一串字节被解释成不同字符;
- 一个多字节字符被拆开处理(如按固定长度切分字节流),半个汉字的字节无法解码;
- 读写时未指定字符集,依赖平台默认编码,换环境即乱码。
避免方式:
- 读写两端显式指定同一个
Charset(如StandardCharsets.UTF_8),不依赖默认值; - 用字符流而非字节流处理文本;
- 校验时用
new String(bytes, charset)与str.getBytes(charset)成对出现; - 网络传输在协议层声明字符集(如 HTTP
Content-Type: charset=utf-8)。
13. 如何实现文件复制?哪种方式最快?
答: 三种常见写法,性能依次提升:
// 1. 逐字节读写:最慢,每个字节一次系统调用
int b;
while ((b = in.read()) != -1) out.write(b);
// 2. 字节数组缓冲:常用,一次搬 8KB
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
// 3. NIO 零拷贝:内核态直接传输,大文件最快
try (FileChannel src = FileChannel.open(Paths.get("in.dat"));
FileChannel dst = FileChannel.open(Paths.get("out.dat"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
src.transferTo(0, src.size(), dst);
}结论:小文件用方式 2(或 BufferedInputStream + Files.copy)即可;大文件首选 FileChannel.transferTo 零拷贝。注意 transferTo 不保证一次传完,必须按返回值循环。
14. File 类常用 API?File 与 Path/Files 有什么区别?
答: File 是文件路径的抽象(不表示文件内容),常用方法:exists()、isDirectory()、isFile()、createNewFile()、mkdir()/mkdirs()、delete()、length()、list()/listFiles()、renameTo()、getAbsolutePath()。
区别:
| 维度 | File | Path / Files |
|---|---|---|
| 引入版本 | JDK 1.0 | JDK 7(NIO.2) |
| 定位 | 路径 + 少量文件操作 | Path 只表示路径,Files 是操作工具类 |
| 异常 | 多数方法返回 boolean,失败无原因 | 抛具体异常(IOException 等),信息明确 |
| 功能 | 能力有限 | 支持符号链接、属性读取、目录遍历(walk)、原子操作 |
| 现状 | 遗留 API | 官方推荐 |
新代码优先用
Path+Files(如Files.readAllBytes、Files.copy、Files.deleteIfExists),只在兼容老接口时才用File。
15. 什么是标准输入输出流?System.out 是什么类型?
答: Java 在 System 类中预置了三个标准流(都是静态字段):
System.in:InputStream,标准输入(默认键盘);System.out:PrintStream,标准输出(默认控制台)。PrintStream是OutputStream的子类,因此System.out是字节流,但被包装成了面向文本的打印能力;System.err:PrintStream,标准错误输出。
常见追问:为什么
System.out.println()能直接打印中文?因为PrintStream内部用字符集把字符编码为字节后再写入;JDK 18 起标准输出的默认编码固定为 UTF-8(JEP 400)。
三、BIO 与资源管理
16. 什么是 BIO?优缺点与适用场景?
答: BIO(Blocking IO)即 JDK 1.0 的 java.io,模型是同步阻塞:线程发起 read()/write()/accept() 后会被挂起,直到操作完成才继续执行。
- 优点:编程模型直观简单,代码可读性好,阻塞机制下结果可靠,易于调试。
- 缺点:一连接一线程,线程会长期阻塞在 IO 上;连接数一多,线程数线性增长,带来内存开销与上下文切换损耗。
- 适用场景:连接数较少且相对固定的架构(如内部管理系统、连接数可控的服务)。
17. BIO「一连接一线程」有什么问题?如何改善?
答: 问题在于线程被 IO 白白占用:线程大部分时间阻塞在等待数据上,什么都没干却占着内存(每个线程约 1MB 栈空间)和 CPU 调度开销。
改善路径(也是 IO 模型的演进史):
- 多线程:为每个连接
new Thread处理。能缓解"后到的客户端必须等前一个处理完"的问题,但线程无上限,高并发下 CPU 全耗在上下文切换上; - 线程池:
Executors.newFixedThreadPool(n)限制线程数量,避免线程爆炸。但当所有线程都阻塞在 IO 上时,新连接只能排队等待,吞吐量被线程数锁死; - NIO 多路复用:一个线程通过
Selector管理成千上万个连接,只在真正有数据就绪时才处理——这才是根治方案。
面试要点:多线程与线程池只是缓解,没有改变"线程等数据"的本质,因此无法从根本上突破连接数瓶颈。
18. 为什么 IO 操作必须关闭流?如何正确关闭?
答: 流底层持有文件句柄、Socket 连接等操作系统资源,这些资源数量有限且不受 JVM 垃圾回收直接管理。如果不关闭:
- 文件句柄泄漏,达到上限后无法再打开新文件(
Too many open files); - 缓冲区数据可能未落盘(见第 10 题);
- Socket 连接长期不释放,服务端连接数耗尽。
推荐用 try-with-resources(Java 7+),由编译器生成 finally 块自动关闭,且关闭顺序与声明顺序相反:
try (BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))) {
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
}两个加分点:
- 资源必须实现
AutoCloseable;多个资源在同一个 try 中关闭时,若关闭也抛异常,后续异常会作为 suppressed(被抑制) 附加,不会掩盖主异常; - 若在装饰器模式中手动关闭,只需关闭最外层流,其
close()会级联关闭被包装的流。
