字符流与字节流的分水岭只有两个字:编码。本篇把编码表、乱码成因、转换流讲透,再借缓冲流看清 Java IO 的装饰器设计。

本篇目标

  • 理解字符流的本质是”字节流 + 编码表”,弄清 GBK / UTF-8 的差异与乱码成因
  • 掌握 FileReader / FileWriter 与转换流 InputStreamReader / OutputStreamWriter 的分工
  • 用 BufferedReader 的 readLine() 和 BufferedWriter 的 newLine() 按行读写文本
  • 看懂 Java IO 的装饰器模式,会解读层层包装的”流链”

字符流是什么

字节流读文本有个尴尬:一个中文占多个字节,一次只读一个字节就可能把汉字”劈成两半”,打印出来就是乱码。字符流解决了这个问题:read() 一次读一个字符,底层自动按编码表把连续的若干个字节拼成一个完整的字符返回。

所以字符流不是凭空多出来的东西,它的工作流程是:读的时候先读字节,再按编码表转成字符;写的时候先把字符按编码表转成字节,再写出去。一句话:字节流 + 编码表 = 字符流。这也是为什么字符流只能处理纯文本——只有文本的字节才有”按表翻译”的意义。

编码表与乱码成因

计算机只认识 0 和 1,字符要靠”编码表”翻译成数字。常见编码表:

  • ASCII / ISO-8859-1:最早的西文编码,一个字符 1 个字节,完全不认识中文
  • GBK:中国国标,一个中文占 2 个字节,英文占 1 个字节
  • UTF-8:国际通用的 Unicode 实现,一个中文通常占 3 个字节,英文占 1 个字节,是现在的主流

乱码的根本原因只有一个:编码用的表和解码用的表不一致。用 UTF-8 存的中文(3 字节一组),拿 GBK 去读(2 字节一组),字节被错误地切开组合,就成了天书。更糟的是,解码失败的字节会被替换成”?”,信息永久丢失,再转回去也救不回来。所以排查乱码的第一反应不是”换个工具打开”,而是先搞清楚文件当初是用什么编码写入的,再用同一张表去读。

String s = "你好";
byte[] b1 = s.getBytes(); // 按平台默认编码(现在多为 UTF-8)
byte[] b2 = s.getBytes("GBK"); // 显式指定 GBK,得到的字节数组完全不同
String back = new String(b2, "GBK"); // 用同一张表解码才能还原

FileReader 与 FileWriter

最简单的字符流用法:

FileReader fr = new FileReader("demo/a.txt");
int ch;
while ((ch = fr.read()) != -1) {
System.out.print((char) ch);
}
fr.close();

FileWriter 可以直接写字符串,不用再手动 getBytes():

FileWriter fw = new FileWriter("demo/b.txt");
fw.write("你好,世界");
fw.close();

但这对”简便组合”有个硬伤:它们只能使用平台默认编码表,无法指定。文件是 GBK 编码而平台默认 UTF-8 时,照样乱码。

什么情况下用字符流

字符流的价值在”读写”,不在”复制”:程序需要读取一段文本、或者需要写出一段文本时用它,可以整字符读取避免半个中文,也能直接写字符串省掉 getBytes()。反过来,单纯把文件从 A 处复制到 B 处不推荐用字符流——字节转成字符再转回字节是白白多两道转换,直接用字节流搬字节又快又安全。和字节流一样,字符流也有字符数组写法:read(char[] arr) 一次读一批,配合 write(arr, 0, len) 写出有效部分,思路完全同构。

转换流:字节与字符之间的桥

InputStreamReader 和 OutputStreamWriter 是字节流通向字符流的桥梁,构造时可以指定编码表:

// 用 UTF-8 读,用 GBK 写,顺便完成编码转换
BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream("demo/utf8.txt"), "UTF-8"));
BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream("demo/gbk.txt"), "GBK"));
int ch;
while ((ch = br.read()) != -1) {
bw.write(ch);
}
br.close();
bw.close();

