Java IO:字符流、编码与装饰模式
字符流与字节流的分水岭只有两个字:编码。本篇把编码表、乱码成因、转换流讲透,再借缓冲流看清 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 = "你好"; |
FileReader 与 FileWriter
最简单的字符流用法:
FileReader fr = new FileReader("demo/a.txt"); |
FileWriter 可以直接写字符串,不用再手动 getBytes():
FileWriter fw = new FileWriter("demo/b.txt"); |
但这对”简便组合”有个硬伤:它们只能使用平台默认编码表,无法指定。文件是 GBK 编码而平台默认 UTF-8 时,照样乱码。
什么情况下用字符流
字符流的价值在”读写”,不在”复制”:程序需要读取一段文本、或者需要写出一段文本时用它,可以整字符读取避免半个中文,也能直接写字符串省掉 getBytes()。反过来,单纯把文件从 A 处复制到 B 处不推荐用字符流——字节转成字符再转回字节是白白多两道转换,直接用字节流搬字节又快又安全。和字节流一样,字符流也有字符数组写法:read(char[] arr) 一次读一批,配合 write(arr, 0, len) 写出有效部分,思路完全同构。
转换流:字节与字符之间的桥
InputStreamReader 和 OutputStreamWriter 是字节流通向字符流的桥梁,构造时可以指定编码表:
// 用 UTF-8 读,用 GBK 写,顺便完成编码转换 |
画成图就是:文件 → 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")); |
从键盘读一行也是同一个套路:new BufferedReader(new InputStreamReader(System.in)).readLine()——把键盘的字节流包装成字符流再加缓冲,这就是著名的”键盘录入”写法。顺带提一句 LineNumberReader:它是 BufferedReader 的子类,额外提供 getLineNumber() 统计行号,做日志分析时偶尔能用到。
字符流能拷贝图片吗
不能。图片、音频这类二进制文件的字节序列大多不是合法字符,解码时会变成”?”,写出后再也拼不回原来的字节,文件就损坏了。记住分工:纯文本的读写用字符流,一切文件的复制用字节流。
装饰器模式:Java IO 的设计精髓
回头看这行代码:
BufferedReader br = new BufferedReader( |
一层套一层,像洋葱。这就是装饰器模式:把对象包进一个”装饰器”里,装饰器与被装饰者实现同一个接口,调用时先委托给被装饰者,再附上自己的增强功能。
用一个生活化的例子体会:
interface Coder { void code(); } |
如果改用继承来扩展功能,”会缓冲的文件流””会读基本类型的文件流””既缓冲又读基本类型的文件流”……每种组合都得写一个子类,类的数量会爆炸。装饰器用组合替代继承,功能在运行期像积木一样任意叠加:FileInputStream 负责”读文件”,BufferedInputStream 负责”加缓冲”,DataInputStream 负责”读基本类型”,想组合哪几层就 new 哪几层。new BufferedXxx(new FileXxx(…)) 这条”包装链”读作:外层调用,逐层委托,最内层真正干活,数据再逐层带回来。看懂这条链,Java IO 的类再多也不会迷路。这个模式日后还会反复出现:Servlet 的 Filter、Spring 的增强器,都是同一套思想的延伸。
经典练习
- 把一篇文本按行反转:第一行与最后一行交换,第二行与倒数第二行交换。思路是用 readLine() 全部读入 ArrayList,再倒序写出。
- 统计一篇文章中每个字符出现的次数,把结果按”字符=次数”的格式写入 times.txt。
- 模拟试用版软件:用一个配置文件记录剩余次数,每运行一次减一并提示,归零时提示购买正版。这是第一次用 IO 做”持久化”。
小结
- 字符流 = 字节流 + 编码表;乱码的根源是编码与解码用了不同的表
- FileReader / FileWriter 图省事但改不了编码,指定编码必须用转换流
- 按行读写认准 readLine() + newLine();字符流不能拷贝二进制文件
- 装饰器模式让 IO 功能像积木一样层层叠加,读懂包装链就看懂了 Java IO 的设计
- 编码问题是 Java 初学者跨不过去的坎,把本篇的表和桥吃透,以后遇到乱码就有章可循
下一篇学习对象序列化和几个特殊流。

