
1. 读不完的文件写不完的坑Java IO流到底在解决什么问题很多人学Java IO时会有一个直观感受类太多、继承关系太乱、记不住。FileInputStream、FileReader、BufferedReader、InputStreamReader……光看名字已经头大。但如果你真正在项目里写过文件导出、上报日志、解析上传文件的逻辑就会知道IO流从来不是背几个类名就能搞定的东西。它会以各种姿势在线上折磨你——比如导出报表时内存爆掉、解析CSV时中文乱码、批量读取大文件时整个接口卡死。Java IO流作为所有数据读写的基础设施其核心价值其实就一句话用统一且分层的方式解决外部数据与程序内存之间的双向搬运问题。所谓流本质就是一组有序的数据序列。就像水管里的水一样数据从一个源头流进程序或从程序流向某个目的地。Java把这种模型抽象为四个基类InputStream、OutputStream、Reader、Writer然后通过装饰器模式层层叠加功能。这种设计让IO体系具备了极强的扩展性但代价是初学者很容易迷路。这篇文章我会结合自己写文件处理模块的实战经历把Java IO的核心分类、高频用法、性能陷阱、NIO与经典IO的分水岭都梳理一遍。适合正在准备Java面试的人也适合写代码时对IO操作始终心里没底的开发同学。我个人建议千万别把IO当成会用几个流就行的小知识点。它是所有读写逻辑的地基地基不牢后面处理大数据、搞网络编程、写中间件全都会还债。2. 字节流与字符流的分野为什么FileReader读二进制一定会出事2.1 四个抽象基类撑起整个IO家族Java IO把所有数据读写抽象成四个顶层角色这是入门第一课也是很多人后面混淆概念的根源。InputStream字节输入流负责把外部字节数据读入程序。OutputStream字节输出流负责把程序中的数据以字节形式写出去。Reader字符输入流负责以字符为单位读取文本数据。Writer字符输出流负责以字符为单位写出文本数据。字节流和字符流的本质区别在于数据单元字节流一次处理一个字节8位字符流一次处理一个字符Java中char是16位。你可以这样理解字节流是原封不动搬砖字符流是先解码再搬字符串。所有文件的底层存储都是字节所以字节流是IO体系的基石。任何IO操作最终都要落到字节层面字符流只是在这个基础上额外做了一层字节到字符的编码转换。这就是为什么FileInputStream可以读一切文件而FileReader只适合读文本文件——如果你用FileReader去读一张图片或一个PDF得到的一定是乱码或者流中断的异常。2.2 FileInputStream与FileReader的取舍下面用一段最基础的代码演示两者的差异。假设有一个GBK编码的文本文件test.txt内容是你好Java。// 用FileInputStream读取拿到的是原始字节 try (FileInputStream fis new FileInputStream(test.txt)) { byte[] buffer new byte[1024]; int len; while ((len fis.read(buffer)) ! -1) { System.out.print(new String(buffer, 0, len, GBK)); } }这里手动指定了GBK解码才能正确显示中文。如果不指定会用平台默认编码通常是UTF-8就会乱码。再看FileReadertry (FileReader fr new FileReader(test.txt)) { char[] buffer new char[1024]; int len; while ((len fr.read(buffer)) ! -1) { System.out.print(new String(buffer, 0, len)); } }FileReader内部做的是平台默认编码的解码。如果文件编码和平台默认编码不一致读出来就是乱码。更关键的是FileReader的编码行为在构造时就被锁死了你无法动态指定字符集这是它最让人诟病的一点。所以我自己在项目里几乎不用FileReader直接读文件而是用InputStreamReader配合FileInputStream因为后者可以在构造时显式传入字符集try (InputStreamReader isr new InputStreamReader(new FileInputStream(test.txt), StandardCharsets.UTF_8)) { char[] buffer new char[1024]; int len; while ((len isr.read(buffer)) ! -1) { System.out.print(new String(buffer, 0, len)); } }这看起来多包了一层但换来的是对编码的完全掌控。项目中凡是涉及跨平台文件读取我都建议用这个组合而不是直接用FileReader。2.3 为什么JDK设计者还要保留FileReader既然FileReader这么不灵活为什么不去掉因为对文件编码和平台默认编码一致的场景它确实更简洁而且很多老代码就是这么写的。JDK要保证向下兼容所以FileReader继续存在。但从新项目最佳实践出发我强烈建议绕过它。在处理IO流选型时记住一条最基本的判断准则要处理文本且关心编码就用字符流或转换流要处理图片、视频、压缩包、序列化数据就老老实实用字节流。3. Buffered流和转换流不只为了性能还改变了读写习惯3.1 为什么裸用FileOutputStream写文件会这么慢从FileInputStream直接read到byte[]这种写法性能并不理想。每次read都会触发一次系统调用好比每喝一口水都要走到河边。系统调用是有用户态到内核态切换开销的频繁调用代价很大。BufferedInputStream/ BufferedOutputStream的作用就是在内存里开一块缓冲区缓冲区的默认大小是8192字节JDK实现里是8KB。当缓冲区满了才真正触发一次系统调用这样能把几十次甚至几百次底层IO合并成一次大幅减少内核态切换次数。下面用代码直观对比不加缓冲try (FileInputStream fis new FileInputStream(bigfile.bin); FileOutputStream fos new FileOutputStream(copy.bin)) { byte[] buffer new byte[1024]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } }这段代码的性能瓶颈在于write方法每次写一个1024字节的小块虽然比单字节读写好很多但依然有很多次系统调用。而加上缓冲流之后try (BufferedInputStream bis new BufferedInputStream(new FileInputStream(bigfile.bin)); BufferedOutputStream bos new BufferedOutputStream(new FileOutputStream(copy.bin))) { byte[] buffer new byte[8192]; int len; while ((len bis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } }实测复制一个几百MB的文件不加缓冲和加缓冲的耗时差距可能是两三倍以上文件越大差距越明显。原因就在系统调用次数上缓冲区让底层IO的批量性大大增强。我把这两种写法的差异总结成一个表方便对比对比维度无缓冲流直接读写使用缓冲流使用自定义大byte[]系统调用触发频率每次read/write都触发缓冲区满才触发取决于byte[]大小常见场景耗时较慢最快接近缓冲流代码复杂度最低低只需多包一层中等读文本是否方便不方便有readLine需手动处理换行所以写IO代码时第一个习惯就是涉及到频繁小数据量读写默认包一层缓冲流。这不是优化技巧而是基本素养。3.2 readLine真的好用但要小心跨平台换行BufferedReader提供了readLine()方法按行读取文本这在解析日志、读取配置文件、处理CSV时非常好用。但这里有个我踩过的坑不同操作系统的换行符不一样。Windows是\r\nLinux和macOS是\n老Mac则是\r。readLine()会把行尾的换行符自动去掉但如果你用split或者手动处理字符串时假设了固定换行符跨平台就会出现数据错位。处理办法是读文件时不要自己判断换行符直接依赖readLine()写文件时如果需要拼接多行统一用System.lineSeparator()它会根据操作系统返回对应的换行符。try (BufferedWriter writer new BufferedWriter(new OutputStreamWriter( new FileOutputStream(output.txt), StandardCharsets.UTF_8))) { writer.write(第一行); writer.newLine(); writer.write(第二行); writer.newLine(); }BufferedWriter的newLine()方法底层用的就是System.lineSeparator()写多行文本最好用这个方法而不是手动写\n。这样生成的日志文件在Windows和Linux之间传送时不会因为换行符格式被工具误判。3.3 转换流本质是编码适配器InputStreamReader和OutputStreamWriter常被称为转换流因为它们完成字节与字符之间的双向转换。我理解它们就是编码适配器字节流不懂字符编码字符流不懂字节传输转换流在中间做翻译。实际项目中最常见的运行场景是从某个接口或MQ拿到一批字节数据需要按指定编码解析成文本。这时候用InputStreamReader指定编码就非常顺手。// 从网络输入流读取UTF-8文本 InputStream wireInput socket.getInputStream(); BufferedReader reader new BufferedReader( new InputStreamReader(wireInput, StandardCharsets.UTF_8)); String line reader.readLine();转换流的价值在于它让字节流与字符流两套体系可以自由组合。这也就解释了为什么IO的装饰器模式这么强大——BufferedInputStream可以叠加在FileInputStream上InputStreamReader又可以叠加在BufferedInputStream上能力逐层加强。4. 序列化与数据流对象落盘的艺术与陷阱4.1 ObjectInputStream和ObjectOutputStream对象持久化的门槛Java原生的对象序列化使用ObjectOutputStream将对象写成字节流再用ObjectInputStream读回来。需求场景很常见缓存对象到本地、网络传输对象、Session持久化等。基本用法如下// 序列化对象到文件 try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(user.dat))) { oos.writeObject(user); } // 反序列化 try (ObjectInputStream ois new ObjectInputStream(new FileInputStream(user.dat))) { User user (User) ois.readObject(); }要求被序列化的类必须实现java.io.Serializable接口否则会抛NotSerializableException。这里有几个容易踩的坑。第一个坑是serialVersionUID。如果一个类在序列化后修改了结构比如加了字段、删了方法但没改字段反序列化时会因为版本不一致抛出InvalidClassException。最佳实践是每个实现Serializable的类都显式声明一个serialVersionUID并且修改类结构时如果有兼容性需求就保持该值不变。第二个坑是transient关键字。被transient修饰的字段不会被序列化。比如缓存了某个对象但不想把密码字段写进磁盘就可以用transient。public class User implements Serializable { private static final long serialVersionUID 1L; private String name; private transient String password; // 不参与序列化 }第三个坑是静态字段不参与序列化。static字段属于类级别不属于实例所以序列化时默认忽略。如果反序列化后静态字段的值变了那一定不是你序列化进去的值。4.2 DataInputStream与DataOutputStream处理基础类型的原生方式如果需要往文件里写入多种基础类型数据int、double、boolean、UTF字符串DataOutputStream会按Java规范固定顺序和长度写入保证跨平台可读。DataInputStream按同样规则读回来。try (DataOutputStream dos new DataOutputStream(new FileOutputStream(data.bin))) { dos.writeInt(100); dos.writeUTF(你好Data Stream); dos.writeDouble(3.14159); } try (DataInputStream dis new DataInputStream(new FileInputStream(data.bin))) { int i dis.readInt(); String s dis.readUTF(); double d dis.readDouble(); }需要特别注意的是读写顺序必须一致。writeUTF写入的字符串有一个两字节的前缀表示长度readUTF会根据这个长度读取。如果顺序写反了读到的就是垃圾数据或者EOFException。DataOutputStream读UTF用的是JDK改良过的UTF-8编码与标准的UTF-8略有差异。如果你要和其他非Java程序交换数据尽量别用writeUTF直接设定字符集写入更安全。4.3 序列化框架选型的个人看法Java原生序列化有一个绕不开的问题性能差、体积大、安全性存在隐患反序列化时可能执行恶意类的构造逻辑。所以现代项目里尤其是各系统间做RPC调用时我很少直接用Java原生序列化而是选Jackson、Fastjson、Protobuf这类方案。但如果只是本地简单落盘缓存对象Java原生序列化的简单性依然无法替代不需要引入额外依赖代码只需两三行。我的建议是搞清原生序列化的机制和坑然后在真正需要高性能传输时再切换到更专业的序列化框架。5. 资源关闭与try-with-resources为什么你写的finally块可能是错的5.1 IO流资源泄漏是怎么产生的IO流底层持有操作系统资源比如文件句柄。如果流没关闭文件句柄会一直被占用。在Windows上最明显的表现是文件被锁定删除或重命名时会报文件正在使用。在Linux上文件句柄耗尽则会导致Too many open files异常服务直接不可用。早期的正确做法是在finally块中关闭流FileInputStream fis null; try { fis new FileInputStream(test.txt); // 读取操作 } catch (IOException e) { e.printStackTrace(); } finally { if (fis ! null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }这种代码不仅啰嗦还非常容易出错。很多人会在finally里忘记处理close方法的IOException或者在close之前自己的try块就抛了异常导致finally里的close成了新的异常源头。最尴尬的是当try块和finally块同时抛异常时finally的异常会覆盖try里的异常导致原始错误信息被吞掉。5.2 try-with-resourcesJDK7之后的标准答案从JDK7开始凡是实现了AutoCloseable接口的资源都支持try-with-resources语法IO流的所有实现类天然满足这一条件。用法如下try (FileInputStream fis new FileInputStream(test.txt); BufferedReader reader new BufferedReader(new InputStreamReader(fis))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { // 统一处理 }多个资源按创建顺序依次关闭并且如果try块抛异常关闭资源时抛出的异常会被抑制原始异常不会被覆盖。JDK为Throwable增加了addSuppressed方法专门记录被抑制的异常。我见过很多老项目还在用传统finally写法其实完全可以在新代码里全面切换到try-with-resources。它不是一种新特性而是Java IO代码的默认标准。如果你还在手写close那真的是在用绳子上吊还嫌绳子不够结实。5.3 关闭顺序先内后外还是先外后内try-with-resources中资源是逆序关闭的也就是后创建的先关闭。对于IO流的嵌套包装这个顺序是对的先关外层BufferedReader再关内层FileInputStream。因为外层关闭时可能会flush缓冲数据如果先关内层外层flush就会报错或丢失数据。但如果你还在用老的finally手动关闭一定要记得先关外层再关内层。这个顺序和直觉相反很多人搞反了。之前就有一个同事用自定义包装流在finally里先关内层FileOutputStream再关外层BufferedOutputStream结果缓冲区里最后一段数据没写完生成的文件不完整排查了半天才找到原因。6. 性能与编码之外你还需要懂的NIO世界观6.1 阻塞IO的致命短板经典IOOIO最大的问题是阻塞。当线程执行一个read操作时如果数据还没准备好线程会一直卡在那里什么都不干。这种模型处理少量连接没问题但面对成千上万的并发连接每个连接一个线程线程数量和上下文切换开销会直接把服务拖垮。BIO在《Java网络编程》时代是主流但现代高并发场景下已经很难扛住大规模连接。网上很多文章会说BIO已死NIO当立这个表述有些极端但对多数互联网后端来说NIO确实是更合理的方向。6.2 NIO三大件Channel、Buffer、SelectorNIONon-blocking IOJDK 1.4引入的核心由三个组件支撑Channel通道双向的IO传输通道既可以读也可以写不像InputStream和OutputStream那样单向。Buffer缓冲区数据读写的中转站所有Channel的读写都必须经过Buffer。Selector多路复用器允许一个线程同时管理多个Channel只有当某个Channel有数据可读写时线程才去处理避免空转阻塞。用生活类比的话Channel是高速公路Buffer是服务区Selector是路口交警。高速公路四面八方都通交警只需要盯着哪条路上有车要进出就去指挥一下而不是每条路派一个人蹲着。一个经典的NIO读文件示例try (FileChannel channel FileChannel.open(Paths.get(test.txt), StandardOpenOption.READ)) { ByteBuffer buffer ByteBuffer.allocate(1024); int bytesRead channel.read(buffer); while (bytesRead ! -1) { buffer.flip(); // 切换为读模式 while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } buffer.clear(); // 清空准备再次写入 bytesRead channel.read(buffer); } }注意flip和clear这两个操作它们是Buffer指针管理的核心。flip将Buffer从写模式切换到读模式limit被置为当前positionposition归零。clear则相反准备下一次写入。新手最容易在这两个方法上翻车忘了flip会导致一个字都读不出来。6.3 既然NIO这么强为什么很多IO操作还是用经典流NIO的优势集中在大规模连接和并发场景但在普通文件读写场景它并不一定比Buffered IO更快。经典IO经过了这么多年的JVM优化在小文件、小数据量读写上已经非常高效而且代码可读性更好。Java 7引入了NIO.2AIO支持真正的异步非阻塞IO但也存在模型复杂、调试困难的问题。在实际项目中NIO的Selector模型用不好反而容易出隐性问题。所以选择的标准很简单网络编程、高并发网关、中间件用NIO或Netty本地文件读写、数据处理用经典流加上缓冲包装就足够了。7. 编码问题的本质与解决方案乱码不是玄学7.1 为什么我的中文变成了奇怪的符号乱码的根源是编码和解码使用的字符集不一致。一个UTF-8编码的中字对应三个字节0xE4 0xB8 0xAD。如果程序用GBK去解码这三个字节得到的就是完全不同的字符。整个过程在没有报错的情况下产生了错误数据是最难排查的问题类型。信息论里有一个简单的理解方式编码是字节序列到字符的映射解码是字符到字节序列的映射。只要写和读用的映射表不一致数据就必然会错。7.2 工程上的应对方案第一文件读写时显式指定字符集。绝不要依赖平台默认编码。// 读文件 Reader reader new BufferedReader( new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)); // 写文件 Writer writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8));第二字符串和字节转换时同样显式指定字符集。String text Java IO; byte[] bytes text.getBytes(StandardCharsets.UTF_8); String decoded new String(bytes, StandardCharsets.UTF_8);第三HTTP接口或RPC框架的编码设置。Spring Boot中可以通过server.servlet.encoding.charsetUTF-8等方式确保请求和响应的字符集统一具体方式因框架版本而异但原则一致传输层、应用层、存储层各环节的编码必须一致。7.3 读入半个字符怎么办当一个UTF-8字符的多个字节被分到两次read调用中读取如果直接做字符解码就会出错。比如一个中字三个字节第一次read只读到了两个字节第二次才读到第三个如果直接new String就会得到错误字符。经典IO的Reader内部通过ByteBuffer和CharsetDecoder维持解码状态能在一定程度上处理跨调用解码但在自定义网络协议处理时仍然要小心分片问题。NIO中这个问题更明显所以有专门处理半包/粘包的策略。一个简单可靠的做法是明确每条消息的大小或结束符用缓冲区先把完整消息攒齐后再解码。比如按行传输的文本协议中只有拿到完整的一行才做编码转换。我个人在实际项目里的建议是解析文本数据时先把整个输入攒成字节数组前提是数据量可控再一次性做编码转换。这样既避免跨调用解码问题也方便调试排查。8. 文件复制、目录遍历与临时文件的正确姿势8.1 别再手写复制文件的循环了JDK 7提供了Files.copy方法一行代码搞定文件复制Files.copy(Paths.get(source.txt), Paths.get(target.txt), StandardCopyOption.REPLACE_EXISTING);这个方法内部会根据文件大小选择合适的复制策略。对小文件可能直接在JVM堆内复制对大文件则自动选择更高效的系统级复制方式。相比自己手写read/write循环代码更简洁性能也更有保障。从Java 10开始Files.copy(InputStream, Path, ...)还能直接把输入流写入文件做下载文件、临时文件落盘非常方便。8.2 目录遍历用Files.walk还是递归处理目录遍历时Files.walk提供了流式API可以很方便地过滤和收集文件try (StreamPath paths Files.walk(Paths.get(/data/logs))) { ListPath txtFiles paths .filter(Files::isRegularFile) .filter(p - p.toString().endsWith(.txt)) .collect(Collectors.toList()); }Files.walk返回的Stream必须放在try-with-resources中否则底层目录流不关闭依然会造成文件句柄泄漏。我见过有人在这里踩坑遍历十万个文件的目录后程序报Too many open files就是因为Stream没关。8.3 临时文件的坑谁负责清理Files.createTempFile生成的文件默认放在系统临时目录如Linux的/tmpWindows的%TEMP%。它不会自动删除需要自己定期清理或使用deleteOnExit机制。Path tempFile Files.createTempFile(app-, .tmp); // 使用完毕后 Files.deleteIfExists(tempFile);deleteOnExit方法在JVM正常退出时会删除文件但如果JVM是通过kill -9强杀的话就没有机会执行清理。所以对临时文件的清理策略不要完全依赖deleteOnExit最好在业务代码里显式删除或由操作系统的定时任务兜底。曾经有个项目在生成报表时没清理临时文件跑了几个月后把服务器磁盘塞满了报警才知道问题出在临时文件清理上。9. 最后分享几个IO实战里的血泪经验写代码多年处理过Excel导出、大日志解析、图片上传下载、多系统间文件传输等场景Java IO看起来简单真正用好的窍门都在细节里。这里把我最有价值的几条经验整理出来。第一条永远不要用read()返回的单字节去拼接数据。read()每次返回一个int是这一个字节的数值而不是通用意义上的字符。频繁调用read()性能极差而且很容易在字节转字符时出错。要么用缓冲流配合readLine要么用定长byte[]批量读取。第二条所有IO读写都遵循同一套原则明确数据源头、明确数据编码、明确数据边界。源头错了后面全错编码错了数据全是乱码边界错了读到的数据永远是错位的。第三条线上排查IO问题先看错误日志的堆栈指向哪个IO类再检查资源是否关闭、路径是否存在、权限是否足够、磁盘是否满了。大多数IO报错都不是代码逻辑本身的问题而是外部条件没满足。FileNotFoundException不要只想着是不是文件名写错了还要看看目录是否存在、进程是否有权限创建文件。第四条对于大文件处理优先考虑分片或流式处理而不是一次性读进内存。一个200MB的文件直接读进byte[]堆内存可能直接OOM。正确做法是用缓冲流配合固定大小的字节数组或者用内存映射文件等方式处理。最后Java IO的知识点虽然多但成人达己的路径是清晰的先掌握字节流和字符流的两大分类再熟悉装饰器组合的用法接着理解编码和资源管理的坑最后再上升到NIO和网络编程。把这四步走扎实你在项目中遇到任何读写需求都能像拼乐高一样快速找到合适的配件。这篇文章以一个实操者的视角把Java IO流的体系、陷阱、最佳实践都过了一遍。如果你准备面试重点理解字节流与字符流的分野、IO的装饰器模式、NIO的三大组件与经典IO的区别如果你在写代码直接照搬上面的代码模式和资源管理方式能帮你少踩很多坑。希望这些经验能让你的Java IO之路走得更顺畅。