
做 Java 开发这么多年IO 这块儿的坑我真是踩了个遍。尤其是中文乱码、资源泄漏还有读写效率问题这三座大山几乎每个项目里都能碰到几回。面试时候问 IO 流的更是多到离谱八股文背得溜没用得真懂原理、真能解决问题才行。这篇文章我不讲虚的直接把我平时排查和优化 IO 的完整思路、代码写法、还有那些教科书里不会写的坑一次性给你说透。不管你是准备面试还是手头正在被乱码折磨按着这个思路走基本都能顺下来。1. 先从根源看起为什么 IO 流读写会中文乱码乱码这事十有八九不是代码写错了而是字符集没对齐。要搞明白乱码是怎么回事得先区分两个概念字节和字符。字节是存储层的东西字符是人能看懂的内容。一个中文汉字在 UTF-8 里占 3 个字节在 GBK 里占 2 个字节。你往文件里写的时候用 UTF-8 编的码读的时候却按 GBK 去解那出来的必然是一堆“锟斤拷”或者问号。我以前刚接触 Java 的时候也有个误区以为FileReader直接读文本文件就不会乱码。其实 FileReader 底层用的是平台默认字符集在 Windows 上默认是 GBK在 Linux 上默认是 UTF-8。同一份代码在 Windows 上跑得好好的部署到 Linux 服务器上就开始乱码这个问题在真实项目里太常见了。1.1 字符集不一致场景逐层拆解乱码场景看着五花八门归纳起来无非就三种来源。第一种是文件本身的编码和读取编码不一致。别人给你传了个 CSV 文件可能是 Excel 另存的默认是 ANSI 也就是 GBK 编码。你的 Java 程序如果用 UTF-8 去读中文列的字段名肯定花掉。这种问题不好从代码端直接解决因为代码不知道文件到底是啥编码得先探测或者跟对方约定好。第二种是网络传输和字符串转换之间发生了编码错位。调用外部 HTTP 接口返回数据时响应头里 Content-Type 如果标着charsetGBK后端用BufferedReader按 UTF-8 去读 InputStream响应体是字节流解码是拿字节去映射字符字符集对不上自然就乱。这种情况需要显式指定解码字符集不能依赖默认值。第三种是数据库连接层导致的乱码也就是项目里最常见的?characterEncodingUTF-8没配上。这是 JDBC 连接串里的参数负责告诉 MySQL 驱动用 UTF-8 编码来传数据。如果漏了这参数驱动可能用系统默认字符集去转换中文存进数据库再读出来就全是问号或乱码。需要注意连接串里的编码要与表结构、数据库服务端字符集三方一致只改一个地方是不够的。1.2 一个简单的编码转换实战示例我之前排查过一个典型的乱码案例获取外部系统返回的 JSON 数据时如果内容是 UTF-8 编码的字节流但响应头没声明 charset程序就会用默认的 ISO-8859-1 去解码。修复方案其实很简单拿到字节流后手工转一次编码。public static String correctCharset(byte[] data, String sourceCharset) throws UnsupportedEncodingException { return new String(data, sourceCharset); }还有一种场景是字符串本身已经被错误解码成乱码字符了。这种也能救回来思路是把当前错误的编码字节还原再按正确编码重新构造字符串// 误将UTF-8字节按ISO-8859-1解码后的乱码字符串 String garbled new String(utf8Bytes, ISO-8859-1); // 还原正确内容的方式 String correct new String(garbled.getBytes(ISO-8859-1), UTF-8);这里提醒一点这种“二次还原”方案不是万能的。如果乱码过程中发生了信息丢失比如生成了问号字符?或者字符被多次错误解码导致字节信息丢失那就没法还原了。所以最好的处理是入口处就统一字符集事后补救只是兜底方案。2. 资源泄漏的真相从 JDK7 开始就该换个写法了IO 流用完不关这个问题的后果很多人没有直观感受。文件句柄是操作系统级的资源底层有数量限制。一个进程能打开的句柄数是有限的Linux 上默认一般是 1024。如果每次操作都打开一个新的 FileInputStream 却忘了 close文件句柄耗尽之后再想打开任何文件都会报Too many open files。最经典的错误写法是下面这样FileInputStream fis null; try { fis new FileInputStream(test.txt); int data; while ((data fis.read()) ! -1) { System.out.print((char) data); } } catch (IOException e) { e.printStackTrace(); } finally { if (fis ! null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }这段代码逻辑上找不出大毛病finally 里也关了流。但问题在哪说实话这段代码的写法深究起来在 close 的时候如果流内部没有成功初始化或者流的 close 方法在中断状态时抛异常也可能在 IOException 的底层处理上存在隐患。更麻烦的是代码冗长、容易漏关而且如果开了多个流比如文件流包缓冲流只要最外层关了理论上内层也会跟着关但前提是你能保证走到 close 那一行。2.1 try-with-resources 的底层设计JDK7 引入的 try-with-resources 从语法层面解决了这个问题。只要类实现了AutoCloseable接口就可以写在 try 后面的括号里JVM 会保证代码块结束之后自动调用 close 方法。不仅正常结束会关抛异常也会关不用再手写 finally 那一坨样板代码。try (BufferedReader reader new BufferedReader(new FileReader(test.txt))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } } catch (IOException e) { e.printStackTrace(); }这里有个细节很多人不知道如果 try 块内部和 close 方法都抛了异常默认情况下 close 的异常会被抑制也就是说你能看到的是业务代码那层的异常而 close 的异常会被追加到 suppressed 数组里。如果你用 try-catch 捕获异常可以通过Throwable.getSuppressed()方法拿到被抑制的异常。这一点在排查复杂 IO 问题时很有用。2.2 多资源关闭顺序的误区操作文件复制这类场景需要同时打开输入流和输出流。很多人以为关闭顺序是随意的其实不对。正确做法是先关输出流再关输入流因为输出流内部可能还有缓冲区数据没有 flush 到磁盘。虽然很多流实现里 close 会先调 flush但依赖这个行为本身就有风险。try (FileInputStream in new FileInputStream(source.dat); FileOutputStream out new FileOutputStream(target.dat)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } catch (IOException e) { e.printStackTrace(); }try-with-resources 里声明多个资源时关闭顺序是逆序的也就是后声明先关闭。上面这段代码中 out 后声明所以 out 先关这个顺序恰好是对的。但如果你把两个声明的顺序反过来那就可能导致数据未刷盘就关闭了输入源虽然多数情况下输出流 close 都会主动刷缓冲但没必要去赌这个行为。2.3 文件复制完整实操工具类里写一个文件复制方法核心参数就是缓冲区大小。缓冲区开多大合适要看场景文件不大 4KB 就够追求高吞吐可以调到 64KB 甚至更大但也不是越大越好。有个常见误解是缓冲区越大越快实测下来超过 64KB 后吞吐量基本不再增长反而浪费内存。复制文件代码如下public static void copyFile(File src, File dest) throws IOException { try (FileInputStream fis new FileInputStream(src); FileOutputStream fos new FileOutputStream(dest)) { byte[] buffer new byte[32 * 1024]; int readLen; while ((readLen fis.read(buffer)) ! -1) { fos.write(buffer, 0, readLen); } fos.flush(); } }flush 这步有必要吗FileOutputStream 其实是没有内部缓冲区的写一个字节就调用一次系统调用直接到内核。但如果你在这个输出流外层包了 BufferedOutputStream那 flush 就很重要了。所以我的习惯是加上 flush 不亏不加也基本没事但会让人多琢磨一下还是写上安心。3. 性能瓶颈为什么你的 IO 操作又慢又卡很多人写 IO 性能出问题第一反应是换更大的缓冲区。但其实 IO 慢的核心原因在于阻塞也就是线程要傻等磁盘和网络完成数据搬运。Java 传统的 BIO 模型里线程在读数据时是干等着的如果对方一直不返回数据线程就卡死在那里资源被白白占着。NIO 和 AIO 是应对高并发的解法但在文件读写这种场景里真正影响体感的往往是另一个更简单的问题没用缓冲流一个字节一个字节地在读。3.1 字节流和字符流的正确选择逻辑文件里如果是纯二进制内容比如图片、压缩包必须用字节流 FileInputStream/FileOutputStream。如果读的是文本文件你要处理的是一行一行的数据那就用字符流比如 FileReader/FileWriter 加上 BufferedReader/BufferedWriter。字符流自带解码过程能方便地从字节转到字符省去手工转换。但字符流有个坑它依赖编码参数。上面提过 FileReader 用默认编码这在跨平台场景下会出问题。所以最稳妥的写法是先用 FileInputStream 拿到原始字节流再包一个 InputStreamReader构造函数里显式传字符集BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(data.txt), StandardCharsets.UTF_8) );对应输出方向同理BufferedWriter writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(out.txt), StandardCharsets.UTF_8) );显式传编码参数的写法看起来多包了一层但这是跨平台不乱码的前提。省这一层编码参数省不了几个字后面排查问题的时间会远大于这个成本。3.2 缓冲流效率提升背后的原理为什么加一层 BufferedInputStream 就能显著提升效率本质上是减少了系统调用的次数。无缓冲时每读一个字节就要触发一次 read 系统调用而系统调用是有开销的需要从用户态切换到内核态。有了缓冲流底层会一次性读取一大块数据到内存缓冲区程序读字节时直接访问缓冲区只有缓冲区空了才再次触发系统调用。一进一出性能差距巨大。我测试过一个具体的例子用无缓冲的 FileInputStream 逐个字节读一个约 10MB 的文件耗时约 350ms而用 BufferedInputStream 包一层读同一文件耗时 20ms 左右。单次操作差异可能不明显但如果是批量处理上千个文件这个差距足以影响整个任务的完成时间。加缓冲是 IO 优化里成本最低、收益最直观的手段没有之一。3.3 高频查询场景下的慢 IO 排查思路有一个电商后台导出的功能每次生成 CSV 都要好几秒最初怀疑是数据库查询慢后来用慢查询日志查了半天也没发现问题。最后定位到代码发现是逐行写文件时没加缓冲每个单元格都触发一次磁盘写入。改成 BufferedWriter 之后耗时从 4 秒多降到 0.8 秒跟换数据库连接池的效果差不多。所以遇到 IO 慢第一步先看代码里有没有低级操作不要一上来就优化 SQL 和中间件。如果你用 JDK8 及以上有一个更省事的办法Files.newBufferedWriter和Files.newBufferedReader这两个静态方法内部已经帮你包好了缓冲流和默认的 UTF-8 编码try (BufferedWriter bw Files.newBufferedWriter(Paths.get(out.txt))) { bw.write(中文内容); }这个写法是把“显式指定编码”和“自动缓冲”两个最佳实践合二为一平时写工具类我会经常用。不过要注意它默认按 UTF-8 写如果需要 GBK 输出还是得回到 OutputStreamWriter 手动指定。4. 面试高频问答这些追问能答上来才算掌握面试里 IO 流的提问套路很清楚几个必背的BIO/NIO/AIO 区别、字节流和字符流的区别、有哪些常见类各自干什么用、如何高效拷贝文件、try-with-resources 原理、怎么处理大文件读取。4.1 必背八股核心概念对比BIO 是阻塞 IO每个连接都要一个线程去处理线程等待数据时是闲置的。NIO 是同步非阻塞 IO引入了 Channel 和 Selector一个线程可以管理多个连接但读写数据时还是同步的。AIO 是异步非阻塞 IO底层由操作系统在数据就绪后回调通知真正实现了异步但在 Linux 上的实现不够成熟实际项目里用得反而不多。大部分高性能网络框架选的是 NIO 而不是 AIONetty 就是典型案例。字节流和字符流的区别可以从功能层面和编码层面解释。字节流处理所有类型的数据字符流只处理文本。底层实现上字符流本质是在字节流外面套了编码解码器Reader 就是从 InputStream 中读取字节再按指定字符集解码成字符。写代码时要记住字符流一定是对字节流的封装。4.2 面试实战IO 模型与相关代码现场推演常见的面试追问还有用你说实现的代码来拷一个文件并解释每个参数的含义。类名要能准确说上来FileInputStream 用于读文件FileOutputStream 用于写文件BufferedInputStream 给字节流加缓冲BufferedReader 提供 readLine 方法按行读取文本。还有 RandomAccessFile支持随机读写可以在指定位置读写数据常用于断点续传场景。这些问法基本都是考熟练度背得顺是基础能把底层原理讲清楚就是加分项。面试官如果追问“编码在不同平台的默认值是否有差异”要答到 JVM 的 file.encoding 参数。Java 8 里这个参数决定了FileReader、PrintWriter等类的默认编码而不同操作系统默认值不一样。Java 18 之后 JEP 400 把 UTF-8 设为默认字符集跨平台乱码问题从语言层面缓解了很多但老项目升级 JDK 时反而要留意行为变化。4.3 实战经验的加分表达面试聊到项目经验时可以提一个真实场景生成百万级数据导出文件最初用 String 拼接后一次性写入内存直接爆掉。后来改成流式写入边查边写每次写完把缓冲区 flush 到磁盘平滑解决了 OOM。这个经验能体现对内存和 IO 关系的理解。另一个加分点是对大文件读取的分治思路用固定大小缓冲区循环读而不是一次性readAllBytes加载到内存合理设置缓冲区大小既能控制内存使用又能保证吞吐。我自己在写作时会强调的一点是IO 异常不要压成一条日志就完事要记录流关闭失败等细节。特别是在循环里写文件一旦中途抛异常前面写了一半的文件怎么清理这是个容易被面试官追问发散的点。我的做法是文件写入成功后立即 rename 为正式文件名失败时删除临时文件保证线上不会出现半成品文件被其他服务消费的问题。5. 常见乱码场景的问排查记录把日常开发里真正容易跳坑的几个点集中整理下都是经过实际验证的。5.1 控制台输出乱码代码里System.out.println(中文)打印出来是乱码问题通常不在代码而在 IDE 或终端设置。IDEA 里可以设置 File Encoding 为 UTF-8同时设置 Console 输出编码为 UTF-8。Windows 自带的 cmd 默认是 GBK 代码页打印 UTF-8 内容会乱这是环境问题不是程序问题。解决办法是启动时加-Dfile.encodingUTF-8或者在终端里先执行chcp 65001切换到 UTF-8 代码页再运行 Java 程序。5.2 读取 CSV 文件中文乱码CSV 文件是重灾区因为它跟随 Excel 环境变化。Excel 在中文 Windows 环境另存的 CSV 默认是 ANSI也就是 GBK 编码。程序里如果用 UTF-8 去读中文列一定乱。有一种稳定做法是程序先读文件头几个字节判断 BOM有 BOM 就按 BOM 标注的编码解析没有 BOM 就先用InputStreamReader配合Charset.forName(GBK)尝试因为国内传过来的 CSV 大概率是 GBK。千万不要提供“自动识别编码”这种不可控逻辑让业务方明确指定编码才是正路。5.3 数据库中文写入后读取乱码这是经典场景。连接串补齐编码参数表结构确认是 utf8mb4服务端配置也检查 character_set_server。三处查下来任何一个不匹配都会导致中文乱码。排查思路是先在 MySQL 客户端手工插入一条中文再通过代码查询。如果手工插入显示正常而代码查出来乱那问题在连接串如果手工插入都乱那问题在数据库字符集配置。5.4 第三方接口返回的中文乱码HTTP 接口返回的中文乱码要先看响应头 Content-Type 里的 charset。但有些老系统的接口不标或者标错这种情况就需要预先知道对方的编码格式再处理。另一个思路是使用成熟 HTTP 客户端时配置自定义编码// 以 Apache HttpClient 为例设置默认字符集 HttpClientBuilder.create() .setDefaultRequestConfig(RequestConfig.custom().build()) .build();更直接的办法是拿到响应体字节后手动指定编码解析byte[] responseBody EntityUtils.toByteArray(entity); String text new String(responseBody, Charset.forName(GBK));这个兜底手段要建立在确定对方编码确实不是 UTF-8 的基础上如果只是猜测而猜错了那还会继续花掉大把排查时间。6. 工程实践中的 IO 硬经验总结第一条硬经验是 IO 操作永远不吞异常。很多人排查问题时发现文件读不出来控制台却没有任何报错因为 catch 块里就一个e.printStackTrace()日志文件里还没打出来。生产环境一定要接入日志框架把 IO 异常的堆栈完整打印到日志里。排查乱码时把e.toString()换成log.error(读取失败文件为{}编码为{}, fileName, charset, e)能省一半时间。第二条是流关闭放在 finally 或者 try-with-resources 里不要图省事忽略。有同学说自己内存没爆过那是因为进程重启得勤或者操作文件频率低问题还没积累到阈值。等线上文件操作一多绝对让你找半天“哪来的句柄泄漏”。这个坑我在压测时踩过一次所有线程都在等文件句柄释放最终服务失去响应重启才恢复。第三条是通过配置中心去控制字符集。在关键的 IO 处理入口不要硬编码字符集而是从配置中心读取一个全局的 charset 配置。这样线上遇到 GBK 文件运维改个配置就能切换不用重新发版。我司的通用文件模块就是这么做的上线三年多乱码类工单几乎为零。第四条是在高并发写文件的场景下尽量用异步批量写。无论是日志还是导出文件同步写太多次系统调用会拖垮吞吐。可以用一个队列把写入任务缓冲起来后台单线程批量 flush这本质上就是缓冲思想在架构层面的复用。还可以配合 CompletableFuture 做到异步不阻塞业务线程但要注意线程池大小和磁盘 IO 能力匹配否则容易造成任务堆积。7. 一个完整案例自动导出报表的 IO 全流程实践最后用实际项目里的导出功能把上面所有点串起来。需求是查询业务库的流水数据导出成带表头的 CSV 文件文件按日期分目录存储文件名带时间戳同时保证大数据量下内存不爆、中文不乱、关闭不出错。完整流程如下查询数据用流式游标拿到一批处理一批追加写入文件。文件先写到临时目录全部写完再 rename 到正式目录避免半成品文件影响后续任务。输出编码固定 UTF-8 带 BOMExcel 打开才不乱码。这里带 BOM 是小技巧UTF-8 的 BOM 是\uFEFF写在文件开头Excel 识别后会自动按 UTF-8 解析否则直接用 UTF-8 无 BOM 的 CSVExcel 在中文系统上还是按 ANSI 打开仍然会乱。public static void exportCsvWithBom(Path tempFile, ListString headers, ConsumerBufferedWriter dataWriter) throws IOException { try (BufferedWriter writer Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { writer.write(\uFEFF); // 写入UTF-8 BOM兼容Excel writer.write(String.join(,, headers)); writer.newLine(); dataWriter.accept(writer); } }写完这批数据后再执行文件挪移Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE);ATOMIC_MOVE 是原子移动要么成功要么失败不会有中间态。这个写法对排查问题非常有帮助因为临时文件即使没删干净也不会影响正常任务读取。加个 finally 里的清理逻辑把残留临时后缀文件定期删掉就行。IO 这块的知识点没有捷径多写多踩坑感受过不关流的代价和缓冲带来的效率提升才算真正掌握。上面这套组合拳打下来乱码、泄漏、性能这三大顽疾都有对应的解法。面试的时候能把这些实操细节讲出来比干背十个类的继承关系有说服力得多。