画成图就是:文件 → FileInputStream(字节)→ InputStreamReader(按表解码成字符)→ 程序。写方向正好相反。FileReader 其实就是 InputStreamReader 的子类,只是把编码表固定成了平台默认值。凡是需要控制编码的场景,都必须用转换流——这也是实际项目里的常态,因为服务器文件的编码经常和本机不一致。

缓冲字符流:readLine 与 newLine

缓冲原理和字节流一样:BufferedReader 一次从文件读一批字符进缓冲区,再逐个返回,减少读盘次数。它真正封神的是两个独有方法:readLine() 一次读一整行(不含换行符),读到末尾返回 null;搭配 BufferedWriter 的 newLine() 写入一个跨平台换行符(Windows 是 \r\n,Linux 是 \n),不用自己判断操作系统。

try (BufferedReader br = new BufferedReader(new FileReader("demo/a.txt"));
BufferedWriter bw = new BufferedWriter(new FileWriter("demo/b.txt"))) {
String line;
while ((line = br.readLine()) != null) {
bw.write(line);
bw.newLine(); // 换行,跨平台
}
}

从键盘读一行也是同一个套路:new BufferedReader(new InputStreamReader(System.in)).readLine()——把键盘的字节流包装成字符流再加缓冲,这就是著名的”键盘录入”写法。顺带提一句 LineNumberReader:它是 BufferedReader 的子类,额外提供 getLineNumber() 统计行号,做日志分析时偶尔能用到。

字符流能拷贝图片吗

不能。图片、音频这类二进制文件的字节序列大多不是合法字符,解码时会变成”?”,写出后再也拼不回原来的字节,文件就损坏了。记住分工:纯文本的读写用字符流,一切文件的复制用字节流。

装饰器模式:Java IO 的设计精髓

回头看这行代码:

BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream("a.txt"), "UTF-8"));

一层套一层,像洋葱。这就是装饰器模式:把对象包进一个”装饰器”里,装饰器与被装饰者实现同一个接口,调用时先委托给被装饰者,再附上自己的增强功能。

用一个生活化的例子体会:

interface Coder { void code(); }

class Student implements Coder { // 被装饰的基础组件
public void code() { System.out.println("javase"); }
}

class AdvancedCoder implements Coder { // 装饰器
private Coder c;
public AdvancedCoder(Coder c) { this.c = c; } // 持有被装饰者
public void code() {
c.code(); // 保留原功能
System.out.println("javaweb、数据库、框架"); // 增强
}
}

如果改用继承来扩展功能,”会缓冲的文件流””会读基本类型的文件流””既缓冲又读基本类型的文件流”……每种组合都得写一个子类,类的数量会爆炸。装饰器用组合替代继承,功能在运行期像积木一样任意叠加:FileInputStream 负责”读文件”,BufferedInputStream 负责”加缓冲”,DataInputStream 负责”读基本类型”,想组合哪几层就 new 哪几层。new BufferedXxx(new FileXxx(…)) 这条”包装链”读作:外层调用,逐层委托,最内层真正干活,数据再逐层带回来。看懂这条链,Java IO 的类再多也不会迷路。这个模式日后还会反复出现:Servlet 的 Filter、Spring 的增强器,都是同一套思想的延伸。

经典练习

  1. 把一篇文本按行反转:第一行与最后一行交换,第二行与倒数第二行交换。思路是用 readLine() 全部读入 ArrayList,再倒序写出。
  2. 统计一篇文章中每个字符出现的次数,把结果按”字符=次数”的格式写入 times.txt。
  3. 模拟试用版软件:用一个配置文件记录剩余次数,每运行一次减一并提示,归零时提示购买正版。这是第一次用 IO 做”持久化”。

小结

  • 字符流 = 字节流 + 编码表;乱码的根源是编码与解码用了不同的表
  • FileReader / FileWriter 图省事但改不了编码,指定编码必须用转换流
  • 按行读写认准 readLine() + newLine();字符流不能拷贝二进制文件
  • 装饰器模式让 IO 功能像积木一样层层叠加,读懂包装链就看懂了 Java IO 的设计
  • 编码问题是 Java 初学者跨不过去的坎,把本篇的表和桥吃透,以后遇到乱码就有章可循

下一篇学习对象序列化和几个特殊流。