本节目标:分清字节流和字符流各自处理什么,能在读写文件时显式指定编码,识别 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、OutputStream | byte | 图片、压缩包、网络包、序列化数据 |
| 字符流 | Reader、Writer | char | 文本、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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。