拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java IO字节流与字符流:读写原理、编码转换与乱码排查指南

写文件这事看着简单翻来覆去就 open、write、close 几行代码。但只要你在这个行业待过一两年就一定遇到过中文乱码、文件复制了一半、读写速度慢到怀疑人生、或者程序跑着跑着报出“Stream closed”这种莫名其妙的问题。这些坑十有八九都出在对“字节流”和“字符流”的理解上。字节流读写与字符流读写是 Java IO 体系里最基础、也最容易被忽视的一块。很多新人学到这里背住了“字节流读字节、字符流读字符”这句话但真到项目里还是不知道选哪个也不知道为什么 FileReader 读中文会乱码、为什么复制图片要用 FileInputStream 而不是 FileReader。这篇文章我就从实际写代码的角度把字节流和字符流的读写原理、编码转换、选型思路、常见坑全部串一遍适合刚学完 Java 基础准备上手做文件处理的新人也适合写了两三年代码但对 IO 细节一直模模糊糊的同学。1. 先搞清楚字节流和字符流到底差在哪1.1 计算机里根本没有“字符”只有字节很多人第一次接触这个知识点会被“字节流”和“字符流”这两个词绕晕。我换个说法你就明白了磁盘上的任何文件本质上都是一串 0 和 1按 8 位一组切成字节。不管是 txt、jpg、mp4 还是 exe存储层面只有字节没有别的。那“字符”是什么字符是人类认识的符号比如 ‘A’、‘中’、‘’。计算机要存储和传输这些符号必须把它们编码成字节序列。同一个字符用不同的编码方式对应的字节可能是不同的。“A”在 ASCII 里是 0x41在 UTF-8 里还是 0x41但“中”在 UTF-8 里是 0xE4 0xB8 0xAD 三个字节在 GBK 里是 0xD6 0xD0 两个字节。所以字节流和字符流最本质的区别不是“读出来的东西不一样”而是“处理层级不一样”字节流直接跟底层字节打交道读出来是什么就是什么不关心编码。字符流在字节流之上做了一层编码解码把字节翻译成人能看懂的字符。这就意味着字节流是通用的什么文件都能读字符流是给文本文件准备的而且必须知道编码规则才能正确工作。1.2 为什么写中文代码时 FileReader 会出乱码这是新手问得最多的一个问题。我直接说结论FileReader 这个类底层用的是平台默认字符集。在中文 Windows 上这个默认字符集一般是 GBK在 Linux 服务器上一般是 UTF-8。如果你在 Windows 上用 FileReader 读一个 UTF-8 编码的文本文件它却按 GBK 去解码中文字符就对不上号了出现乱码几乎是必然的。更麻烦的是FileReader 在构造时无法指定字符集你只能接受它的默认行为。这就导致同一套代码在本机跑得好好的部署到 Linux 服务器上就乱码。这个问题我后面会给出正经的解决办法但你现在先记住一个原则跨平台场景下尽量不要直接用 FileReader 和 FileWriter。用生活类比来说字节流是快递运输的原包裹字符流是拆包裹后按说明书组装成家具。如果你拿错了说明书编码不对组装出来的家具自然是歪的。而 FileReader 是那种不允许你选说明书的工具它默认用当地的语言来读跨地区就出问题。2. 字节流读写最底层、最通用、最不会被骗的方案2.1 FileInputStream 和 FileOutputStream 的基本读写字节流的核心实现类就是 FileInputStream 和 FileOutputStream。它们做的事非常简单FileInputStream 从文件里一个字节一个字节地把数据抠出来FileOutputStream 把一个字节一个字节地写进文件。一个最基础的复制文件的代码长这样import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class ByteStreamCopy { public static void main(String[] args) { try (FileInputStream in new FileInputStream(source.png); FileOutputStream out new FileOutputStream(target.png)) { int data; while ((data in.read()) ! -1) { out.write(data); } } catch (IOException e) { e.printStackTrace(); } } }注意这段代码里的read()方法它返回的是 int。为什么读一个字节要返回 int 而不是 byte因为 read() 需要返回 -1 来表示“文件读完了”而 byte 的范围是 -128 到 127装不下 -1 这个结束标记同时还要避免和真正的字节数据 0xFF 混淆。如果你用 byte 去接收读到 0xFF 时会变成 -1程序就会误以为文件结束了。这是一个非常经典的坑我后面会详细讲。上面这段代码功能上没错但性能很差。因为每次 read() 都是一次系统调用相当于你每次只从文件里拿一个字节来回折腾。复制一个几 MB 的文件还好如果是几百 MB 的视频这种写法慢到你想砸键盘。2.2 用缓冲流给字节读写提速解决上面那个性能问题的办法不是自己写一个 byte[] 数组循环读虽然这也是一个办法而是用 BufferedInputStream 和 BufferedOutputStream。它们内部维护了一个 8192 字节8KB的缓冲区一次性从磁盘读一大块数据到内存然后你的程序从这个缓冲区里拿数据不用每次都去碰磁盘。改造后的代码import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class BufferedByteStreamCopy { public static void main(String[] args) { try (BufferedInputStream in new BufferedInputStream(new FileInputStream(source.mp4)); BufferedOutputStream out new BufferedOutputStream(new FileOutputStream(target.mp4))) { byte[] buffer new byte[4096]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } catch (IOException e) { e.printStackTrace(); } } }这里还额外传了一个 4096 字节的 byte[]让 read() 每次尽量把缓冲填满write() 也一次写一批。实测下来复制同样大小的文件带缓冲的版本比逐字节读写的版本快几十倍不止。如果你的代码里还在用单字节 read() 写循环相信我赶紧换掉。我个人的经验是缓冲区大小不是越大越好。8KB 到 64KB 之间性能差别不大再大上去反而可能因为内存分配和 GC 压力得不偿失。64KB 是一个比较合适的上限日常写业务代码用默认的 8KB 缓冲就足够。2.3 为什么我劝你永远用 try-with-resources 关流上面的代码里我用了 try-with-resources 语法这不是为了装酷是真的有讲究。在 Java 7 之前我们必须在 finally 块里手动关流FileInputStream in null; try { in new FileInputStream(source.png); // 读写操作 } finally { if (in ! null) { try { in.close(); } catch (IOException e) { e.printStackTrace(); } } }这段代码不仅啰嗦而且存在一个隐患如果 try 块里先抛出了异常然后 close() 又抛出一个新异常新异常会覆盖掉原本的异常信息导致排查问题时丢失真正的线索。try-with-resources 会自动处理这一切它保证在 try 块结束时关闭所有声明过的资源而且如果 try 块和 close() 都抛异常原始异常会被保留关闭时产生的异常会被附加为 suppressed 异常。只要你实现了 AutoCloseable 接口就能享受这个待遇。这个细节平时写 demo 看不出来但在生产环境排查问题时保留原始异常信息往往是救命的关键。所以我的习惯是凡是涉及流的代码一律 try-with-resources绝不手写 finally 关流。3. 字符流读写编码转换是灵魂3.1 InputStreamReader字节到字符的翻译官字符流的底层仍然离不开字节流它只是在外面包了一层编码解码逻辑。这个“包”就是 InputStreamReader 和 OutputStreamWriter。看名字就能猜到它们的关系InputStreamReader 把一个 InputStream 变成 ReaderOutputStreamWriter 把一个 OutputStream 变成 Writer。转换时你需要指定字符集比如import java.io.FileInputStream; import java.io.InputStreamReader; import java.io.IOException; import java.nio.charset.StandardCharsets; public class ReadWithCharset { public static void main(String[] args) { try (InputStreamReader reader new InputStreamReader(new FileInputStream(note.txt), StandardCharsets.UTF_8)) { int c; while ((c reader.read()) ! -1) { System.out.print((char) c); } } catch (IOException e) { e.printStackTrace(); } } }这里的 StandardCharsets.UTF_8 是 Java 7 以后提供的一个常量比写字符串 UTF-8 更安全因为拼错字符串会抛 UnsupportedCharsetException而写常量不会有这种问题。需要说明的是InputStreamReader 每次 read() 返回的是一个 int 表示的字符码点不是字节。也就是说它已经帮你把文件里的字节序列按 UTF-8 解码成了 Unicode 字符。你拿到手就可以直接转成 char 打印不用担心一个中文占几个字节的问题因为字符流层面看到的就是一个个完整的字符严格来说是 Unicode 码点可能还有代理对问题但日常文本处理基本遇不到。3.2 FileReader 的坑无法指定编码现在再回头看 FileReader。它其实是 InputStreamReader 的一个便捷子类便捷到把字符集写死了——用平台默认字符集而且不给你改的机会。看一下源码就很清楚FileReader 的构造函数是这样的public FileReader(String fileName) throws IOException { super(new FileInputStream(fileName)); }它连字符集参数都没暴露出来底层用的就是 Charset.defaultCharset()。这在单机小工具场景下问题不大但在 Web 应用、微服务、跨平台部署场景下就是个定时炸弹。举一个我踩过的真实案例一个老项目里用 FileWriter 写日志在开发同学的 Windows 机器上跑得好好的因为默认 GBK部署到 CentOS 服务器后日志文件里的中文全部变成了乱码。后来排查了很久才发现是 FileWriter 用了服务器默认的 UTF-8 写文件而运维同学的本地方言查看工具默认 GBK 去读两边编码对不上。所以我的建议很明确只要你的代码运行环境可能变化就不要用 FileReader/FileWriter直接用 InputStreamReader/OutputStreamWriter并且显式指定 UTF-8。项目里统一一个字符集所有文件的读写、数据库连接、HTTP 请求响应都保持一致能省下大量跨环境排错的功夫。3.3 BufferedReader 和 BufferedWriter按行读写的正确姿势有了字符流你终于可以做一件字节流做起来很痛苦的事按行读写文本。这就是 BufferedReader 和 BufferedWriter 的用武之地。BufferedReader 的 readLine() 方法非常实用它一次读一行自动处理行尾的换行符返回的字符串不含 \n 或 \r\n。配合 InputStreamReader代码可以这样写import java.io.BufferedReader; import java.io.FileInputStream; import java.io.InputStreamReader; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; public class ReadLines { public static void main(String[] args) { ListString lines new ArrayList(); try (BufferedReader reader new BufferedReader(new InputStreamReader(new FileInputStream(data.txt), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { lines.add(line); } } catch (IOException e) { e.printStackTrace(); } System.out.println(共读取 lines.size() 行); } }写文件的时候BufferedWriter 的 newLine() 方法会根据平台自动插入换行符但为了跨平台一致性我更推荐自己写 \n。如果你明确知道文件是要在 Windows 上被记事本打开的那就写 \r\n否则统一用 \n 一般不会错。按行读写的场景在真实项目中非常常见处理 CSV、读取配置文件、导入导出数据、日志分析。而且 BufferedReader 内部也有一个 8192 字符的缓冲性能比逐字符读取好得多。4. 字节流和字符流如何选择以及转换的底层逻辑4.1 一句话选型原则我总结了一个特别土但特别实用的决策规则百试百灵如果你处理的是图片、音频、视频、压缩包、可执行文件或者任何“不是纯文本”的文件用字节流别犹豫。如果你处理的是文本、配置、日志、CSV并且需要按行读取或按字符处理用字符流但必须指定字符集。如果你不确定文件是什么格式先用字节流把数据读进来再根据内容判断不要贸然用字符流。这里有个很多人会犯的错以为文本文件就一定是字符流处理。其实字节流也能读文本你手动按 UTF-8 解码字节也能得到字符只是代码更繁琐。反过来字符流处理图片则必错无疑因为图片里的字节序列根本不符合任何文本编码规则解码过程必然产生乱码或异常。4.2 字节流到字符流的转换为什么需要桥接类我再把这张关系图用文字梳理一遍。你在代码里写的 Reader、Writer它们并不直接读磁盘文件而是通过底层字节流真正完成 IO 操作。InputStreamReader 和 OutputStreamWriter 就是这座桥。它们的构造方法接受一个字节流对象再指定一个字符集完成从字节到字符的转换。所以如果你看到有人写BufferedReader reader new BufferedReader(new FileReader(a.txt));你要知道这行代码至少包了三层BufferedReader 提供按行读取和缓冲能力FileReader 本质是 InputStreamReader 的子类负责把 FileInputStream 的字节解码成字符而 FileInputStream 负责真正从磁盘读字节。每一层都有其存在的意义理解了这个层次结构你就不会觉得 IO 类很多很难记了。很多框架底层就是这么做的。比如某些 ORM 框架读 SQL 脚本、配置注入、资源文件加载都是先拿到 InputStream再包一层指定 UTF-8 的 InputStreamReader最后套 BufferedReader。你去看 Spring、MyBatis 的源码会发现这种组合出现频率极高。4.3 用 Files 工具类简化日常读写如果你觉得上面的代码太啰嗦Java 7 以后的 java.nio.file.Files 类提供了更简洁的 API适合那些不需要流式处理的小文件场景import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.charset.StandardCharsets; import java.util.List; public class FilesExample { public static void main(String[] args) throws Exception { Path path Paths.get(note.txt); // 一次性读入所有行 ListString lines Files.readAllLines(path, StandardCharsets.UTF_8); // 一次性写入所有行 Files.write(path, lines, StandardCharsets.UTF_8); // 一次性读入整个文件内容为字符串 String content Files.readString(path, StandardCharsets.UTF_8); } }Files.readAllLines 适合文件不大、可以全部加载到内存的场景比如配置文件、测试数据。如果文件有几个 GB还是老老实实用 BufferedReader 按行处理否则内存直接爆掉。这里要强调一下小文件用 Files 省事大文件用流按需处理这是 IO 编程的基本素养。5. 常见问题与排查技巧实录5.1 乱码问题的排查思路乱码问题几乎每个人都会遇到我给出一个系统的排查顺序第一确定文件本身是什么编码。Linux 下可以用file命令查看Windows 可以用 Notepad 看编码标识或者用十六进制工具看 BOM 头。UTF-8 编码的文本通常没有 BOMUTF-16 编码的文件会以 FF FE 或 FE FF 开头。第二确认你的解码字符集和文件编码一致。这是乱码最常见的原因。解决方案就一句话读写都显式指定 UTF-8并且让读写双方保持一致。第三小心“双重乱码”。有时候文件本身是 GBK 编码你误当作 UTF-8 读了再用 UTF-8 写回去数据就彻底坏了。因为解码错了之后你手里的字符串已经不是原始的字符序列了再转换也不会还原。这种情况只有重新从原始文件读取才能修复所以发现乱码后千万不要保存文件。5.2 用 read() 返回值判断文件结束的经典错误我见过不少新手写这样的代码byte[] data new byte[1024]; while (in.read(data) ! -1) { // 处理 data }这个写法有个隐藏 bugread(data) 返回的是实际读到的字节数不是“是否读到数据”的标记。如果文件末尾剩余不足 1024 字节最后一次 read 可能返回比如 200然后下次 read 返回 -1循环退出。但前面那次 read 已经把最后 200 字节读进 data 了循环体内的处理逻辑可能基于“data 一定是满的”这个假设于是把后面的脏数据也当作真实数据处理了。正确的写法是记录每次返回的实际长度byte[] data new byte[1024]; int len; while ((len in.read(data)) ! -1) { // 只用 data[0] 到 data[len-1] }几乎所有涉及字节数组的读写都要遵守“用返回的 len 来限定处理范围”这个原则。包括写文件时也不要直接把整个 data 写进去而是out.write(data, 0, len)否则会把缓冲里多余的旧数据也写进文件。我帮别人 review 代码时这个错误出现的频率非常高尤其在一些从 C 转 Java 的同事代码里。C 里 fread 的返回值也是实际读到的元素个数道理完全一样但换了个语言就容易踩。5.3 字节流怎么读写才知道有没有写完还有一个容易忽略的细节flush()。在使用 BufferedOutputStream 或 BufferedWriter 时数据会先写到缓冲区缓冲区满了或者你手动调用 flush()才会真正写入底层输出流。如果你不 flush 就直接关闭流close() 会自动触发一次 flush所以大多数情况下没问题。但有一个场景很特殊你需要在程序运行过程中持续输出数据并且希望数据尽快落盘。比如写日志、写进度条、或者跑一个长时间任务需要定时输出状态。这时候如果不主动 flush数据可能一直积压在缓冲区里外层监控程序看到的就是一个“卡住”的文件。我的经验是需要实时性的输出主动调用 flush()批量处理完一批数据也主动 flush() 一次只有“全部处理结束、马上关流”的场景才可以把 flush 交给 close() 兜底。5.4 文件读取时“文件正在被占用”怎么处理Windows 下偶尔会遇到一个文件被另一个进程占用导致 FileOutputStream 构造时报 FileNotFoundException或者 FileChannel 操作失败。这种情况在 Linux 下比较少但 Windows 下如果你刚用编辑器打开了一个文件又想在代码里覆盖它就会碰钉子。处理方式无非几种一是换文件名或目录二是确保没有程序占用文件三是用 FileChannel 以共享锁的方式打开。不过从设计上讲发布系统、日志系统这类高并发场景应该尽量减少对同一个文件的直接覆盖操作改用追加模式或者滚动日志能避开很多 Windows 平台的怪问题。6. 一点实操心得字节流和字符流这个知识点单独看每个类都很简单但组合起来却能让很多人栽跟头。我在项目里沉淀下来的几个习惯分享给各位第一代码里统一使用 UTF-8团队内所有相关文件、数据库、配置都保持一致。不管是读写文件还是网络传输都显式指定绝不依赖默认。这个习惯能帮你避开至少一半的乱码问题。第二能用缓冲流就不用原始流能用 try-with-resources 就不用 finally 关流。这不是炫技而是长期维护成本的区别。你少写的每一行模板代码意味着少一次出错的机会。第三复杂的 IO 操作尽量用 NIO 的 Files 工具类简单直接但前提是文件大小可控。超过几百 MB 的文件还是要走流式处理否则内存压力扛不住。第四处理文本文件时如果要兼容 UTF-8 BOM记得对开头的 FE FF 特殊处理否则第一行可能多出一个不可见字符导致解析异常。这个小问题我现在想起来还想吐槽当年排查 CSV 导入第一列多了一个乱码符号花了我整整一晚上。第五写给别人用的代码输出的文件路径和文件名最好用变量或配置管理不要硬编码。文件不存在时判断父目录不存在就先创建再开始写。这些防呆处理能在生产环境帮你挡掉很多不必要的麻烦。我在实际处理一个数据导入功能时曾经遇到过这样的情况业务方给了一个 2GB 的文本文件每一行是一条业务数据。用 BufferedReader 按行读每一行解析后写回数据库整个过程跑了快一个小时。后来通过调整缓冲区大小、批量提交、减少每条记录的日志输出把时间压缩到了二十多分钟。这就是 IO 处理里细节决定效率的真实写照。如果你现在正被文件读写搞得头疼不妨从这篇文章里的几个关键点入手确认编码、确认缓冲、确认关闭方式、确认 read 返回值。这四个点对了80% 的文件读写问题都能解决。有一个小技巧我最后再分享一下吧需要快速判断一个文本文件的大致编码时可以读它的前几个字节看 BOM。UTF-8 的 BOM 是 EF BB BFUTF-16 LE 是 FF FEUTF-16 BE 是 FE FF。不过很多 UTF-8 文件不带 BOM这个判断方法只能作为参考最终还是要靠实际内容验证。判断的代码很简单try (FileInputStream in new FileInputStream(file.txt)) { byte[] head new byte[3]; int len in.read(head); if (len 3 (head[0] 0xFF) 0xEF (head[1] 0xFF) 0xBB (head[2] 0xFF) 0xBF) { System.out.println(UTF-8 with BOM); } }这个技巧在处理第三方导入文件时特别好用遇到带 BOM 的文件先把开头的三个字节去掉再解析就不会出现第一行数据异常了。写到这里我突然想起刚入行那会儿被一个乱码问题折腾到凌晨两点的场景。后来发现问题只是一个 FileReader 默认编码引起的从那次以后我所有的代码里就没有再出现过 FileReader 和 FileWriter 这两个类了。也希望你看完这篇文章别再在同样的坑里摔一次。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门