Java IO 是学习 Java 时绕不过去的一道坎。我最早被它搞懵是因为类实在太多了InputStream、BufferedInputStream、InputStreamReader、FileReader、FileInputStream……名字长得像兄弟各自身世又不一样背了两天就混。后来做了几年后端天天跟文件上传、日志读写、网络报文打交道再回头看“Java IO”这种基础题目反而觉得它比很多高深框架更值得静下心梳理。这篇文章就给你一条主线先搞懂 IO 体系里的四梁八柱字节流、字符流、缓冲流、NIO再把最常见的编码问题、性能问题、面试题串一遍。文章里的代码都是可以直接复制运行的比喻也都是我平时给别人讲课时用的。适合三类人看准备 Java 面试的求职者、刚学完语法想进阶的新手、以及想搞懂框架底层 IO 机制的老开发。顺手纠正一个容易对错号的地方Java 里的 IO 是 Input/Output 输入输出流不是单片机、FPGA 上那种 GPIO 的“IO 口”如果你是从硬件资料那边搜过来的别对错号。1. 先记住整体脉络Java IO 由哪些核心组件组成很多人学 IO 最大的问题不是学不会而是没有地图感。一上来就撞上十几个类谁继承谁、谁包谁、谁装饰谁全部糊成一团。其实 Java IO 的体系非常规整记住两个维度就能把绝大多数类归位一个是方向一个是数据形态。1.1 输入输出方向以程序为中心看待一切先解决最容易犯迷糊的方向问题。Java 里判断输入还是输出永远以程序本身为参照物不是以文件、网络另一端为参照物。程序从外部拿数据进来叫输入流Input程序把数据送出去叫输出流Output。拆成动作就是读文件用的 InputStream/Reader写文件用的 OutputStream/Writer。很多人写文件拷贝时明明是从文件 a 读到程序再把数据从程序写到文件 b结果代码里两个流全用成 InputStream一运行就报空指针或者生成一个空文件。我习惯用一个仓库比喻程序是仓库输入是进货输出是发货。进货得有人去港口接货发货得有人把货拉到码头。Java 的命名也很直白凡是读相关的方法名会是read写相关的会是write不会出现“从输出流里读数据”这种反直觉操作。顺带一提我看到很多做硬件相关的同学搜“io口输入”也会找到这类文章Java IO 里没有引脚、没有电平、没有上拉下拉那一套属于嵌入式范畴别混在一起学。1.2 数据形态字节流与字符流的二元划分第二个维度是数据的“长相”。文件、图片、压缩包、网络报文底层都是二进制字节Java 用byte表示对应字节流文本文件、JSON、日志、配置文件里面是一个个字符Java 用char表示对应字符流。这套设计引出了 IO 家族的“四大家族”数据形态读方向抽象类写方向抽象类典型文件类字节InputStreamOutputStreamFileInputStream / FileOutputStream字符ReaderWriterFileReader / FileWriter四大家族的顶层都是抽象类真正干活的是它们的子类。FileInputStream是连接文件与程序的“节点流”负责老老实实从磁盘搬字节FileReader则是面向文本的“节点流”内部其实也依赖字节流只是多了一层编码转换。字符流不能凭空产生它底层仍然走字节只不过在字节和字符之间加了解码层。把这条路想通后面讲InputStreamReader桥接流的时候你就秒懂。1.3 组合设计用一个共性清单记住所有流Java IO 的类多但组合规则异常统一核心就是装饰器模式。你可以把一个流理解成一根水管FileInputStream是接在水源上的裸水管只能出最原始的字节BufferedInputStream是给水管加了个水箱先把水囤一波再放出来减少来回跑路InputStreamReader是把水流从“字节水”加工成“字符水”的转接头BufferedReader又在这个基础上加了个缓存池还能按行读取。实际写法就是把它们一层层套起来BufferedReader reader new BufferedReader( new InputStreamReader( new FileInputStream(config.txt), StandardCharsets.UTF_8 ) );这一行代码里有四个类各司其职。记住三个规律以Stream结尾的是字节流以Reader/Writer结尾的是字符流。以File开头的是节点流直接连接数据源。以Buffered开头的是处理流负责增强性能不能单独存在必须包在别的流上。几乎所有流都实现了Closeable接口可以放进 try-with-resources 自动关闭。2. 字节流与字符流什么时候用谁编码角色如何分工这一节是 IO 基础中的基础。很多人写第一个文件复制程序时用的是字节流写第一个中文读取程序时用字符流但从来没想过底层发生了什么。我把两套流拆开讲字节流解决“有没有数据”的问题字符流解决“数据变成什么文字”的问题。2.1 字节流文件拷贝最底层的玩法看一段最标准的文件复制代码try (FileInputStream in new FileInputStream(source.bin); FileOutputStream out new FileOutputStream(target.bin)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }这里有三个关键点新手往往栽在上面。第一read(byte[])的返回值是实际读到的字节数不是缓冲区长度。最后一次读取时文件剩余数据可能不足 8192 字节这时返回比如 100你必须只写这 100 字节。如果图省事直接out.write(buffer)就会把缓冲区的空白部分也写进去生成的文件尾部会多出一堆无意义字节。这是我见过最常见的“看起来没问题但文件全坏”的写法。第二缓冲区大小选多少合适JDK 里BufferedInputStream默认缓冲区就是 8192 字节8KB这也是很多框架采用 8KB 作为标准块大小的原因。你完全可以用 1KB、64KB但对普通磁盘来说 8KB 到 64KB 差距不大再大反而可能增加内存压力。原则是有缓冲就行别一艘字节船去运货。第三为什么不直接用in.read()一次一个字节因为每次一个字节的读法会触发极频繁的系统调用相当于你要去银行办 100 次业务只取 1 块钱光排队就排死。缓冲区相当于批量转账一次性把 8192 块钱拿到手性能可以差两个数量级。想感受对比的话拿一个 100MB 文件分别用单字节读和缓冲读跑一遍用System.currentTimeMillis()计时结果会非常直观。2.2 字符流编码问题如何被解决字节流本身不关心“文字”它只搬运byte。而一个英文单词、一个汉字在磁盘上是怎样的排列完全是编码规则说了算。常见 UTF-8 编码下一个汉字占 3 个字节比如“中”对应E4 B8 AD如果程序按字节切割恰好把一个字的 3 个字节切到不同缓冲区里再各自尝试解码就会出来一堆乱码。字符流解决的就是这件事InputStreamReader负责把“字节流”按照指定字符集解成一枚枚完整的字符你再用BufferedReader.readLine()按行读就不会出现半个汉字的问题。标准写法try (BufferedReader reader new BufferedReader( new InputStreamReader( new FileInputStream(note.txt), StandardCharsets.UTF_8 ))) { String line; while ((line reader.readLine()) ! null) { // 处理每一行 } }这里有个非常容易踩的坑FileReader在 JDK 8 及以前不能指定字符集默认使用操作系统的平台编码。Windows 上默认可能是 GBKLinux 上通常是 UTF-8同一份代码换个机器跑就乱码。JDK 11 开始给 FileReader 增加了带 Charset 的构造器但在老项目里还是建议用InputStreamReader手动包一层显式声明 UTF-8。从 Java 18 开始UTF-8 成为 Java 默认字符集但显式指定依然是更稳妥的职业习惯。2.3 选型规则字节流还是字符流附决策表判断标准其实一句话就能说清这个文件或者数据流你用记事本能看懂吗能看懂说明是文本用字符流看不懂比如图片、音视频、压缩包、class 文件用字节流。网络传输的数据往往在底层是字节流但如果你确定对方发的是 JSON 文本就可以包一层字符流转成字符串再解析。场景推荐方案原因图片、音视频、zip、class 文件FileInputStream / FileOutputStream本质是二进制不能按字符解码配置文件、日志、JSON 文本BufferedReader InputStreamReader(UTF-8)需要正确处理编码按行读效率高网络 socket 上的报文先字节流接收再按协议转字符兼容二进制和文本混合内容大文件逐行处理BufferedReader内存占用小按行加载需要随机修改文件某段RandomAccessFile可以定位游标读写把形态选对80% 的 IO 代码就不会跑偏。剩下 20% 是关于性能和更好的工具类下面一节展开谈。3. 装饰器与高级流缓冲、二进制、序列化的性能与实战基础流只能解决“能读写”但实际工程里我们还要性能、要便利。Java 的做法不是做一个万能流而是用装饰器把能力一层层叠上去。这一节讲的缓冲流、数据流、对象流都是你在代码里高频使用的处理流。3.1 缓冲流用 8KB 默认缓冲区减少系统调用前面已经说过缓冲流是一次性搬一批数据它的好处是既保留了字节流的灵活性又不用自己手写 byte[] 缓冲区try (BufferedInputStream bis new BufferedInputStream( new FileInputStream(large.zip)); BufferedOutputStream bos new BufferedOutputStream( new FileOutputStream(large_copy.zip))) { byte[] buf new byte[8192]; int len; while ((len bis.read(buf)) ! -1) { bos.write(buf, 0, len); } }BufferedInputStream内部有一个默认 8192 字节的缓冲数组它从底层文件一次性读入 8KB你每次调用 read 其实是从内存缓冲里取只有缓冲耗尽才真正访问磁盘。BufferedOutputStream同理写完先堆积在内存缓冲里凑够 8KB 再一次刷盘大幅减少磁盘 I/O 次数。这里有一个必须警惕的问题带缓冲的输出流数据不一定马上落盘。如果你写完了文件但不调用close()也不调用flush()程序可能表现得一切正常重启后却发现文件内容是空的或者缺了最后一段。原理是缓冲区里还有一批没刷出去的数据。规则很简单用完流一定要关close()会先自动flush()再释放资源如果还想继续写就主动flush()。3.2 数据流与对象流二进制数据的读写利器如果你要写自定义文件格式、网络包、游戏存档就需要精确控制每个字段占几个字节这时字符流反而不合适。比如整数 123456用文本写出来是“123456”六个字符用DataOutputStream.writeInt()写固定 4 字节紧凑而且读取时能精确还原。try (DataOutputStream dos new DataOutputStream( new FileOutputStream(data.bin))) { dos.writeInt(123456); dos.writeUTF(hello); } try (DataInputStream dis new DataInputStream( new FileInputStream(data.bin))) { int i dis.readInt(); String s dis.readUTF(); }注意writeUTF/readUTF是一对它用的是改良版 UTF-8 编码能自描述长度读取时才知道这个字符串占多少字节。有人图方便把writeUTF和readUTF写成不同顺序或者拿writeBytes去配readUTF读出来全乱这类低级错误我见过不止一次。对象流ObjectInputStream/ObjectOutputStream更进一步可以把整个 Java 对象序列化成字节流反序列化时恢复成对象。要求类实现Serializable接口并且最好显式声明serialVersionUID。如果不写编译器会按类结构生成一个 ID之后你给类加一个字段老数据反序列化就会抛InvalidClassException因为 ID 对不上了。显式声明等于给数据格式一个稳定的版本号。不想序列化的敏感字段用transient修饰静态字段本身不参与序列化。但我要说一句经验之谈Java 原生序列化不适合做跨语言接口或长期存储。它绑定 Java 类结构改动字段容易不兼容而且反序列化历史上出过不少安全漏洞。现在团队之间对接优先用 JSON、Protobuf 这类更可控的格式Java 原生对象流适合做临时缓存、本地快速存档。3.3 桥接流与其他常用流打通字节到字符的通道字符流和字节流之间靠InputStreamReader和OutputStreamWriter连接它们就是桥。字节从磁盘进来经过桥上的字符集解码器变成字符字符写出去经过编码器变成字节落盘。Writer writer new OutputStreamWriter( new FileOutputStream(out.txt), StandardCharsets.UTF_8 );为什么必须把编码参数写出来因为桥上不指定编码就退回平台默认字符集。同一个 jar 包放到不同系统写的文件编码可能不同下游解析的人早晚会找你。另外顺带说两个高频出现的类PrintStream和PrintWriter。System.out就是一个PrintStream它提供print/println/printf这类非常方便的方法PrintWriter是字符流版本行为类似。打印类流的优势是不会抛 IOException对用户友好但代价是你无法得知写失败。日常调试用System.out.println没问题正式项目的日志还是交给 Logback、Log4j2 这类框架不要在业务代码里到处 println。其他几个小众但面试偶有涉及的流比如PushbackInputStream读了一个字节还能推回去、SequenceInputStream把多个输入流串起来当做一个流、LineNumberReader按行读并知道行号了解用途即可实际工作中用得少。4. 文件随机访问与 NIO高性能 IO 和网络编程的地基到了这里基础用法已经够应付日常开发。但一旦涉及大文件处理、高并发网络服务传统读写流就不够看了。Java 提供了两条进阶路线一个是面向文件的RandomAccessFile一个是面向通道和缓冲区的 NIO。4.1 RandomAccessFile像指针一样定位读写RandomAccessFile最大的特点是可以移动读写指针在文件的任意位置读、写数据而不是像流一样只能从头到尾顺序读写。try (RandomAccessFile raf new RandomAccessFile(record.dat, rw)) { raf.seek(1024); int value raf.readInt(); System.out.println(value); raf.seek(2048); raf.writeInt(999); }“rw”表示读写模式还有“r”只读、“rws/rwd”强制写入磁盘等模式。seek(long)把指针移到指定字节位置getFilePointer()返回当前位置length()返回文件长度。经典应用场景是断点续传下载了一半记录当前指针位置下次从中断处继续写。但要注意一个常见误解RandomAccessFile不能真正“插入”数据。如果你在文件中间写内容它只会覆盖原来位置上的字节不会自动把后面的内容往后推。要实现插入你得自己把后段数据读出来写完新内容再拼接回去。另外这个类并没有继承InputStream/OutputStream它实现了DataInput/DataOutput所以叫“随机访问文件”而非“流”别用流的思维去套它。4.2 NIO 三件套Channel、Buffer 与 SelectorNIONew IO是 Java 面向高性能 IO 设计的另一套体系核心是三个组件Channel通道、Buffer缓冲区、Selector选择器。用物流园区来类比Buffer 是货车车厢Channel 是连接货物区和目的地的车道Selector 是园区调度中心。传统 IO 是一条线程对应一条车道车多了就堵NIO 可以用一个调度中心同时盯着几百条车道哪条车道有货要卸调度中心就派线程过去处理没有货的车道不用占用线程。try (FileChannel in FileChannel.open(Paths.get(big.zip)); FileChannel out FileChannel.open(Paths.get(big_copy.zip), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { in.transferTo(0, in.size(), out); }这段代码用transferTo实现了文件复制底层可能走系统级零拷贝数据不用在用户态和内核态之间来回倒腾大文件复制时性能优势明显。再说MappedByteBuffer也就是内存映射文件它把文件的一段区域映射到进程内存地址空间读文件就像读数组一样直接。配合FileChannel.map()使用适合超大文件的局部读写。缺点是占用地址空间用完要注意释放逻辑上也增加了排查复杂度业务场景没有明确收益时我不推荐随手用。到这里必须先泼一盆冷水普通文件复制用 NIO 不一定比 Buffered 流快尤其是小文件NIO 的初始化成本反倒可能更高。NIO 的真正主场在网络通信。SocketChannelSelector让一个线程同时管理成千上万个连接这才是它存在的意义。4.3 BIO/NIO/AIO 与 Redis 的 IO 多路复用对比面试里最常出现的 IO 三兄弟是BIO阻塞 IO、NIO非阻塞 IO常指多路复用 IO、AIO异步非阻塞 IO。很多人背了定义还是说不清我建议用一个点外卖的场景串起来BIO下单后你站在窗口前干等菜不到人不走期间什么也干不了——同步且阻塞。NIO下单后你回去打游戏每隔一会儿去窗口看一眼菜好了没——同步自己检查结果但非阻塞。AIO下单后你继续玩后厨做好直接打电话通知你取餐——异步非阻塞结果由系统主动通知。对应到代码层面Java 的 NIO 用Selector实现的是IO 多路复用一个线程注册很多通道通过select()监控哪些通道有事件就绪再集中处理。AIO 则是 JDK 7 开始提供的AsynchronousServerSocketChannel把 IO 结果通过回调函数返回但由于在部分平台上的底层支持和生态成熟度问题实际项目里远不如 NIO 普及。顺便提一个被问烂的场景Redis 的线程 IO 模型。Redis 虽然对外是单线程执行命令但它底层用的是 epoll 多路复用能够用单线程同时监听大量客户端连接的事件有请求才处理没有请求就去干别的。这和 Java NIO 的思想是相通的——把“等待事件”从“业务线程”中分离出来线程数是有限的连接数却可以成千上万靠的就是多路复用。模型阻塞线程模型适用场景BIO阻塞一个连接一个线程连接数少、逻辑简单NIO非阻塞一个线程管理多连接高并发网络服务、网关AIO非阻塞异步回调连接数极大且需要异步通知的场景5. 高频面试题、经典坑与算法题 IO 读写模板基础讲完最后落回实战。这一节我整理了三类内容面试容易卡壳的概念辨析、日常开发最容易踩的坑、以及算法竞赛场景下怎么写出快得多的 IO 模板。5.1 还可能被问到的四组概念辨析第一组是“字节流 vs 字符流”。一句话版本字节流处理一切二进制字符流处理文本且负责编码解码字符流底层也依赖字节流。第二组是“阻塞 vs 非阻塞”。阻塞是发起 IO 后线程一直等到数据就绪才返回非阻塞是发起后立刻返回线程可以先去干别的之后再回来检查。第三组是“同步 vs 异步”。同步是指结果由调用方主动去取异步是指结果由系统或者回调机制送上门。注意同步/异步描述的是结果获取机制阻塞/非阻塞描述的是等待时的线程状态两者不是一回事。NIO 可以做成同步非阻塞AIO 是异步非阻塞。第四组是“IO 密集 vs CPU 密集”。它不属于 IO 体系本身但调优线程池时必被问。CPU 密集任务线程数建议接近 CPU 核心数IO 密集任务线程大部分时间在等待 IO可以开更多线程经验公式是 核心数 / (1 - 阻塞系数)。比如 8 核机器、阻塞系数约 0.8线程数约 40 个。阻塞系数的测算比较难实际项目常靠压测调整。面试时别背定义把点外卖的例子用自己的话讲出来考官一般就会点头。5.2 必须避开的五个典型 IO 坑第一个坑忘关流。文件句柄是稀缺资源不 close 会一直占着Windows 上甚至出现文件被占用无法删除。解决方式很简单任何打开流的代码都放进 try-with-resources或者使用 try-finally 确保关闭。第二个坑编码混乱。一台机器上开发好好的部署到另一台机器乱码。原因多半是用了不带 Charset 的FileReader/FileWriter或者省略了桥接流的编码参数。统一显式写StandardCharsets.UTF_8这类问题一次根治。第三个坑缓冲输出流不 flush 就结束。我记得有次排查线上日志丢失最后发现代码里用BufferedWriter写完堆叠的数据后没有 close也没有 flush进程被强杀导致缓冲数据全部丢失。凡是用带缓冲的输出类最后必须关流或者手动 flush。第四个坑一次性读入整个文件。新手喜欢写byte[] all new byte[(int) file.length()]; in.read(all);小文件没问题几百 MB 的文件直接栈堆溢出或者内存抖动。正确做法是固定大小缓冲区分批次读写或者按行读。第五个坑对流有先入为主的顺序假设。有的流只能读、有的只能写想同时读写一个文件得分开开两个流或者用RandomAccessFile。还有人写拷贝代码时先关闭输入流再读输出流位置结果数据没写全。流的生命周期管理要整体考虑不能拆得七零八落。5.3 算法与竞赛场景的高效 IO 模板参加蓝桥杯、刷 LeetCode 这种场景很多人被卡住的不是算法本身而是输入输出。Scanner虽然好用但解析速度慢数据量上十万以后就开始拖后腿。竞赛用的经典思路是BufferedReader包InputStreamReader输出用BufferedWriter包OutputStreamWriterBufferedReader reader new BufferedReader(new InputStreamReader(System.in)); BufferedWriter writer new BufferedWriter(new OutputStreamWriter(System.out)); String line; while ((line reader.readLine()) ! null) { String[] parts line.split( ); // 按题目逻辑处理最后 writer.write(...) } writer.flush();比这更快的还有自实现FastReader直接基于BufferedInputStream自己解析整数核心逻辑就是手动跳过空格、累加数字。这类模板网上很多但我不建议日常业务代码里使用可读性差也不利于维护只有在竞赛考场上它才是和排序算法一样值得提前备好的“脚手架工具”。还有一个技巧如果需要输出大量行尽量把结果拼在StringBuilder里最后一次性写入而不是每循环一次就调用writer.write()。减少 IO 调用次数效果立竿见影。写在最后一点个人的小建议我自己的体会是学 Java IO 别着迷于把每个类都背下来先记住“字节流是底座字符流是加工层缓冲流是性能层NIO 是并发层”遇到具体场景再查不迟。面试的时候把同步异步、阻塞非阻塞那组词讲顺了比背一百个类名管用。真写了几年业务代码之后你会发现IO 的优雅恰恰来自组合设计——它把繁琐的底层差异都藏在一个个流里让我们可以像搭积木一样拼出适合当前场景的读写通道。希望你也能把这堆积木摆得明明白白。
阅读完成 · 觉得有帮助?