字符流

字节流能读写一切,但有个别扭的角落——文本。一个汉字在 UTF-8 里占 3 字节,在 GBK 里占 2 字节;如果用字节流读取,你必须自己判断”这 3 字节是一个汉字还是 3 个英文字母”。这种事让人头大。

字符流(character stream)就是为此而生。它把”字节到字符”的转换封装起来——你只管 read 一个 charwrite 一行字符串,编码的事交给它。这一章我们从 Reader/Writer 抽象类讲到桥梁流、缓冲流、PrintWriter,最后讲透字符编码的前世今生——ASCII、GBK、Unicode、UTF-8 到底什么关系。

一、Reader 与 Writer 抽象类

字符流的顶层抽象是 Reader(读)和 Writer(写),对应字节流的 InputStream/OutputStream

public abstract class Reader implements Readable, Closeable {
    public int read() throws IOException;                    // 读 1 个字符(0~65535,到末尾 -1)
    public int read(char[] cbuf) throws IOException;         // 批量读入
    public abstract int read(char[] cbuf, int off, int len);
    public long transferTo(Writer out);                      // Java 10+
    public abstract void close();
}

public abstract class Writer implements Appendable, Closeable, Flushable {
    public void write(int c) throws IOException;             // 写 1 个字符
    public void write(char[] cbuf) throws IOException;
    public abstract void write(char[] cbuf, int off, int len);
    public void write(String str) throws IOException;        // 直接写字符串
    public Writer append(CharSequence csq);                  // 追加(来自 Appendable)
    public abstract void flush();
    public abstract void close();
}

注意:字符流的单位是 char(16 位 Unicode 码元),不是 byteread() 返回 int,0~65535 是有效字符,-1 是末尾——和字节流同样的 -1 模式。

二、InputStreamReader / OutputStreamWriter:字节到字符的桥梁

字符流不能凭空读字符——数据源最终还是字节(文件、网络都是字节)。InputStreamReader 是”字节流到字符流”的桥梁:它持有一个 InputStream,按指定 Charset 把字节解码成字符。

import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

// 用 UTF-8 编码读
Reader r = new InputStreamReader(
    new FileInputStream("poem.txt"),
    StandardCharsets.UTF_8);

// 写 GBK
Writer w = new OutputStreamWriter(
    new FileOutputStream("out.txt"),
    Charset.forName("GBK"));

InputStreamReader/OutputStreamWriter 是字符流里唯一直接接触字节的类。所有其他字符流(FileReaderBufferedReader 等)内部都依赖它们。

2.1 编码必须显式指定

如果你不传 Charset,JDK 会用平台默认编码——这在不同机器上结果不同:

  • Windows 中文版默认 GBK
  • Linux/macOS 默认 UTF-8
  • Docker 容器里可能是 ASCII

这种”同样的代码在不同机器跑出乱码”的 bug 极其难排查。规则:永远显式指定编码,别依赖默认值。StandardCharsets 提供了三个常量:UTF_8UTF_16ISO_8859_1US_ASCII,省得你拼字符串。

三、FileReader / FileWriter:便捷类

FileReader/FileWriterInputStreamReader/OutputStreamWriter 的便捷封装——直接传文件路径,省去一层嵌套。

// Java 11+ 支持显式指定编码
Reader r = new FileReader("poem.txt", StandardCharsets.UTF_8);
Writer w = new FileWriter("out.txt", StandardCharsets.UTF_8, true);  // true=追加

坑提醒:Java 11 之前,FileReader 没有接受 Charset 的构造器——只能用平台默认编码,这是历史遗留的”便捷类陷阱”。在新代码里:要么用 Java 11+ 的 FileReader(path, charset),要么直接用 InputStreamReader + FileInputStream 显式组合。

四、BufferedReader / BufferedWriter:行读写

Reader.read() 一次读一个字符,调一次解码——慢。BufferedReader 加缓冲区,并提供按行读取readLine()——这是处理文本文件最常用的方法。

import java.io.*;

