《Java 编程入门》14.2 字节流、字符流与编码

把 InputStream、Reader 与 Charset 的关系拆成三层:字节、字符、编码;用 library 的 CSV 读写演示显式 UTF-8、BOM 处理、严格解码与缓冲包装,并说明 PrintWriter 吞异常、默认字符集和二进制文本混用这三类常见故障。

本节目标:分清字节流和字符流各自处理什么,能在读写文件时显式指定编码,识别 BOM 与非法字节序列,并知道哪些 API 会静默吞掉错误。
适用版本:JDK 25(对照 JDK 21);JDK 18 起 file.encoding 默认 UTF-8,但「默认是 UTF-8」不等于「所有环节都显式指定了 UTF-8」。

上一节留下的问题

14.1 的 BookFileStore 已经能把图书写成 CSV,再读回来。但那段代码里有一个刻意留下的细节:所有读写都写了 StandardCharsets.UTF_8。如果不写会怎样?为什么中文在有些机器上正常、在 Excel 里打开却是乱码?为什么同样的文件,Files.readString 有时直接抛 MalformedInputException,有时只是把汉字变成 �?

这些问题都指向同一件事:文件里没有「字符」,只有字节;字符是编码规则解释出来的结果。 这一节把字节流、字符流和 Charset 的关系拆开。

字节与字符是两个层次

Java 的 IO 分成两套:

层次代表类型处理单位典型用途
字节流InputStream、OutputStreambyte图片、压缩包、网络包、序列化数据
字符流Reader、Writerchar文本、CSV、JSON、日志

字符流不是凭空工作的,它内部一定要有一个字节流加一个字符集。InputStreamReader 把字节按编码解码成 char,OutputStreamWriter 把 char 按编码编码成字节。Files.newBufferedReader(path, charset) 只是把这三层包装好了。

先看一段文本在不同编码下的字节数:

String text = "图书:Effective Java\n作者:Joshua Bloch\n";
byte[] utf8 = text.getBytes(StandardCharsets.UTF_8);
byte[] gbk = text.getBytes(Charset.forName("GBK"));
System.out.println("text chars = " + text.length());
System.out.println("utf8 bytes = " + utf8.length);
System.out.println("gbk bytes  = " + gbk.length);

本机 JDK 25 输出:

text chars = 34
utf8 bytes = 46
gbk bytes  = 40

34 个 char,UTF-8 下 46 个字节,GBK 下 40 个字节。差异来自汉字:UTF-8 一个常用汉字通常占 3 字节,GBK 占 2 字节;ASCII 字符在两种编码里都占 1 字节。同一个字符串,换一种编码,字节序列完全不同。 读文件时用错编码,就会把「图书」解释成别的字符,或者解释失败。

这里还有一个容易混淆的点:String.length() 返回的是 UTF-16 代码单元数量,不是字符数,也不是字节数。一个 emoji 可能占两个 char。要数字符(码点),用 codePointCount;要数字节,先选编码再 getBytes。

四层包装:节点流与处理流

实际读文本文件时,通常叠四层:

Files.newInputStream(path)          节点流:从文件读字节
  -> BufferedInputStream            处理流:减少系统调用
  -> InputStreamReader(decoder)     处理流:字节解码成字符
  -> BufferedReader                 处理流:按行读、带缓冲

BufferedReader.readLine() 是按行读取的关键。没有它,你只能一个 char 一个 char 地读,或者自己处理 \n、\r\n。Files.newBufferedReader(path, charset) 直接给你最后两层,是最常用的入口。

写文件同理:BufferedWriter 包 OutputStreamWriter 包 Files.newOutputStream。Files.newBufferedWriter 是快捷方式。

library 读取 CSV 时用显式 UTF-8,并保留行号,方便定位坏行。下面的方法放在 BookCsvReader 工具类里,stripBom 是它的私有辅助方法:

public static List<Book> readCsv(Path file) throws IOException {
    List<Book> books = new ArrayList<>();
    CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
            .onMalformedInput(CodingErrorAction.REPORT)
            .onUnmappableCharacter(CodingErrorAction.REPORT);
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(Files.newInputStream(file), decoder))) {
        String line = stripBom(reader.readLine());
        if (line == null || !line.equals(BookCsv.HEADER)) {
            throw new IOException("CSV 表头不正确: " + line);
        }
        int lineNumber = 1;
        while ((line = reader.readLine()) != null) {
            lineNumber++;
            if (line.isBlank()) {
                continue;
            }
            try {
                books.add(BookCsv.fromLine(line));
            } catch (IllegalArgumentException e) {
                throw new IOException("第 " + lineNumber + " 行无法解析: " + line, e);
            }
        }
    }
    return books;
}

