Skip to content

缓冲与字符编码

IO 学不会,常见线上问题不是“不会写 read/write”,而是:

  1. 为什么加了 BufferedInputStream 会快。
  2. 为什么中文变成乱码。
  3. 为什么大文件导入会 OOM。
  4. 为什么输出流不 flush 数据就没出去。

这几个问题都和缓冲、编码、内存有关。

学习目标

学完本页,你应该能说明:

  1. 缓冲区为什么能提升 IO 性能。
  2. flushclose 的区别。
  3. 字节、字符、编码、解码是什么关系。
  4. 乱码为什么发生。
  5. 大文件为什么不能一次性读入内存。
  6. JDK7/JDK8 中常见文本读写应该怎么写。

为什么缓冲能变快

没有缓冲时,程序可能每读一个字节就访问一次底层资源。访问磁盘、网络、系统调用都比访问内存慢得多。

mermaid
flowchart TD
    A["应用程序每次读 1 字节"] --> B["多次系统调用"]
    B --> C["频繁访问磁盘或网络"]
    C --> D["性能差"]

有缓冲后,底层一次读一大块到内存缓冲区,应用程序后续从内存里慢慢取。

mermaid
flowchart TD
    A["应用程序读取"] --> B["内存缓冲区"]
    B --> C{"缓冲区还有数据"}
    C -- "有" --> D["直接从内存返回"]
    C -- "没有" --> E["一次从磁盘读一批数据"]
    E --> B

缓冲不是让磁盘变快,而是减少慢操作次数。

输入缓冲和输出缓冲

类型作用常见类
输入缓冲一次从底层读一批,程序逐步消费BufferedInputStreamBufferedReader
输出缓冲程序先写到内存,攒够一批再写底层BufferedOutputStreamBufferedWriter

输出流为什么要 flush

mermaid
flowchart TD
    A["out.write()"] --> B["写入输出缓冲区"]
    B --> C{"缓冲区满了吗"}
    C -- "否" --> D["数据可能还没到磁盘或网络"]
    C -- "是" --> E["自动刷到底层"]
    D --> F["flush() 或 close()"]
    F --> E

flush 表示把缓冲区里的数据推到底层资源;close 通常会先 flush 再关闭资源。

不要误解:flush 不一定等于数据已经永久落到磁盘。操作系统和磁盘控制器还可能有自己的缓存。如果需要更强落盘保证,要看 FileChannel.force

字节、字符、编码、解码

文件里存的永远是字节,不是 Java 字符。

概念含义
字节最底层的二进制数据
字符人看到的文字,例如 A
编码字符转字节,例如 String 写入文件
解码字节转字符,例如读取文件显示文本
mermaid
flowchart TD
    A["字符:中文"] --> B["按 UTF-8 编码"]
    B --> C["字节序列"]
    C --> D["按 UTF-8 解码"]
    D --> E["字符:中文"]
    C --> F["按 GBK 错误解码"]
    F --> G["乱码"]

乱码的本质是:

写入时用一种字符集编码,读取时用另一种字符集解码。

为什么 UTF-8 是默认建议

UTF-8 是现在服务端开发最常用的文本编码。它兼容 ASCII,能表示中文、英文、符号,适合跨系统传输。

商业项目建议统一:

  1. 源码文件使用 UTF-8。
  2. 配置文件使用 UTF-8。
  3. 数据库连接使用 UTF-8/utf8mb4。
  4. HTTP 请求响应声明 UTF-8。
  5. 文件读写显式指定 UTF-8。

不要依赖系统默认编码。Windows 老系统默认编码可能不是 UTF-8,Linux 也可能被环境变量影响。

JDK7/8 文本读取推荐写法

JDK7 开始可以使用 Files.newBufferedReader

