八股文背了一堆,不如把这些答案的来历和原理吃透一次。这篇不按语法书顺序罗列分类,直接把 Java IO 这套体系从设计动机、底层机制到实际踩坑串起来讲。不管是你准备面试聊底层,还是项目里做文件传输、网络通信时选型,读完都能有明确判断依据。
1. Java IO 为什么设计成“流”的样子
1.1 顺着水管想问题:流的本质是单向管道
Java 的 IO 抽象,核心就一个词:流(Stream)。这东西用生活里的水管来类比最好理解。数据像水一样,从水源流向目的地。Java 里的输入流、输出流,本质就是两条方向不同的单向管道。记住这个“单向”很重要,它决定了你在编码时的直觉判断:InputStream 是程序从外面往内存里“抽水”,OutputStream 是程序把内存里的数据“排出去”。你要是想又读又写同一种流,在经典 IO 里是不存在的,必须分别建立两条管道。
为什么这么设计?因为真实世界的设备场景就是单向的:键盘只能输入,显示器只能输出,磁盘文件可以读也可以写,但读写是两种独立操作。把方向分清楚,各管各的,实现类就不用在同一条管道里维护“当前方向”这种复杂状态了。你平时用 FileInputStream 读文件、用 FileOutputStream 写文件,文件可以同时打开这两个流,但每条流内部的状态是干净且独立的。
我在面试中经常问候选人一个问题:Java 里的“流”和“集合”有什么区别?很多人答不上来。集合是内存里已经存在的一组数据,是静态的;而你拿到一个 InputStream,手里并没有数据,数据还在文件/网络/键盘那边,你得调用 read() 把它一点一点拉过来。流的本质是“连接数据源与目的地之间的移动通道”,而不是数据本身。理解这一点,后面理解缓冲区、理解 NIO 的 Channel,都会轻松很多。
1.2 装饰器模式:为什么 Java 的流能一层套一层
这是 Java IO 体系里最容易被忽略却又最有魅力的设计。你写代码时一定见过这样的写法:
BufferedInputStream bis = new BufferedInputStream(new FileInputStream("data.txt")); DataInputStream dis = new DataInputStream(bis);外层套内层,一层包一层。这就是装饰器模式在 JDK 里的教科书级应用,Java IO 的设计者让每个流类只负责一项能力,你要什么功能就往上叠加什么功能,而不是为每种组合写一个独立类。
试想一下,如果不这么设计会怎样?一个带缓冲的、能读基本类型的、从文件读入的输入流,就得单独写一个 BufferedDataFileInputStream。再乘上各种排列组合,类数量会爆炸式增长。而使用装饰器模式后,FileInputStream 负责读取文件字节,BufferedInputStream 负责加缓冲区,DataInputStream 负责解析基本数据类型——各司其职,自由组合。这就是为什么 JDK 的 io 包下有 60 多个类,但你只需要掌握少数几个核心类,加上理解组合规则,就能应对绝大多数场景。
总之一句话:你不需要背全 java.io 包下所有类,你只需要记住四个家庭——字节输入流、字节输出流、字符输入流、字符输出流,然后理解装饰器如何给他们穿衣服。
2. 字节流与字符流:编码问题的根源与解法
2.1 为什么有了字节流还要搞出字符流
字节流以字节为单位读写数据,1 个字节装 8 位二进制。计算机底层只认字节,文件在磁盘里存的也是字节序列。那么字符流存在的意义是什么?
答案就两个字:编码。你写了个字符串 "你好",存到磁盘上,它是以 UTF-8 编码的字节序列(中文 3 个字节一个字符,正好 6 个字节)。如果拿 InputStream 去读这 6 个字节,你得自己手动拼接、自己判断哪几个字节组成一个汉字,这就太痛苦了。字符流干的事情,就是在字节流的外面套了一层“编码/解码器”,帮你完成字节数组到 char 数组之间的转换,每次 read() 返回的就是一个完整的字符(或字符数组),你不用关心这个字在 UTF-8 下占 3 个字节还是 2 个字节。
所以字符流的本质是:字节流 + 编码表。这个概念要刻在脑子里,它是后面理解乱码问题的钥匙。
看一下这张经典的对应表:
| 输入 | 输出 | 底层单位 | 适用场景 |
|---|---|---|---|
| InputStream | OutputStream | 字节 | 图片、视频、压缩包等二进制文件 |
| Reader | Writer | 字符 | 文本文件、网络传输的文本内容 |
值得一提的是,字符流不是凭空产生的,字母、数字在 ASCII 时代 1 字节确实搞定,但中文、日文、韩文等多字节字符出现后,纯粹的字节操作很难满足文本处理需求。于是 Reader/Writer 体系诞生,换来的代价是字符流不能直接操作二进制,你拿 FileReader 去读一张图片,读出来的内容你会完全不认得,还很可能因为解码失败产生乱码。
2.2 转换流:字节与字符之间的桥梁
InputStreamReader 和 OutputStreamWriter 就是这座桥。很多初学者搞不懂这两个类存在的意义,直到他们在项目中遇到“从网络字节流中读取文本内容”的场景。
当你从 Socket 获取的是 InputStream,内容却是文本时;当你读取的文件编码不是平台默认编码时——你都需要使用转换流。代码长这样:
InputStream in = new FileInputStream("data.txt"); Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8);这里的关键就是把“字节”和“字符”衔接起来,而且还可以指定编码集。读过老项目代码的朋友应该有印象,很多团队直接写new FileReader("xx.txt"),然后出现乱码——问题就出在这里!FileReader 这个类在构造时用的是平台默认编码(在中文 Windows 上通常是 GBK,在 Linux 服务器上通常是 UTF-8),同一个进程换个运行环境,读同一份文件结果完全不同。所以业界约定俗成的规范是:FileReader/FileWriter 老老实实别用,要读取文本文件,一定要走 InputStreamReader + 指定字符集这条路。
同理,字符流往字节流转换时也是这样操作:
Writer writer = new OutputStreamWriter(new FileOutputStream("out.txt"), StandardCharsets.UTF_8);这两种转换流的地位在 io 体系里是很特殊的,它们既属于字符流的体系,又以字节流为基础,是两种体系的粘合剂。
2.3 编码不一致:乱码是怎样一步步产生的
乱码的根因通俗讲就一句话:写入时的编码和读取时的编码对不上。写入端用 UTF-8 编码,"码" 这个字变成了 3 个字节 E7 A0 81;读取端却用 GBK 解码,3 个字节被按 2 字节的宽度拆成了两个乱码字符,于是文章里出现“鐮佷功锛”这类鬼画符。
这里也提醒大家,IO 中有一个非常隐蔽的坑:读取字节流的长度时,如果用到available()方法,它会返回当前流中可读的字节数,但这个数字并不可靠,尤其在网络流上,数据可能是分批到达的。你要是拿这个数字来 new byte[available()] 一次性读完,很容易读不全。当初我在做文件上传功能时踩过这个坑,后来改成循环读取 + ByteArrayOutputStream 收集,才彻底解决。
正确读取文本文件的方式是循环读取,或者是直接用 BufferedReader 的 readLine(),让它处理字节拼接的细节:
BufferedReader reader = new BufferedReader( new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8)); String line; while ((line = reader.readLine()) != null) { // 处理每一行 }3. 缓冲区机制:Buffered 类为什么性能好这么多
3.1 一次磁盘读入 8KB:内存与磁盘速度差距的巨大鸿沟
我们常听说内存比磁盘快很多倍——这个“很多倍”究竟是多少?对于机械硬盘而言,顺序读速度大概每秒 150MB 到 200MB,而内存随机访问速度是几十 GB 每秒,相差上百倍;即便是 SSD,随机小块访问的差距依然明显。更麻烦的是,每次调用系统 IO 接口是要有系统调用开销的,程序从用户态切到内核态,内核完成磁盘读取,再切回来,这个切换本身就有成本。
假设你写了一个最朴素的复制文件代码:
FileInputStream in = new FileInputStream("big.mp4"); FileOutputStream out = new FileOutputStream("copy.mp4"); int b; while ((b = in.read()) != -1) { out.write(b); }这段代码功能完全正确,性能极其糟糕。每读 1 个字节就要触发一次系统调用,复制一个 1GB 的文件,就等于做了上亿次系统调用。这个性能差距用什么概念类比?相当于你从图书馆借书,一本一本地办理借阅手续,而不是推一辆小车一次性借走一个书架。所谓理财上的“批量处理”思维在这里体现得淋漓尽致。
3.2 BufferedInputStream 的缓冲工作细节
BufferedInputStream 做的事情正是“推车借书”。它内部维护了一个默认大小为 8KB(8192 字节)的 byte 数组。当你第一次调用 read() 时,它会一次性从底层输入流中读取 8192 字节到内存数组里,然后返回给你第一个字节。你继续 read(),它直接从内存数组中返回第 2、3、4 个字节……直到数组里的数据被读完了,它才会再跑一次系统调用,继续批量补充数据。
这样算下来,读取 1GB 文件,系统调用次数从十亿次降到了约 13 万次(1GB / 8KB = 131072 次)。性能差距是数量级的,实测读文件速度快几十倍是很正常的事情。
输出流也是同样的逻辑。BufferedOutputStream 内部维护一个缓冲区,你 write() 的字节先塞进内存数组,攒到 8192 字节一次性刷入磁盘。少数情况是,你写完大量数据后,需要调用 flush() 方法把缓冲区中剩余的数据压出去,否则数据会滞留在内存缓冲区里。close() 方法内部会自动 flush,但如果你要立即读到数据(比如先写再读),就必须手动 flush。
我的经验是,凡是需要高频、大批量、逐字节操作的 IO 场景,一律在外面包上缓冲流。尤其是网络流,网络是一个天然后进先出、分块传输的介质,不包缓冲类,每次 read() 都可能等着数据包从对端发过来,延迟和开销都很大。
3.3 BufferedReader 的 readLine 为什么好用
字符流家族中也有对应的缓冲类:BufferedReader 和 BufferedWriter。BufferedReader 有一个独门绝技 readLine(),可以一次性读取一整行字符,不需要你自己拼接字符、处理换行符。它的内部还是那个缓冲数组,只不过这次数组存的是 char 而不是 byte。每次调用 readLine(),它会在缓冲区里扫描换行符(或文件结尾),然后把这一整行内容返回。
有一种说法是“用 BufferedReader 逐行读取文本文件是最优雅的姿势”,我很认同。你既不需要考虑字节缓冲的细节,也不需要处理编码——编码在处理字节到字符的那一层(InputStreamReader)已经解决了,BufferedReader 里面那层缓冲是为了高效逐行读取。责任分离,层层处理,这也是 Java IO 装饰器体系的一个完美体现。
BufferedWriter 也值得拥有,它提供了 newLine() 方法自动拼接系统相关的换行符,可以避免跨平台时换行符不一致的坑。虽然现在“\n”基本通用了,但 Windows 下老编辑器打开还是可能显示成一行,所以我的习惯是每次 write() 一行就调一次 newLine(),省心。
4. 阻塞与 NIO:经典 IO 的天花板
4.1 阻塞 IO 的线程困境
理想情况下,你用上面的文件 IO 技术就能应对 90% 的项目需求了。但在网络编程场景里,经典 IO 会出现一个绕不开的问题——阻塞。
在 Java 传统 Socket 编程中,serverSocket.accept()会阻塞到有客户端连接才返回,inputStream.read()会阻塞到有数据读入才返回。一个线程发出 read() 请求后,就卡死在那了,CPU 虽然在飞快运行,但这个线程无法干别的事。如果一个服务器需要同时服务 1000 个客户端,就得创建 1000 个线程。线程不是免费的,每个线程默认要分配约 1MB 的栈内存(JVM 参数可调),1 万个线程就是 10GB 内存,直接内存爆炸。即便内存撑得住,线程的大量上下文切换也会把 CPU 耗光在调度本身上而不是业务处理上。
这就是 BIO(Blocking IO)的核心矛盾:IO 等待和线程资源绑定在一起,一个线程只能服务一个连接。
4.2 NIO 三件套:Channel(通道)、Buffer(缓冲)、Selector(选择器)
NIO(Non-blocking IO,JDK 1.4 起引入)改变了这个格局,它的核心是三个东西:Channel、Buffer、Selector。
Channel 直译是通道,你可以理解成它是一条双向的管道,既可以读也可以写(FileChannel 比较特殊,只能读写文件,但 SocketChannel 在 TCP 连接上确实是双向的)。注意这里跟经典 IO 的区别:经典流是单向的,而 Channel 是双向的。
Buffer 是 NIO 另一个核心——所有读写操作都是围绕 Buffer 展开的。你要读数据?先把数据从 Channel 读入到 ByteBuffer。你要写数据?先把数据填充到 ByteBuffer,再通过 Channel 写给对方。Buffer 本质上就是一个字节数组,但用四个指针支撑起了读写切换的复杂度:position(当前读写位置)、limit(可读写边界)、capacity(容量)、mark(标记位)。这儿有个高频面试考点:flip()方法是干什么的?它把写模式切换为读模式,具体动作是:limit = position,position = 0,也就是把“写到了哪里”变成“可读到哪里的界限”,然后从头开始读。
Selector 才是 NIO 的灵魂,它做的事情是:一个线程注册多个 Channel,然后调用 select() 阻塞等待,当其中任意一个 Channel 有了就绪事件(可读、可写、有新连接等),select() 返回,然后遍历 SelectedKeys,挨个处理就绪的 Channel。一个线程管理成千上万个连接的网络 IO 事件,这就是 NIO 能支撑高并发的底层原因。
给个直观对比表格:
| 维度 | 传统 IO(BIO) | NIO |
|---|---|---|
| IO 模型 | 阻塞同步 | 非阻塞同步 |
| 流方向 | 单向 | 双向(Channel) |
| 数据载体 | Stream 直接读字节 | Buffer 中转 |
| 连接与线程 | 1 连接 1 线程 | 1 线程管多连接(Selector) |
| 适用场景 | 连接数少、短连接 | 连接数大、长连接 |
4.3 别什么都上 NIO:场景选型的思路
这里我想说点务实的。NIO 确实强,但它的复杂度也是实打实的。如果你用原生 NIO 写一个 HTTP 服务器,光是处理 ByteBuffer 读写切换、半包粘包、网络事件分发这些细节,就够你调试好几个通宵了。所以现实世界中,99% 的工程师不会直接用原生 NIO 做网络编程,而是用 Netty、Mina 这类封装好的高性能网络框架。Netty 把 NIO 的复杂细节处理得妥妥帖帖,你在 Netty 里写业务,只需要关注 ChannelHandler 里的回调逻辑,性能还非常好。
那什么时候用 BIO、什么时候用 NIO 呢?我的个人判断是分情况讨论:
- 传统的短连接、低频请求(如小型管理后台、内部 RPC 的少量调用),BIO 完全够用,代码清晰简单,维护成本低。
- 高并发长连接场景(聊天服务器、IoT 设备接入、消息推送),必须上 NIO 或基于 NIO 的框架。
- 文件读写这种本地 IO,经典 IO 就足够快,NIO 的 FileChannel 也未必有特别大的优势,更高性能的 MapByteBuffer(内存映射文件)倒是值得研究,但那是另一个话题了。
记住一个原则:架构选型要匹配系统规模,别因为“NIO 高端”就强行上 NIO。我曾经见过一个每天请求量只有几百的小系统,因为某同事“为了炫技”上了 Netty,结果出了 bug 没人能看懂,维护成本翻了十几倍,这是典型的过度设计。真正的高手会用最朴素的方案解决 80% 的问题,剩下的 20% 才需要重型武器。
4.4 ByteBuffer 的 flip 与清除操作细节
既然提到 ByteBuffer,这里展开讲三个关键操作:flip()、clear()、compact()。
flip() 是“写转读”,之前提到了。clear() 是“读转写”,它把 position 置为 0、limit 置为 capacity,相当于把整个缓冲区的状态重置为可写状态,但注意 clear() 不会真正清空数据,它只是移动指针,旧数据下次写的时候会被覆盖。compact() 是“读完了准备继续写,但保留没读完的数据”,它会把剩余未读数据往前移动、position 指向剩余数据的末尾、limit 设为 capacity——这样你继续写的时候不会覆盖那些还没处理的数据。
这三个方法在面试里经常被拿出来问,答清楚一件事情就能通关:你在操作字节时,必须始终清楚自己处于写模式还是读模式,以及模式切换时这些指针如何变化。如果只在读模式下继续调用 get(),position 会越过 limit 抛 BufferUnderflowException;如果写模式下忘了 flip() 直接读到 limit,旧的残留数据会被当成新数据读出来。
我个人对初学者的建议是,不要在业务代码里直接调原生 ByteBuffer,封装一层能规范读写顺序的工具类,或者直接依赖 Netty 的 ByteBuf——它在读写切换上比 JDK 的 Buffer 友好得多(天然支持双指针,不再需要手动 flip),安全性高一个层次。
5. 流的生命周期:关闭资源与 try-with-resources
5.1 不关闭流的后果:文件句柄泄漏
弄明白了流的工作原理,接下来是一个看起来很基础、实际翻车率极高的环节:资源关闭。
在你打开了文件流后,操作系统会给进程分配一个文件描述符(file descriptor)。Java 里打开一个 FileInputStream,底层就占用一个 fd。在 Linux 中进程能打开的文件描述符是有限制的(通常是 1024 或更高,可以用 ulimit 查看)。如果你只开不关,fd 被耗尽,再打开新文件就会抛Too many open files异常。
我见过太多因为不关流或者关流姿势不对弄崩服务器的案例了。最典型的是:在循环里 new 流,却不 close,跑了一段时间后系统报错。当然,现代 JVM 的 GC 有 finalize 兜底(文件流实现了 finalize 方法),但 GC 是不可预期的,在高频请求下说不定什么时候就炸了。
所以,把“谁打开、谁关闭”当作一条铁律,一来能保证代码健壮,二来也便于做代码 review。
5.2 try-with-resources 的推荐写法
Java 7 引入了 try-with-resources 语法,这是最省心也最推荐的关闭方式,它保证不管是否抛出异常,资源都会被自动关闭:
try (InputStream in = new FileInputStream("input.txt"); OutputStream out = new FileOutputStream("output.txt")) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } catch (IOException e) { // 统一处理 IO 异常 }这里有两个细节值得注意。
第一,在 try 块中创建多个流时,关闭顺序是从后往前的——后打开的先关闭,这符合资源依赖关系(外层流依赖内层流,先关外层才能保证数据刷盘和释放)。
第二,当 try 块正常执行完后,会自动调用所有资源的 close()。这个机制依赖的是 AutoCloseable 接口。你有没有想过:如果 close() 本身抛异常,而 try 块里也有异常,会怎么样?答案是 try 块里的异常会传递给调用方,close() 抛出的异常会被“抑制”(suppressed),但你可以通过e.getSuppressed()拿到被抑制的异常集合。Java 7 起Throwable类增加了这个特性,不过绝大多数场景你不需要处理它,Java 已经帮你选了对调用方最有价值的那个异常。
5.3 关闭外层流就够了吗:一个最常见的误解
很多人问我:我只关闭最外层的流,内层的 FileInputStream 不关,会不会泄漏?
答案是不会。这个问题的原理是:try-with-resources 中,你在 try 块里声明的变量就是需要关闭的流。如果你只声明了 BufferedInputStream,关闭时候会调用它的 close(),而这个 close() 内部会调用包裹的 FileInputStream 的 close()——装饰器模式在生命周期上也是环环相扣的。所以,你只需要关闭最外层,内层会自动被连带关闭。
但要小心的例外是:如果你自己手动创建的流变量需要单独关闭,比如流被拆分成了多个临时变量,就得逐个关闭。同时,在 try-with-resources 中,如果只声明外层流,内层流在 try 结束后同样会被自动关闭,可以放心。但如果为了捕获流创建过程中的异常,内层流的创建也应该放在 try 之外并考虑到,这种边界情况在代码审查中容易被忽略。
5.4 数据丢失场景:不 flush 就 close 的迷惑行为
还有一个高频坑,我认为必须拿出来单独说:BufferedOutputStream 或 Writer 在 close() 时究竟会不会帮你 flush?
会,不论底层流是谁,close() 方法都会先执行 flush,确保缓冲区中残留的数据全部写入文件或网络。这是 io 包的设计铁律。但问题在于,如果你忘写了 flush(),且把流放在一个较大的 try-with-resources 外面,可能在 close() 之前就有大量数据滞留在缓冲区,如果你中途直接中断了程序或进程崩溃,这部分滞留的数据就永久丢了。
所以,我的个人习惯是:在明确知道“这一批次数据写完了,要继续做其他事情”时,主动调用 flush();在 close() 前,不强迫自己调用 flush(),让 close() 兜底。但你要知道有这一层兜底是正常情况下的,如果流没正常关闭(比如进程被杀、网络断开),缓冲区里的数据就是真没了。
较大文件、远程网络传输时,除了业务上的 flush 之外,更要考虑的是如何保证数据完整性,比如每写一段就落盘一次,或者进行端到端的校验。这些超出 IO 流本身的范畴,但写生产代码时必须考虑。
6. 面试中的 IO 考察点:核心是“为什么”
6.1 高频问题清单与底层应答思路
前端时间在帮忙做技术面试,发现 IO 部分问得最多的就那么几个问题,但很少有人能答出深度。这里我按由浅到深的顺序把高频题和答题要点整理一下:
问题 1:请说说 Java IO 流的分类体系?
回答时不要只罗列字节流/字符流、输入流/输出流,还要把装饰器模式和桥接层的设计一并讲清楚:InputStream/OutputStream 以字节为单位,Reader/Writer 以字符为单位,两者之间通过 InputStreamReader/OutputStreamWriter 连接,BufferedXxx 通过装饰器增强能力。能讲清楚这层“为什么这样设计”,比背出 60 个类名有价值得多。
问题 2:字节流和字符流有什么区别,如何选择?
这个问题的关键点是编码。字符流内部会处理字符集编码,能直接读一个完整的字符;字节流只处理 0/1 字节序列。二进制文件必须用字节流,文本文件优先用字符流,但如果要精细控制编码(比如指定 UTF-8),在字符流外层包一层即可。回答时稍微展开“乱码的根因”,能立刻和普通候选人拉开差距。
问题 3:BufferedInputStream 为什么比 FileInputStream 读得快?
回答的抓手是“减少系统调用次数”:内存缓冲让高频读操作在用户态解决,只有缓冲区空了才触发底层 read(),系统调用次数从每字节一次降为每 8KB 次。要是能继续说到 write 的 flush 时机、磁盘块对齐等细节,面试官基本就满意了。
问题 4:BIO、NIO、AIO 有什么区别?
这是个老生常谈的问题,但很多人只会背“同步阻塞、同步非阻塞、异步非阻塞”这六个字。更好的答法是先说清楚:阻塞/非阻塞关注的是线程在等待数据时是否被挂起;同步/异步关注的是数据就绪后是由应用自己取,还是内核回推给应用。NIO 是同步非阻塞,AIO 是异步非阻塞(Linux 下底层是 epoll 的封装,在 TrueAsync 上实现不完美)。如果能把 1 线程 + Selector 管理万级连接的原理讲清楚,几乎就是标准答案了。
问题 5:谈谈你项目里怎么处理 IO 超时和中断?
这题其实是在考察工程能力。你在 Socket 读数据时设置了 SO_TIMEOUT,超时后 read() 会抛 SocketTimeoutException;你在写大文件时如何及时响应 cancel 信号;你用 Future 包装异步 IO 任务时如何正确中断底层线程。平时没踩过坑的人,很难答得有血有肉。
6.2 从面试官视角:考察的是理解和边界感
我面试候选人时,不会只听他背“BIO 一个线程处理一个连接”,更希望他明确说出每种 IO 模型的取舍和适用边界。可以这么说:我会问你“你的项目真的需要 NIO 吗?”,如果他能从连接数、消息频率、线程开销、开发维护成本几个维度有条理地分析,而不是一味顺着“NIO 更高端”往下说,我会给他很高的评价。
更进一步的加分项是聊到内存映射文件(MappedByteBuffer)、零拷贝(sendfile)、DirectBuffer 与堆外内存。这些是属于 NIO 进阶的知识,一般中小型项目用不到,但能说明你对 IO 性能有较为完整的认知。后面有机会单独写一篇关于 DirectByteBuffer 和零拷贝的文章,这里先埋个伏笔。
6.3 一个通用回答框架:用“流、缓冲、阻塞、资源”四个维度串起来
如果只能给一条面试技巧,那就是把零散知识点串联成一个体系,并给出自己的判断逻辑。我喜欢用一个四步框架来组织回答:
- 流:说清楚数据怎么流动的,单向还是双向,字节还是字符。
- 缓冲:说明缓冲存在的意义是降低系统调用开销,提升吞吐。
- 阻塞:说明阻塞/非阻塞对线程资源模型的影响,这是网络编程选型的核心。
- 资源:强调生命周期管理和异常处理,这体现了代码的健壮性。
用这四个维度,你可以应对大部分 IO 相关问题。比如面试官问“怎么把一个 1GB 的文件高效地从 A 复制到 B”,你可以回答:FileChannel 的 transferTo/transferFrom,或者 BufferedInputStream + BufferedOutputStream 加 8KB 以上缓冲区,然后展开资源管理和异常处理。这样整个回答层次分明,既有技术深度,又有工程意识。
7. 实战复盘:一次文件上传故障的完整排查链路
7.1 现象:小文件正常,大文件总是中断
说一个我真实踩过的坑。前年做了一个文件上传服务,客户端把文件以 multipart 方式 POST 到服务端,服务端把数据流写入本地磁盘。功能上线后一切正常,直到某天用户传一个 1.2GB 的视频文件,传了一半就报Connection reset。
一开始以为是网络问题,换了网络重试依旧如此。后来抓了 tcpdump 发现,连接在某个时间点被服务端强制断开了,而服务端日志里没有任何异常堆栈——因为异常根本没有传播到业务代码里。
7.2 排查过程:从代码到 OS 到网络栈
我首先检查了服务端代码。当时用的是典型的 servlet 接收,然后通过 InputStream 读、OutputStream 写。代码本身没有任何问题,try-with-resources 也用了。然后怀疑是 Tomcat 的连接超时设置或上传大小限制,查了配置,调整了 maxPostSize 到 2GB 还是不行。
接着看系统日志,发现一个关键线索:内核日志里有大量Out of memory: Kill process的痕迹,指向了 Java 进程。这时候才意识到问题可能出在堆外内存和文件描述符上。再深挖,发现我们为了上传性能引入了 NIO 相关的 FileChannel,而且 ByteBuffer 用的是 allocateDirect()(直接缓冲区,分配在堆外)。大文件传输时,DirectBuffer 不断分配和释放,堆外内存碎片化严重,最终导致进程被 OOM Killer 干掉,连接随之被重置。
定位到根因后,方案就很明确了:
- 控制 DirectBuffer 的使用,避免大文件场景下无上限的堆外分配。
- 对大文件改走 FileChannel 的 transferTo(零拷贝),内核直接完成数据搬移,不经过用户态缓冲区。
- 在写入数据时,坚持循环写出并监控写返回值,防止半写导致数据不完整。
这里要补充的是:NIO 的零拷贝并不是万能的。transferTo 在底层走 sendfile 系统调用,适合“文件到 Socket”或“文件到文件”的场景,能显著降低 CPU 和内存消耗。但如果你要对数据做加工(比如加密、压缩、解析格式),必须经过内存,就不能走这条路了。
7.3 复盘提炼:IO 问题排查的三板斧
这个案例给我们的启发值得好好总结——排查 IO 问题时,有三个检查方向是高频出问题的:
- 第一,流有没有正常关闭、异常有没有被吞掉?日志里看不到异常,不代表没有异常,可能异常被 catch 后打印到了别的渠道,或者直接没有打印。
- 第二,缓冲区类型和大小是否匹配场景?DirectBuffer 性能好但内存管理复杂,堆内缓冲区在大块数据时反而更稳定。
- 第三,数据流经的每一层是否有超时限制?网络层(Socket 超时)、应用层(业务超时)、服务容器层(Tomcat 连接超时),任何一层超时都会表现为“连接中断”。
IO 问题往往不是触发在代码那一行,而是跨越了很多层次,需要我们从用户态层层往内核态排查。盲改配置不如先抓包、先看系统日志,工具和证据才是排障的第一语言。
我在排查这一类问题时有一个比较固定的习惯:先用 lsof 看进程持有多少文件描述符,再用 strace 看系统调用是否异常,再结合线程堆栈看当前阻塞在哪一个 IO 点。这套组合拳几乎没有落空的时候。
8. 实操总结与我的推荐体系
8.1 日常编码的 IO 实践清单
根据自己的实战经验,整理了一份 IO 编码自查清单,每次写 IO 代码都拿出来对着过一遍:
- 读文本文件,用
BufferedReader + InputStreamReader + FileInputStream,并显式指定 UTF-8,别用 FileReader、FileWriter。 - 读写二进制文件,用
BufferedInputStream/BufferedOutputStream(或者 FileChannel 的 transferTo),缓冲区推荐 8KB 到 64KB。 - 所有流都用 try-with-resources 管理生命周期,别手写 finally close。
- 网络编程选型时,先评估连接数和消息量,不要盲目上 NIO/Netty。
- 写大数据后注意 flush 的时机,别把数据滞留在内存缓冲区里。
- 一旦发生 IO 异常,日志里要打出流此时的读写位置、字节数、文件路径等上下文信息,便于事后定位。
- 涉及编码的写入,统一在项目层面规定为 UTF-8,避免开发机器和生产环境默认编码不同导致玄学乱码。
8.2 学习路径建议:从会用、到理解、再到能排障
对刚进阶的 Java 工程师来说,建议按这个阶段学习:
第一阶段是会用,也就是 FileInputStream、FileOutputStream、BufferedReader 这几个类的 API 玩熟; 第二阶段是理解设计,看懂装饰器模式、适配器模式在 io 包中的体现,搞懂字符流与字节流的差异; 第三阶段是能排障,能独立处理乱码、文件句柄泄漏、OOM 这类线上事故; 第四阶段才是学 NIO/Netty 的高性能模型。
这个梯度和很多网上流传的“21 天精通 Java IO”完全不一样——前者是螺旋上升、真实成长的节奏,后者只是让你背一堆名词然后自我感觉良好。我自己带团队的时候也遵循这个节奏:新人来了先让他写两周文件工具类,读读写写自然就懂了很多东西;而不是一上来就丢一堆设计模式概念让他去背。
从面试角度看,能把第一、二阶段讲透的人,已经超过了大多数只背 API 的候选人;三、四阶段能力属于加分项,能让你在讨论系统设计时更有底气。
最后分享一个小技巧:准备面试或者自我检测时,不要问自己“我会哪些 IO 类”,而是要问“如果我现在要把一个大文件从磁盘传到另一台服务器,我会怎么做、为什么这么做、性能瓶颈在哪里”。能清晰回答出这个问题的,说明你的 IO 知识已经成体系了。