这段代码有三个值得注意的选择:

  • CodingErrorAction.REPORT 让非法字节直接报错,而不是替换成 �。数据导入场景里,宁可在第一行失败,也不要悄悄写进一条标题为 � 的记录。
  • IOException 带上行号和原始行,用户能自己打开文件修正。第 13 章讲过异常转换:底层 IllegalArgumentException 被翻译成带上下文的上层异常。
  • BufferedReader 放在 try-with-resources 的最外层。关闭时它会依次关闭内层,顺序和打开相反。

缓冲、关闭顺序与异常抑制

BufferedInputStream 和 BufferedReader 不是可有可无的装饰。每次 read() 都可能触发一次系统调用,逐字节读一个 10 MB 文件意味着上千万次调用。缓冲区把多次小读合并成一次大读,默认 8 KB,通常够用。

复制二进制文件时,不需要自己写循环,InputStream.transferTo 会处理:

try (InputStream in = Files.newInputStream(source);
     OutputStream out = Files.newOutputStream(target)) {
    long copied = in.transferTo(out);
    System.out.println("copied = " + copied);
}

它内部使用缓冲区,读到最后返回总字节数。注意 transferTo 不会关闭流,关闭仍由 try-with-resources 负责。

关闭顺序也值得再说一次。下面这段代码把 BufferedReader 放在最外层:

try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8))) {
    return reader.lines().toList();
}

关闭时先关 BufferedReader,它会把缓冲区里剩余的内容处理完,再关 InputStreamReader,最后关文件流。顺序反了(先关文件流)会让外层读不到数据。try-with-resources 的语义是「按声明的逆序关闭」,所以外层资源写在前面。

如果关闭时抛异常,而 try 块里已经抛了一个异常,关闭异常会被标记为 suppressed,附在主异常上。打印栈追踪时能看到 Suppressed: ...。第 13 章讲过这个机制,这里补一个实际影响:finally 里手动 close() 会覆盖主异常,try-with-resources 不会。

编码错误:替换、忽略还是报告

CharsetDecoder 对非法输入有三种策略:

策略行为适用场景
REPLACE(默认)用 � 替换无法解码的字节展示、容错读取
IGNORE直接丢掉无法解码的字节几乎不推荐
REPORT抛 MalformedInputException数据导入、协议解析

用 GBK 编码写一个文件,再按 UTF-8 读:

Path gbkFile = data.resolve("gbk.txt");
Files.write(gbkFile, text.getBytes(Charset.forName("GBK")));
try {
    Files.readString(gbkFile, StandardCharsets.UTF_8);
    System.out.println("gbk as utf8 = no error");
} catch (MalformedInputException e) {
    System.out.println("gbk as utf8 = MalformedInputException");
}
System.out.println("gbk decoded = " + Files.readString(gbkFile, Charset.forName("GBK")));

本机输出:

gbk as utf8 = MalformedInputException
gbk decoded = 图书:Effective Java\n作者:Joshua Bloch\n

Files.readString 内部使用的是严格解码,不会把坏字节悄悄换成 �。而 new String(bytes) 和很多老 API 用的是替换策略,得到的是「看起来有内容、其实是乱码」的字符串。读外部数据时,越早失败越好。

BOM:文件头那三个字节

Windows 上的记事本和 Excel 导出 UTF-8 CSV 时,经常在文件开头加一个 BOM(Byte Order Mark),字节是 EF BB BF。解码成字符串后是 U+FEFF,一个不可见的字符。

它会让 line.equals("isbn,title,author,year") 变成 false,因为实际字符串是 "isbn,title,author,year"。表现就是「表头明明一样,程序却说表头不正确」。

String withBom = Files.readString(bomFile, StandardCharsets.UTF_8);
System.out.println("bom first  = U+%04X".formatted((int) withBom.charAt(0)));
System.out.println("after strip= " + stripBom(withBom).substring(0, 5));

本机输出:

bom first  = U+FEFF
after strip= 图书:Ef

处理方式有两种:

  • 读第一行时剥掉:if (line.startsWith("")) line = line.substring(1);。简单,适合只读。
  • 写文件时不加 BOM:Java 的 StandardCharsets.UTF_8 不会自动加 BOM,BOM 通常是导出工具加的。如果你要写给 Excel 看,可能需要主动加;如果写给程序读,保持无 BOM。

不要用 strip() 去 BOM,strip() 只去空白,U+FEFF 不是空白字符(在某些 Unicode 定义里它是零宽不换行空格,但 Java 的 strip 不处理它)。

二进制和文本不要混用

