Java IO 流这块,说起来真是一肚子话。我早些年面试候选人,十个里有八个能把InputStream、OutputStream倒背如流,可真让他们读一个 GB 级别的文件、处理一次乱码、解释为什么要用BufferedReader,立马就露馅。工作里更是常见:线上日志文件写到一半进程挂了,数据丢了;报表导出的 Excel 内存爆了;读取网络流卡死不动了……这些问题追根溯源,全是 IO 流体系没吃透。所以这篇汇总,我不打算罗列 API 文档,而是把我这些年踩过的坑、看过的源码、面试官真正想听的点,一次性给你捋清楚。
这套内容适合谁?准备面试的 Java 工程师、刚入行想夯实基础的新人,以及写了几年 CRUD 但一碰 IO 就心里打鼓的老开发。看完你会明白:IO 流的分类设计到底解决了什么问题,Buffered前缀为什么那么神,内存映射和零拷贝又在什么场景下救你的命,以及那些让你头大的IOException和乱码,根源到底在哪。
1. Java IO 流体系:一张图看懂分类与设计思路
1.1 字节流与字符流的本质区别
IO 流最常见的分法是按数据类型拆成两大阵营:字节流和字符流。字节流的顶层抽象是InputStream和OutputStream,字符流则是Reader和Writer。理解它们的关键,不是背类名,而是搞清楚“今天你处理的是什么资源”。
字节流处理的是二进制数据,图片、视频、压缩包、PDF,这些文件的本质是字节序列,读进来是什么就传什么,不做任何转换。字符流则专门为文本文件设计,它的核心价值在于“字符编码转换”——比如读取 UTF-8 编码的 txt 文件,底层拿到的是一串字节,字符流会按照指定字符集把字节解码成char;写出去时再把char编码成字节。
很多人问:那我用字节流读文本文件行不行?行,但你会遇到边界问题。比如中文在 UTF-8 下占三字节,你用一个 1024 字节的 byte 数组去读,很可能把一个汉字的三个字节切成了两半,再转字符串就出现乱码。字符流内部有缓冲区,并且会做编码解码处理,按字符边界切分,所以读文本优先用字符流。反过来,如果非要用字节流读文本,那就要处理“字节不全”的问题,代码复杂度立马上去。
另外有个细节:Reader和Writer只是抽象类,真正的转换靠InputStreamReader和OutputStreamWriter。它们像一个适配器——左边接字节流,右边出字符流。还记得System.in是字节流,而Scanner能读System.in吗?Scanner底层就用了一个InputStreamReader来做转换。
1.2 节点流与处理流:装饰器模式让功能叠加
再往下分,IO 流按功能角色可以分成节点流和处理流。节点流是直接连接数据源和程序的“水管”,例如FileInputStream直接读文件、ByteArrayInputStream直接读内存数组。处理流则是包装在其他流外面的“净化器”,不直接连数据源,而是给已有流加功能。
举个例子,BufferedInputStream就是一个处理流,它内部维护一个 8192 字节的缓冲区,读数据时一次性从文件搬一大块到内存,之后程序从内存里取,减少系统调用次数。再比如BufferedReader,它在缓冲的基础上还能按行读文本,readLine()方法几乎是所有文本处理程序的标配。
这套设计的精髓是装饰器模式。你可以用一行代码叠加多个处理流:
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8));最内层是节点流,负责打开文件;InputStreamReader负责字节到字符的转换;BufferedReader在最外层,负责缓冲和按行读取。每一层只干一件事,却可以自由组合,这就是为什么你学 IO 时感觉类特别多——不是乱,是刻意设计成这样。
我经常用生活类比解释:节点流像自来水厂的水管,处理流像你家里装的净水器、增压泵。水管解决“水从哪来”,净水器解决“水能不能喝”,增压泵解决“水压够不够”。三层各管一段,互不影响,想换增压泵时不需要把水管也拆了。
明白了这个结构,再看DataInputStream、ObjectInputStream、PushbackInputStream时就不会头皮发麻了。它们都是处理流,各自在字节流之上加了读取基本数据类型、反序列化对象、回退一个字节的能力。面试官常问“为什么 Java IO 类这么多”,答案就是:为了让程序员用叠加的方式组合出符合需求的流,而不是为每个场景硬造一个类。
2. 常用 IO 类使用要点与源码级理解
2.1 文件读写四大基础类
先看最常用的四个东西:FileInputStream、FileOutputStream、FileReader、FileWriter。它们都是直接操作文件的节点流,用法简单到不值一提,但坑一点不少。
FileInputStream的构造函数会检查文件是否存在。如果你传一个不存在的路径,运行时直接抛FileNotFoundException。有人问:new FileInputStream("a.txt")文件不存在为什么不是IOException?因为FileNotFoundException是IOException的子类,它专门表示“文件在这个文件系统里找不到或者没有权限访问”这一种情况。
读文件的标准姿势是开一个 byte 数组当临时仓库,循环读取直到返回 -1。我曾经见过有人把读操作写成while(in.read() != -1),然后里面再读一次?那是在反复横跳,一次读一个字节,程序运行得比拖拉机还慢。正确写法:
byte[] buf = new byte[4096]; int len; while ((len = in.read(buf)) != -1) { // 处理 buf 中前 len 个字节 }注意read(buf)的返回值是实际读到的字节数,最后一次可能不足 buf 长度,处理时必须只看前 len 个字节,不然你会把上次残留的数据一起处理了。
写文件时,FileOutputStream默认会覆盖已有文件。想追加内容就传第二个参数new FileOutputStream("a.txt", true)。这个参数很多人记不住,我心里把它当成“是否在文件末尾接着写”,false就是掀桌子重写,true就是好声好气续写。
至于FileWriter,我的建议是能不用就不用,至少别直接用。因为它默认用平台编码,在 Windows 上是 GBK,在 Linux 上是 UTF-8。同一个程序在两台机器上跑,输出的文件编码不一样,下游系统就炸了。要写文本请用OutputStreamWriter(new FileOutputStream(...), StandardCharsets.UTF_8),把编码主动权攥在自己手里。
2.2 缓冲流为什么能大幅提升性能
很多人知道用BufferedInputStream快,但说不出快在哪。核心原因是它把“多次小读取”变成了“少数大读取”。操作系统每次 read 系统调用都有固定开销,上下文切换、内核态和用户态的数据拷贝,你读一万个小字节就要触发一万次调用;而缓冲流一次性读 8192 字节到内存,后续读的都是内存数据,系统调用次数直接少了几千倍。
源码里,BufferedInputStream维护了一个byte[] buf,read()时如果缓冲区有剩余就直接返回,没剩余才调用底层inputStream.read()重新填满。所以你再调用read()读一个字节时,实际是从内存拿的,并不是真的访问磁盘。
BufferedOutputStream同理,写数据时先攒到缓冲区,攒满了才调用底层流一次刷到磁盘。但这里有个大坑:缓冲区没满时,数据还在内存里,如果你忘记关闭流,或者程序崩溃,最后那些数据就丢了。关闭流的本质就是“把缓冲区里残留的数据刷到目的地”,这一点必须刻在脑子里。
什么时候该用缓冲?凡是频繁读写小数据块的地方都该用。比如文本文件按行读,BufferedReader自带 8192 字符的缓冲,按行读时性能比直接FileReader一个个字符读高出几个数量级。我自己做日志分析时,一个几百 MB 的日志文件,用BufferedReader.readLine()遍历和用FileReader.read()遍历,时间能差出七八倍。
2.3 对象序列化与 Object 流的使用陷阱
序列化是个热门面试点,也是实际项目里最容易出诡异 Bug 的地方。ObjectOutputStream可以把实现了Serializable接口的对象变成字节流,ObjectInputStream再读回来。听起来很美好,但有几个坑是你迟早要踩的。
第一个坑是 serialVersionUID。如果你写了一个类,序列化到文件,后来改了类的成员变量,再反序列化老文件,Java 会抛出InvalidClassException。原因很简单:序列化时会在流里写一个版本号,反序列化时用它和当前类的版本号比对,不一致就拒绝。解决办法是显式声明:
private static final long serialVersionUID = 1L;加上它,类的结构演化就有一定余地,不会因为改了方法或加了兼容字段就反序列化失败。但注意,如果删字段、改字段类型,即使 UID 一样也可能失败,那是没办法的事。
第二个坑是序列化包含不安全的对象。对象流会递归序列化它的所有非transient成员变量。如果成员里有FileInputStream、Thread这种不能序列化的东西,就会抛NotSerializableException。处理办法是把这些字段标记为transient,恢复时再手动重新初始化。
第三个坑是安全漏洞。反序列化时,你是在从字节流中“凭空创建”对象,代码执行了创建对象的所有构造逻辑和字段赋值逻辑。如果这个字节流来自不信任的来源,攻击者可以构造恶意字节码让你执行任意代码。这些年爆出的很多 RCE 漏洞都和反序列化有关。所以,绝对不要对不信任的字节流做反序列化,或者一定要用白名单过滤类。
我个人建议:跨进程传输对象数据时,优先考虑 JSON,关键时刻能救命。序列化带来的对象结构和版本问题让运维痛不欲生,而 JSON 的容忍度高很多。但 JSON 也有性能开销,所以压测环境下,如果对象特别复杂、性能要求极高,再用Protobuf这类二进制序列化方案,那是另一个话题了。
3. 实战中的 IO 性能优化与模型选择
3.1 BIO、NIO、AIO 到底选哪个
IO 模型是面试高频分区,也是实际选型必须面对的决策。先背结论:BIO 是阻塞 IO,NIO 是非阻塞 IO,AIO 是异步 IO。但光背结论没用,你得明白它们解决的是什么问题。
BIO 时代,一个客户端连接进来,服务端就分配一个线程去处理。线程在等待数据时是阻塞的,闲着啥也干不了。连接少没问题,但连接数多了,线程数暴涨,CPU 大量时间花在线程切换上,系统很快就撑不住。经典的 C10K 问题就是这么来的。
NIO 用了一个关键组件:Selector,也叫多路复用器。它像酒店前台,一个前台能接待几百个客人的入住请求,而不用每个客人都配一个专属服务员。NIO 里一个线程注册多个通道,可以通过select()一次性感知哪些通道有数据可读、哪些可以写,然后才去处理它们。这样线程数大幅减少,吞吐量自然上来。
AIO 更进一步,你发起一个读请求,然后什么都不管,等系统读完了内核主动通知你,线程全程不阻塞。Java 7 的AsynchronousChannelGroup就是干这个的,但实际项目里用得很少。为什么?因为 Linux 的异步 IO 支持一直不成熟,很多实现最后还是走了模拟路线。真到高并发场景,NIO + 多线程配合 Netty 这类框架才是主流选择,AIO 反而成了小众选项。
如果项目里只是在本地读文件、写文件,没必要上升到模型级别,老老实实普通流加上缓冲就够了。但如果你是做网络编程、网关、长连接推送,那 NIO 就是必须掌握的底层逻辑。我的经验是:别一上来就写 NIO 代码,先把Selector和ByteBuffer玩明白,再直接学 Netty,这样不会重复造轮子。
3.2 性能杀手:频繁小写与无缓冲
性能优化里有一条黄金法则:尽量减少系统调用次数。这句话在 IO 场景里的体现,就是“能缓冲就缓冲,能一次读多就别一次读一”。
写文件最忌讳的是:
for (int i = 0; i < 100000; i++) { out.write((i + "\n").getBytes()); }每一次write()如果底层没有缓冲,都会触发一次系统调用,十万次调用就是十万次上下文切换。毫秒级到秒级的差距就这么拉开的。解决方案是加一个BufferedOutputStream,或者自己攒一个 ByteArrayOutputStream 然后一次写出。
还有一个容易忽略的点:getBytes()不指定字符集,会使用平台默认编码。同一段代码在 Windows 和 Linux 上生成的字节序列可能不同。如果这个文件要送给别的系统解析,一定要手动指定:
out.write((i + "\n").getBytes(StandardCharsets.UTF_8));读文件也一样,你得知道数据大小时合理分配数组。byte[1024]和byte[8192]的性能差异有时候非常明显。太小了,循环次数多,系统调用多;太大了,内存占用浪费。常规文件读取,4KB 到 64KB 之间是最常用的区间,具体取决于你的磁盘块大小和文件系统策略。
如果你处理的是超大文件,比如 10GB 以上,普通流加数组也会内存吃紧。这时候可以考虑内存映射文件FileChannel.map()。它把文件的一部分直接映射到内存地址,读写非常快,而且不会把整个文件加载进堆内存。我曾经用MappedByteBuffer处理过 18GB 的文本日志,速度比传统 IO 快了近一倍,而且堆内存几乎没有压力。不过映射文件有些边界问题,比如映射区域大小受限于文件大小,写操作不及时刷盘可能丢数据,需要谨慎使用。
3.3 正确的关闭流姿势与 try-with-resources
Java 7 之前,关流是手动的,代码丑得要命,还容易漏。新手最常犯的错是:只在finally里关最外层流,忘了关内层流。虽然内层流关的时候一般也会关外层流,但如果最外层不是自动关的那种,你就会泄露文件句柄。
Java 7 的try-with-resources彻底解决了这个问题。凡实现了AutoCloseable的资源,都可以在 try 括号里声明,无论正常返回还是异常抛出,都会按声明顺序的逆序自动关闭。
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8))) { // 使用 reader } // 无需手动 close这段代码的诱惑力在于,异常处理和关闭逻辑都变透明了。Java 9 之后,你甚至可以直接:
InputStream in = new FileInputStream("a.txt"); try (in) { // 使用 in }注意try (in)要求in是 final 或 effectively final,否则编译报错。
还有些细节:关闭流时如果已经在处理异常,关闭时可能再次抛出异常。在try-with-resources里,这个“关闭时抛的异常”会被压制吗?是的,它会被加为初始异常的 suppressed 异常,你能用e.getSuppressed()拿到。这个知识点面试官偶尔会问,属于那种“用过才知道”的冷知识。
手动关流时代,我还见过一个经典坑:在 finally 里关闭流,但这个流在外层已经关了,再关一次可能抛 IOException,导致原始异常被覆盖。后来我学乖了,干脆把关流也写成工具方法,捕获所有 IOException 并忽略——但最好的解法还是直接用 try-with-resources,从源头消灭这一类问题。
4. 高频面试题与疑难杂症速查
4.1 五个必问的 IO 概念题
我汇总了一下面试里最高频的五个 IO 问题,给个“面试官期待答案”的参考。
第一题:字节流和字符流怎么选?如果是二进制数据,必须用字节流;如果是文本数据,优先用字符流,并且要指定字符集。字符流底层也是字节流,只是多了编码转换层。
第二题:BIO、NIO、AIO有什么区别?从阻塞和非阻塞的角度解释,再补一个场景:连接数少用 BIO 简单直接,连接数多用 NIO 提高线程利用率,AIO 虽然异步但 Linux 生态不成熟,主流方案还是 NIO 框架。
第三题:BIO 和 NIO 的关系是什么?NIO 不是 BIO 的替代品,而是针对高并发网络编程的一种更高效的模型。在本地文件读写上,NIO 的优势并没有那么夸张,甚至文件不大时普通流更快。
第四题:InputStream和Reader有什么区别?前者是字节输入流的父类,后者是字符输入流的父类。Reader自带编码转换,InputStream直接读原始字节。两者可以通过InputStreamReader转换。
第五题:BufferedReader和前Reader的区别?关键在于内部缓冲区。BufferedReader可以减少底层读操作次数,同时提供readLine()这种按行读取的便捷方法,在读取大文本时性能差距非常明显。
这些问题看着基础,但背后考察的是你有没有真正理解 IO 的本质。光背定义回答得出,但说不出场景和取舍,很难让面试官满意。
4.2 实际开发中遇到的 IO 异常处理
接下来聊几个真实开发中会遇到的 IO 异常,每个都值得单独记住。
FileNotFoundException:文件不存在、路径是个目录、没有访问权限都会抛。注意,目录也可能是这个异常,很多人没想到。判断逻辑里最好先Files.exists()或Files.isRegularFile()做预检查,而不是直接让异常炸出来。
SocketException: Socket closed:网络编程里很常见。你调用了socket.close()后,又尝试读写流,就会抛它。排查思路很简单——检查是不是有线程在 close,另一个线程还在读。
SocketTimeoutException:设置过setSoTimeout后,read()超过指定时间没有数据到达就会抛。处理时不能简单当成连接关闭,要根据业务场景决定重试还是断开。有时候是对方服务慢了,直接断开会让在线率雪崩。
StreamCorruptedException:读对象流时发现格式不对,通常是你用ObjectInputStream读一个根本不是序列化数据的文件,或者序列化版本不匹配、数据被篡改。这种最好加一层校验字节头,提前过滤脏数据。
UTFDataFormatException:写字符串到DataOutputStream时,字符串字节长度超过 65535,就会抛。这个限制来自 Java 序列化协议中字符串长度字段的长度。我曾经导出大批量 JSON 时踩过这个坑,后来改成单条写入和分批拼接,绕开了这个限制。
异常处理的核心哲学是:明确哪些异常需要捕获并处理,哪些应该抛给调用方。IO 异常基本都是受检异常,强制你处理,但错误的“处理”就是 catch 后打个 log 继续往下跑。尤其是写文件失败,如果不检查,你流程上是成功的,但数据没落盘,后果很严重。我的建议是:读写文件这种关键路径,捕获后必须重新抛出包装成业务异常,而不是默默吞掉。
4.3 编码问题:乱码到底是谁的锅
乱码问题,十有八九是字符集不一致。文件用 UTF-8 写,你用 GBK 读,自然得出天书。解决乱码的唯一思路是“必须知道源头是什么编码,然后指定同一个解码”。
读取时你可以在InputStreamReader上显式传Charset:
new InputStreamReader(in, StandardCharsets.UTF_8)注意,StandardCharsets.UTF_8是常量,不会受环境编码影响。有些人写成"UTF-8",代码里全是魔法字符串,一旦拼错就静默使用了默认编码,更难排查。
写入时同样指定编码,并且在网页场景里注意响应头的Content-Type。我见过一个前后端联调坑:后端输出字符串到HttpServletResponse时忘了设置Content-Encoding,前端声明 UTF-8,结果后端按 ISO-8859-1 发的,最终乱码。排查了好久,最后定位到是PrintWriter默认用平台编码导致的。
另一个编码相关的细节是 BOM。UTF-8 文件可能带一个EF BB BF的 BOM 头,某些解析器不会自动跳过,导致你读到的第一个字符是\uFEFF,然后字符串比对就莫名其妙失败。处理办法是读数据后手动跳过前三个字节,或者用支持 BOM 的流包装。
说到FileReader为什么被我反复黑,这就是原因。你没法给FileReader指定编码,它内部会调用底层平台默认的Charset。所以任何需要跨平台运行的服务,都不要直接用FileReader和FileWriter,一律通过InputStreamReader和OutputStreamWriter显式指定字符集。
5. 进阶与扩展:NIO、内存映射与零拷贝
5.1 用 FileChannel 高效读写文件
聊完基础,必须提一下java.nio包里的FileChannel。如果你对性能有追求,它比基础流更加硬核。FileChannel是连接到文件的通道,支持读写、位置定位、强制刷盘、内存映射等操作。
最简单的高效读法:
try (FileChannel channel = FileChannel.open(Paths.get("big.txt"), StandardOpenOption.READ)) { ByteBuffer buf = ByteBuffer.allocate(8192); while (channel.read(buf) != -1) { buf.flip(); // 处理 buf buf.clear(); } }这里最反直觉的是flip()和clear()。flip()是把写模式切换成读模式,position 归零,limit 设为之前写的位置;clear()是重新变成写模式,但不会真的清空数据。这两兄弟搞反了,你读到的全是空白或者越界。
FileChannel还有一个杀手级功能:直接把两个通道接通,用于零拷贝传输文件。传统 IO 拷贝文件要走“内核读缓冲区 → 应用缓冲 → 内核写缓冲区 → 磁盘”四步,零拷贝让数据在内核态内部搬移,不经过用户态,能省不少 CPU 和内存。Java 里最简写法就是用transferTo或transferFrom。我曾经优化一个文件上传服务,仅把FileOutputStream换成channel.transferTo(),整个服务 CPU 占用从 80% 降到 15%,效果立竿见影。
5.2 MappedByteBuffer 内存映射的正确姿势
内存映射是 NIO 提供的另一个大杀器。调用channel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize)会把文件区域映射为内存中的MappedByteBuffer,之后你可以像操作字节数组一样访问文件内容。它的优势是读取速度非常快,而且不会把整个文件加载进 JVM 堆。
使用要点有几个。第一,映射区大小受限于文件大小,如果文件很大,你需要分块映射。第二,MappedByteBuffer没有close()方法,释放依赖 GC 和文件关闭,在高频场景下可能耗时较长。第三,写模式下数据先写入内存,最终刷盘时机由操作系统控制,在你需要确保数据落盘时,得调用MappedByteBuffer.force()。
我通常用内存映射处理大量行数据的分析,比如按行解析一个大 CSV。用传统BufferedReader边读边解析几百 MB 的文件,Heap 区域波动很大;改用MappedByteBuffer后堆内存几乎不变,速度还快。但注意,解析时你要自己做行切分,不能用现成的readLine,代码量会涨不少,所以这叫“权衡后的大杀器”,不是无脑标配。
5.3 零拷贝在实际项目里的应用边界
“零拷贝”这个概念这两年特别火,但初学者很容易误解成所有 IO 都能零拷贝。它特指在网络传输场景,把数据从文件直接送到网卡,不经过用户态缓冲。Java 里最典型的就是FileChannel.transferTo(),底层在 Linux 上会调用sendfile系统调用。
但零拷贝不是银弹。它要求目标通道最好是网络通道而非普通文件,且对文件系统、平台支持有要求。有些平台实现sendfile不完整,最终可能退化成普通拷贝。所以你要用可以,但测试要覆盖你真实运行的环境,不能只看书上的理论。
另外,MappedByteBuffer算不算零拷贝?算,但没有完全零。它减少了堆内拷贝,但内核和设备之间可能还有拷贝路径。真正的零拷贝是 CPU 不参与数据搬移,让 DMA 控制器直接搞定。我们平常讨论的零拷贝大多是“减少的一次用户态拷贝”,理解到这个层面已经够用。
就我经验,业务系统里遇到大文件网络传输,优先用transferTo,简单可靠,比手写分块 reader 性能好得多。而内部分析计算,优先用MappedByteBuffer。两个技术的边界弄清楚后,你的代码质量会有质的提升。
6. 日常开发中最容易忽略的 IO 细节
最后聊几个我平时帮人 review 代码时经常抓到的细节问题,这些坑也许不大,但每一个都真实让项目吃过亏。
第一,流之后不要忘了 flush。尤其是那些带缓冲的Writer,比如BufferedWriter、PrintWriter,它们内部数据都存在缓冲,如果不调用flush()或close(),最后一批数据可能永远到不了目的地。很多人写PrintWriter,写完忘了 close,结果日志文件居然是空的,排查半天。
第二,文件读写别用绝对路径硬编码。代码里写死C:\\temp\\data.txt或者/Users/xxx/data.txt,换一个部署环境直接跑不通。好的做法是把路径放到配置文件里,或者用相对路径配合系统属性。环境内置变量用一次就明白,尤其user.dir和java.io.tmpdir这两个。
第三,小心read()返回值的含义。InputStream.read()返回单字节的 int 值,范围 0~255,如果流结束则返回 -1。很多人把它直接当成 char 用,碰到非 ASCII 字符就错得离谱。正确转法是用(char) b或者直接读byte[]再编码解码。
第四,文件复制别用循环 one-byte。某次有人写文件复制,逐个字节read()/write(),1GB 文件跑了二十分钟。我改成了byte[] buf循环之后,秒完。这就是最典型的“不会缓冲”案例。
第五,网络 IO 一定要设置超时。InputStream.read()没有数据时会一直阻塞,如果对端掉线且 TCP keepalive 没配置,这个阻塞可能持续到天荒地老。给Socket设置setSoTimeout,读超时后抛异常,你再判断重试还是断连。这是我线上事故里得出的血泪教训。
以上这些细节点,比任何 API 手册都更能检验你是不是真的“用过” IO 流。写代码不是把 API 背下来,而是把每个参数、每次返回值、每种异常的具体行为都当成可能出问题的地方,逐个检查,这样才能写出稳定的代码。
说实话,Java IO 流从来不是背一背就能掌握的东西,它更像一个工具箱:你得知道每个工具的形状,用过才知道力道,还得遇到合适场景才想起它。这套汇总涵盖了分类体系、常用类细节、性能优化、面试考点和踩坑实录,希望你在项目里遇到 IO 问题时,能少走几步弯路。这也是我把它完整分享出来的原因。