Skip to content

第20课:IO流 - 字符流

🎯 学习目标

  • 理解字符流与字节流的区别(何时用哪个)
  • 理解字符编码(UTF-8/GBK)与字符流的关系
  • 掌握 Reader/Writer 体系、FileReader/FileWriter、BufferedReader/Writer
  • 掌握指定编码读写(InputStreamReader/OutputStreamWriter)
  • 识别陷阱(FileReader 默认编码、乱码、跨平台换行)

📖 一、概念讲解:为什么需要字符流

1. 字节流处理文本的问题

字节流以 byte 为单位。但文本是"字符",一个字符可能占多个字节(UTF-8 中文 3 字节)。用字节流读文本:

java
// ❌ 中文可能乱码
try (FileInputStream fis = new FileInputStream("中文.txt")) {
    int b;
    while ((b = fis.read()) != -1) {
        System.out.print((char) b);   // 多字节字符被拆碎,乱码
    }
}

字符流 = 字节流 + 编码,自动按编码把字节组合成字符,正确处理多字节字符。

2. Reader/Writer 体系

Reader(抽象基类,读字符)
  ├─ InputStreamReader(字节→字符桥梁,指定编码)
  │    └─ FileReader(= InputStreamReader + FileInputStream,用默认编码)
  └─ BufferedReader(处理流,加缓冲 + readLine)
Writer(写字符)
  ├─ OutputStreamWriter(字符→字节桥梁,指定编码)
  │    └─ FileWriter(= OutputStreamWriter + FileOutputStream,用默认编码)
  └─ BufferedWriter(处理流,加缓冲 + newLine)

关键:字符流的根是字节流——Reader/Writer 内部仍读字节,只是按编码解码成字符。


📖 二、基本读写:FileReader/FileWriter

java
// 写字符(覆盖模式)
try (FileWriter fw = new FileWriter("text.txt")) {
    fw.write("你好世界\n第二行");
}

// 读字符(单字符)
try (FileReader fr = new FileReader("text.txt")) {
    int ch;
    while ((ch = fr.read()) != -1) {   // read 返回 char(0-65535)
        System.out.print((char) ch);
    }
}

// 追加模式
try (FileWriter fw = new FileWriter("text.txt", true)) {
    fw.write("\n追加内容");
}

注意:FileReader/FileWriter 用平台默认编码(中文 Windows 通常是 GBK,Linux/Mac 通常 UTF-8)。跨平台或需指定编码时,用 InputStreamReader/OutputStreamWriter(见下)。


📖 三、逐行读写:BufferedReader/Writer

java
// 逐行读
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {   // 读一行(不含换行符)
        System.out.println(line);
    }
}

// 逐行写
try (BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt"))) {
    bw.write("第一行");
    bw.newLine();          // 跨平台换行(Windows \r\n, Linux \n)
    bw.write("第二行");
}

readLine 优势:一次读一行返回 String,比单字符 read() 高效且方便。newLine() 用系统换行符,跨平台一致。


📖 四、指定编码:InputStreamReader/OutputStreamWriter

java
import java.nio.charset.StandardCharsets;