DataOutputStream 写的是二进制:writeInt 写 4 个字节,writeUTF 写长度加 modified UTF-8。它适合自定义协议、缓存文件,不适合用文本编辑器打开。

try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(binary))) {
    out.writeInt(3);
    out.writeUTF("图书");
}
System.out.println("binary size = " + Files.size(binary));

本机输出:

binary size = 12

4 字节整数加 2 字节长度加 6 字节 UTF-8 内容,一共 12 字节。如果反过来用 BufferedReader 读这个文件,会得到一堆乱码,甚至抛异常。看到「文件」两个字之前,先问它是文本还是二进制。

另一个常见坑是 PrintWriter:

try (PrintWriter printer = new PrintWriter(
        Files.newBufferedWriter(printerFile, StandardCharsets.UTF_8))) {
    printer.println("第一行");
    printer.printf("数量:%d%n", 3);
}

PrintWriter 的方法不抛 IOException,而是把异常记在内部,需要用 checkError() 查询。写日志、写报表时这很方便;写必须成功的数据文件时很危险:磁盘满了、权限没了,代码依然「正常」结束。需要可靠写入时用 BufferedWriter,让异常抛出来。

在 library 里落地

library 的输入有两类:

  • 启动时加载 data/books.csv,必须严格:编码错、表头错、列数错都要立刻失败。
  • 导出报表 data/report.txt,可以宽松:写失败就提示用户,不继续。

对应两个 API 选择:

// 严格导入:显式 UTF-8、严格解码、带行号
List<Book> books = BookCsvReader.readCsv(Path.of("data", "books.csv"));

// 导出报表:显式 UTF-8、BufferedWriter、异常向上抛
try (BufferedWriter writer = Files.newBufferedWriter(
        Path.of("data", "report.txt"), StandardCharsets.UTF_8)) {
    writer.write(LibraryReport.byAuthor(books));
}

CSV 文件本身在本机写成这样:

isbn,title,author,year
9780134685991,"Effective Java, 3rd Edition",Joshua Bloch,2018
9787111213826,深入理解计算机系统,Randal E. Bryant,2016

第一行没有 BOM,第二行的标题带逗号所以加了引号。读取方按 UTF-8 解码,Csv.parse 负责处理引号,BookCsv.fromLine 负责列数和年份。每一层只做一件事,出错时能定位到具体层。

常见错误与边界

依赖平台默认编码。 JDK 18 起默认 UTF-8,但容器、CI、IDE 的启动参数可能改掉它。而且「写的时候用默认、读的时候用默认」在同一个进程里通常没问题,换一台机器就未必。跨进程的数据一律显式指定编码。

用 FileReader / FileWriter。 这两个类使用平台默认编码,且不能指定 Charset。新代码用 Files.newBufferedReader / newBufferedWriter。

new String(bytes) 不传编码。 同上。new String(bytes, StandardCharsets.UTF_8) 才是明确写法。

PrintWriter 不检查错误。 它把 IOException 吞掉,只设置内部标志。可靠写入用 BufferedWriter,或者写完调用 checkError() 并处理。

用 readAllLines 读大文件。 它把整个文件读进 List<String>。导入几十万行 CSV 时会占大量内存。用 BufferedReader 逐行处理,或者 Files.lines 配合 try-with-resources。

忽略换行符差异。 Windows 的 \r\n 和 Unix 的 \n 混在一起时,readLine() 能处理,但自己按 \n split 会留下 \r。CSV 字段里如果真有换行,必须用引号包起来,按行读的简单解析器会拆错——这也是为什么复杂 CSV 建议用成熟库,而不是手写。

小结

  • 文件里只有字节,字符是编码解释出来的;Reader/Writer 内部一定有一个字节流加一个字符集。
  • String.length() 是 UTF-16 代码单元数,不是字节数,也不是码点数。
  • 文本读写显式指定 StandardCharsets.UTF_8;数据导入用 CodingErrorAction.REPORT 严格失败。
  • BOM 是文件头的 EF BB BF,解码后是 U+FEFF,会让表头比较失败,读第一行时剥掉。
  • DataOutputStream 是二进制,不能用文本方式读;PrintWriter 吞异常,可靠写入用 BufferedWriter。
  • library 的导入严格、导出宽松,两层用不同的 API 和错误策略。

单个文件读写已经稳了。下一个场景是目录:按年月归档的 CSV 怎么一次遍历完,新文件丢进 inbox 后怎么自动发现。下一节讲 Files.walk 和 WatchService。

阅读导航:上一节:14.1 Path 与 Files · 下一节:14.3 目录遍历与 WatchService 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发