java
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class ReadTextDemo {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("orders.txt");

        try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

为什么不用下面这种?

java
new FileReader("orders.txt")

因为 FileReader 使用平台默认编码。平台一变,结果可能就变。

JDK8 Files.lines

JDK8 可以用 Files.lines 返回 Stream:

java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Stream;

public class FilesLinesDemo {
    public static void main(String[] args) throws IOException {
        try (Stream<String> lines = Files.lines(Paths.get("orders.txt"), StandardCharsets.UTF_8)) {
            lines.filter(line -> line.contains("PAID"))
                 .forEach(System.out::println);
        }
    }
}

注意:Files.lines 返回的 Stream 也持有文件资源,必须关闭。用 try-with-resources 包起来。

大文件为什么不能 readAllBytes

很多初学者喜欢这样写:

java
byte[] bytes = Files.readAllBytes(Paths.get("big-file.zip"));

小文件可以,大文件危险。因为它会把整个文件一次性放进堆内存。

mermaid
flowchart TD
    A["10GB 文件"] --> B["readAllBytes"]
    B --> C["申请 10GB byte[]"]
    C --> D{"堆内存够吗"}
    D -- "不够" --> E["OutOfMemoryError"]
    D -- "够" --> F["GC 压力巨大"]

大文件应该分块处理:

java
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class ChunkReadDemo {
    public static void main(String[] args) throws IOException {
        long total = 0;

        try (InputStream in = new BufferedInputStream(new FileInputStream("big-file.dat"))) {
            byte[] buffer = new byte[64 * 1024];
            int len;
            while ((len = in.read(buffer)) != -1) {
                total += len;
                // 在这里处理本批数据,例如计算摘要、解析记录、写入下游
            }
        }

        System.out.println("total bytes = " + total);
    }
}

商业场景:批量导入 CSV

批量导入不能把所有行都读到 List 再一次性入库,数据量大时会 OOM。更常见的做法是分批读取、分批校验、分批写入。

mermaid
flowchart TD
    A["打开 CSV 文件"] --> B["按行读取"]
    B --> C["解析成对象"]
    C --> D["放入批次列表"]
    D --> E{"批次满 1000 条"}
    E -- "否" --> B
    E -- "是" --> F["批量校验和入库"]
    F --> G["清空批次"]
    G --> B
    B --> H["文件结束"]
    H --> I["处理最后不足 1000 条的数据"]

示例:

java
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

public class CsvImportDemo {
    public static void main(String[] args) throws IOException {
        importCsv("patient.csv");
    }

    public static void importCsv(String file) throws IOException {
        List<String> batch = new ArrayList<String>(1000);

        try (BufferedReader reader = Files.newBufferedReader(Paths.get(file), StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.trim().isEmpty()) {
                    continue;
                }

                batch.add(line);
                if (batch.size() == 1000) {
                    saveBatch(batch);
                    batch.clear();
                }
            }
        }

        if (!batch.isEmpty()) {
            saveBatch(batch);
        }
    }

    private static void saveBatch(List<String> batch) {
        System.out.println("save " + batch.size() + " rows");
        // 真实项目中这里做参数校验、去重、批量入库、错误行记录
    }
}

常见坑

问题原因处理
中文乱码编码和解码字符集不一致显式使用 StandardCharsets.UTF_8
写完文件内容不完整输出流缓冲未刷出flush 或正确 close
大文件导入 OOM一次性读入内存分块或按行处理
Files.lines 后文件无法删除Stream 未关闭try-with-resources
CSV 解析错列直接 split(",") 不支持引号和转义商业项目使用成熟 CSV 解析库
接口返回乱码响应头没声明编码设置 Content-Type: application/json;charset=UTF-8

面试标准回答

缓冲区能提升 IO 性能,是因为它把多次小 IO 合并成较少的大 IO,减少系统调用和底层资源访问次数。字符编码解决的是字符和字节之间的转换,乱码通常是编码和解码使用的字符集不一致。JDK7/8 中读文本建议用 Files.newBufferedReader(path, StandardCharsets.UTF_8),不要依赖平台默认编码。处理大文件要分块或按行读取,不能随便 readAllBytes,否则可能造成堆内存 OOM。

关联知识点