// 用 UTF-8 读
try (BufferedReader br = new BufferedReader(
        new InputStreamReader(new FileInputStream("file.txt"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
}

// 用 UTF-8 写
try (BufferedWriter bw = new BufferedWriter(
        new OutputStreamWriter(new FileOutputStream("out.txt"), StandardCharsets.UTF_8))) {
    bw.write("UTF-8 编码写入");
}

为什么用这个而不是 FileReader:FileReader 用平台默认编码,在 GBK 系统读 UTF-8 文件会乱码。InputStreamReader 显式指定编码,跨平台一致。

JDK 11+ FileReader 增加了带 Charset 的构造器:new FileReader("f.txt", StandardCharsets.UTF_8),更简洁。


📖 五、编码原理

编码特点中文占用
UTF-8变长,ASCII 1字节,中文 3字节,国际通用3 字节
GBK定长双字节,中文 Windows 默认2 字节
UTF-16双字节(基本平面)2 字节
ISO-8859-1单字节,不支持中文

乱码根因:读和写用了不同编码。写用 UTF-8,读用 GBK → 字节被错误切分 → 乱码。

规则:读文件的编码必须等于写文件的编码。不确定时探测文件 BOM 或统一用 UTF-8。


⚠️ 六、常见陷阱

陷阱1:FileReader 默认编码导致跨平台乱码

java
// 在 UTF-8 Linux 写的文件,在 GBK Windows 用 FileReader 读 → 乱码
try (FileReader fr = new FileReader("file.txt")) { ... }

规避:用 InputStreamReader(..., StandardCharsets.UTF_8) 显式指定。

陷阱2:readLine 后丢失换行符

readLine() 返回的字符串不含换行符。需要原样保留换行用 line + "\n"System.lineSeparator()

陷阱3:字符流读二进制文件

用字符流读图片/视频等二进制 → 字节被当字符解码 → 损坏。二进制必须用字节流。

陷阱4:不刷新缓冲

BufferedWriter 写后不 close/flush → 数据在缓冲未落盘。try-with-resources 的 close 自动 flush。

陷阱5:手动拼换行 \n 跨平台不一致

\n 在 Windows 是 LF,而 Windows 期望 CRLF。用 bw.newLine()System.lineSeparator()


🆚 七、Java vs C 对比

特性C 语言Java
文本读写fgets/fputs,字节与字符不分(char=byte)字节流/字符流分离
编码靠 locale,易乱码显式指定 Charset
逐行读fgets 读到换行readLine
跨平台换行\n(Windows 需 \r\n)newLine() 自动适配

对 C 程序员:C 里 char 就是字节,文本与二进制用同一套 fopen。Java 区分字节流/字符流,字符流自动处理编码——这对处理多字节字符(中文)更安全。记住:文本用字符流,二进制用字节流。


💡 八、最佳实践

  1. 文本用字符流,二进制用字节流——根据数据类型选。
  2. 显式指定编码:用 InputStreamReader/OutputStreamWriter + UTF-8,不依赖平台默认。
  3. 用 BufferedReader/Writer:readLine 和缓冲提升性能与便利。
  4. 一律 try-with-resources 关闭。
  5. 跨平台换行用 newLine(),不用硬编码 \n。
  6. 大文本逐行处理,避免一次性读入内存。

📝 练习预告

完成 练习/Ex20_CharStream.java 中的 6 道题:

  1. FileWriter/FileWriter 写读中文
  2. BufferedReader 逐行读
  3. 指定 UTF-8 编码读写
  4. 文本文件复制(字符流)
  5. 编码转换(GBK→UTF-8)
  6. 综合:统计文件行数/单词数/字符数

完成后对比 答案/Sol20.java,查看逐行讲解与多解法。


📖 九、编码与 Charset

Java 中编码由 Charset 表达。

java
Charset utf8 = StandardCharsets.UTF_8;
Charset gbk = Charset.forName("GBK");

推荐优先使用 StandardCharsets

text
StandardCharsets.UTF_8。
StandardCharsets.UTF_16。
StandardCharsets.ISO_8859_1。
StandardCharsets.US_ASCII。

原因:

text
避免拼写错误。
不需要处理 UnsupportedEncodingException。
可读性更好。

编码转换本质:

text
读:字节 + 源编码 -> 字符。
写:字符 + 目标编码 -> 字节。

如果源编码判断错误,后续再怎么处理字符串都已经晚了,因为字符已经被错误解码。


📖 十、BOM 与文件编码

BOM 是 Byte Order Mark,常出现在 UTF 编码文件开头。

常见现象:

text
UTF-8 BOM 文件开头有 EF BB BF。
读取后第一行第一个字符可能是不可见的 \uFEFF。
配置文件解析时可能因为 BOM 导致 key 不匹配。

处理方式:

java
String firstLine = br.readLine();
if (firstLine != null && firstLine.startsWith("\uFEFF")) {
    firstLine = firstLine.substring(1);
}

实践建议:

text
团队统一使用 UTF-8 无 BOM。
从外部系统接收文件时,考虑 BOM 清理。
对配置、CSV、导入文件写边界测试。

🧪 十一、实战案例:逐行处理日志

需求:读取大日志文件,统计 ERROR 行数。

java
public static long countErrorLines(Path path) throws IOException {
    long count = 0;
    try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
        String line;
        while ((line = reader.readLine()) != null) {
            if (line.contains("ERROR")) {
                count++;
            }
        }
    }
    return count;
}

为什么逐行处理?

text
内存稳定。
逻辑清晰。
适合日志、CSV、文本导入。
不需要一次性加载整个文件。

如果需要保留换行符,readLine 不适合直接原样复制,因为它会丢失行结束符。


🧪 十二、实战案例:编码转换

把 GBK 文件转换成 UTF-8:

java
public static void convertGbkToUtf8(Path source, Path target) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(source, Charset.forName("GBK"));
         BufferedWriter writer = Files.newBufferedWriter(target, StandardCharsets.UTF_8)) {
        String line;
        while ((line = reader.readLine()) != null) {
            writer.write(line);
            writer.newLine();
        }
    }
}

注意:

text
这种写法会统一换行符。
如果必须保留原始字节级格式,不能用字符流转换。
如果文件不是 GBK,读取阶段已经会乱码。

📖 十三、PrintWriter 与格式化输出

PrintWriter 适合写人类可读文本。

java
try (PrintWriter writer = new PrintWriter(
        Files.newBufferedWriter(Path.of("report.txt"), StandardCharsets.UTF_8))) {
    writer.printf("name=%s, score=%d%n", "Alice", 95);
}

特点:

text
提供 print、println、printf。
适合生成报告、日志、简单文本。
默认不抛 IOException,而是记录错误状态。

如果需要严格处理写入失败,应检查:

java
if (writer.checkError()) {
    throw new IOException("write failed");
}

🛠 十四、字符流排查清单

常见问题:

text
FileReader 使用平台默认编码导致乱码。
读写编码不一致。
readLine 丢失换行符。
手写 \n 导致跨平台差异。
用字符流处理二进制文件导致损坏。
文件开头有 BOM。
一次性读取大文本导致内存压力。
PrintWriter 吞掉异常。

排查建议:

text
确认文件真实编码。
读写都显式指定 Charset。
跨平台换行用 newLine 或 %n。
导入文件先处理 BOM。
大文件逐行处理。
二进制和文本严格分开。

✅ 十五、掌握标准

学完本课后,应能做到:

text
能解释字符流为什么需要编码。
能区分 Reader/Writer 和 InputStream/OutputStream。
能用 BufferedReader 逐行读取文本。
能用 OutputStreamWriter 指定编码写文本。
能解释 FileReader 默认编码的风险。
能处理 readLine 不保留换行的问题。
能完成 GBK 到 UTF-8 的文本转换。
能识别 BOM、乱码、二进制误读等问题。
能判断什么时候应保留原始换行符,什么时候可以统一换行符。
能说明 PrintWriter 吞异常的风险。

字符流的核心是“字节与字符之间的编码转换”。只要文本跨机器、跨系统、跨语言,就必须显式控制编码。


🎓 下一步

  • 第21课:IO流 - 对象序列化 — Serializable、transient、serialVersionUID