try (BufferedReader br = new BufferedReader(
        new InputStreamReader(
            new FileInputStream("poem.txt"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = br.readLine()) != null) {   // 读到末尾返回 null
        System.out.println(line);
    }
}

readLine() 返回的内容不包含换行符——\n\r\r\n 都会被识别并剥除。要原样保留得用 read()

BufferedWriter 对应 newLine()——写出平台相关的换行符:

try (BufferedWriter bw = new BufferedWriter(
        new OutputStreamWriter(
            new FileOutputStream("out.txt"), StandardCharsets.UTF_8))) {
    bw.write("第一行");
    bw.newLine();          // 平台换行符(Linux \n,Windows \r\n)
    bw.write("第二行");
}

五、PrintWriter:格式化输出

PrintWriter 提供 printprintlnprintf 一套方法,和 System.out 一脉相承。它最大的特点是不抛 IOException——出错只设置一个 checkError() 标志,让”打印日志”这种场景不被异常打断。

import java.io.*;

try (PrintWriter pw = new PrintWriter(
        new OutputStreamWriter(
            new FileOutputStream("log.txt"), StandardCharsets.UTF_8),
        true)) {   // 第二参 true = 自动 flush
    pw.println("开始处理");
    pw.printf("用户 %s, 年龄 %d%n", "Alice", 30);
    pw.printf("Pi = %.4f%n", Math.PI);
}

自动 flushPrintWriter 可以开启”println 时自动 flush”模式——这在写日志、与用户交互时有用。底层 BufferedWriter 默认不 flush,关流时才 flush。

PrintWriter 的”不抛异常”是双刃剑——方便但也容易掩盖错误。如果你关心写入是否真的成功,调一下 pw.checkError()

六、字符编码详解:从 ASCII 到 UTF-8

理解字符流绕不开”编码”。我们用一节讲透它的来龙去脉。

6.1 ASCII:英语的 128 个字符

1963 年的 ASCII(American Standard Code for Information Interchange)用 7 位二进制表示 128 个字符——26 个英文字母(大小写)、数字、标点、控制符。'A' 是 65、'a' 是 97、'0' 是 48。这是字符编码的起源。

6.2 ISO-8859-1:补完一个字节

ASCII 只用了 7 位,第 8 位空着。ISO-8859-1(又叫 Latin-1)把第 8 位也用上,扩展到 256 个字符——补充了西欧语言的字母(é、ü、ñ 等)。一个字节一个字符,简洁但只能表示西欧语言。

6.3 GBK:中文的方案

中文有上万个汉字,1 字节 256 个字符根本不够。GB2312(1980)和它的扩展 GBK(1995)用变长编码——ASCII 字符 1 字节,汉字 2 字节。一个 GBK 文件里 'A' 占 1 字节、'中' 占 2 字节。Windows 中文版至今默认 GBK。

6.4 Unicode:统一全世界

每个国家一套编码(中文 GBK、日文 Shift-JIS、韩文 EUC-KR),互相不通——同一份文档在不同机器乱码。Unicode(1991)的目标是:给全世界每个字符一个唯一编号

Unicode 是个字符集(character set),不是编码方案。它给每个字符分配一个码点(code point)——U+0041AU+4E2DU+1F600😀。目前 Unicode 已收录 14 万+ 字符,还在持续增加。

但 Unicode 只规定”哪个码点对应哪个字符”,没规定”码点在文件里怎么存”。

6.5 UTF-8:Unicode 的最佳实现

UTF-8 是 Unicode 的一种编码方案(encoding form),把码点存成字节序列。它变长:

字符范围字节数示例
ASCII(U+0000~U+007F)1 字节'A'41
拉丁扩展(U+0080~U+07FF)2 字节'é'C3 A9
BMP 基本多文种平面(U+0800~U+FFFF)3 字节'中'E4 B8 AD
辅助平面(U+10000+,含 emoji)4 字节'😀'F0 9F 98 80

UTF-8 的妙处:

  • 完全兼容 ASCII——纯英文的 UTF-8 文件就是 ASCII 文件。
  • 变长但自同步——读到中间字节能识别”这是某个字符的第几字节”,不会错位。
  • 空间高效——英文 1 字节,中文 3 字节,比 UTF-16/UTF-32 节省。

这是为什么 UTF-8 成了互联网的事实标准——HTML、JSON、源代码默认都是 UTF-8。

6.6 一句话区分

术语是什么
ASCII128 字符的英文字符集 + 编码
ISO-8859-1256 字符的西欧扩展,1 字节 1 字符
GBK中文编码,变长(1 或 2 字节)
Unicode全球字符集,给每个字符一个码点
UTF-8Unicode 的编码方案,变长(1~4 字节)
UTF-16Unicode 的编码方案,2 或 4 字节
CharsetJava 中表示编码方案的类

记住:Unicode 是”谁是谁”,UTF-8 是”怎么存”

七、实战:读取 UTF-8 文本文件并统计行数

下面这个例子用 BufferedReader 读取一个 UTF-8 文本文件,统计行数、字数、字符数:

Java · 在线运行

三种读取方式对比

  • BufferedReader.readLine():经典方式,可控性最强。
  • Files.readAllLines:一次性读完所有行到 List<String>,简单但大文件占内存。
  • Files.lines:返回 Stream<String>,惰性读取,适合大文件 + 流式处理(下章详解)。

八、字符流 vs 字节流:何时用哪个

场景用字节流用字符流
文本文件(txt/csv/json/html)
二进制文件(图片/视频/zip)
序列化对象
网络原始字节
HTTP 文本响应可以✅(指定编码后更安全)

铁律文本用字符流,二进制用字节流。混用必有坑——比如用 Reader 读 PNG 文件,字节被错误解码成字符,再 write 回去必然损坏。

九、常见编码陷阱

9.1 乱码来源

乱码无非两种:

  1. 解码错误:用 GBK 解码 UTF-8 文件——UTF-8 的 3 字节汉字被当成 GBK 的 1.5 个汉字。
  2. 编码缺失:字符在目标编码里不存在——比如 emoji 😀 在 GBK 里没有对应码点,写入时变成 ?

9.2 String 与字节互转

String s = "你好";
byte[] utf8 = s.getBytes(StandardCharsets.UTF_8);       // 6 字节
byte[] gbk  = s.getBytes(Charset.forName("GBK"));        // 4 字节

String back = new String(utf8, StandardCharsets.UTF_8);  // 正确还原
String wrong = new String(utf8, Charset.forName("GBK")); // 乱码

永远传 Charset 参数——getBytes() 不带参数会用平台默认编码,是跨平台 bug 的常见根源。

9.3 检查文件编码

Java 没有内置”猜编码”的方法。常见做法:

  • 优先信任元信息(HTTP 头 Content-Type: text/html; charset=utf-8、HTML 里的 <meta charset>)。
  • 没有元信息时用第三方库 juniversalchardet
  • 实在不行试 UTF-8(失败再降级到 GBK)——UTF-8 的字节有严格模式,错误的字节会抛 MalformedInputException

十、本章速查表

用途关键方法
Reader / Writer字符流抽象基类read / write / append
InputStreamReader / OutputStreamWriter字节↔字符桥梁(指定编码)构造器传 Charset
FileReader / FileWriter文件便捷类(Java 11+ 支持编码)构造器
BufferedReader / BufferedWriter加缓冲、按行读写readLine / newLine
PrintWriter格式化输出、不抛异常print / println / printf
StringReader / StringWriter字符串当源/目标内存读写

结语:让文本处理变得优雅

字符流是字节流之上的”语义层”——它把”字节流按编码解码成字符”这件麻烦事封装掉,让你专注处理文本本身。掌握它的关键有三:

  • 桥梁流是核心InputStreamReader/OutputStreamWriter 是字符流唯一接触字节的地方,编码在这里指定。
  • 永远显式指定编码StandardCharsets.UTF_8 是你最好的朋友。不指定就用默认值,是乱码 bug 的源头。
  • BufferedReader.readLine 是神器:处理文本文件 90% 的需求,用 readLine + try-with-resources 就够了。

理解了字符编码从 ASCII 到 UTF-8 的演进,你对”为什么会出现乱码”会有一种透视感——乱码不是玄学,是字节和字符之间编码错配的结果。

下一章我们看序列化——把内存中的对象”冻结”成字节流,存到文件或传到网络。那是另一种”对象 ↔ 字节”的转换艺术。