缓冲与字符编码
IO 学不会,常见线上问题不是“不会写 read/write”,而是:
- 为什么加了
BufferedInputStream会快。 - 为什么中文变成乱码。
- 为什么大文件导入会 OOM。
- 为什么输出流不 flush 数据就没出去。
这几个问题都和缓冲、编码、内存有关。
学习目标
学完本页,你应该能说明:
- 缓冲区为什么能提升 IO 性能。
flush和close的区别。- 字节、字符、编码、解码是什么关系。
- 乱码为什么发生。
- 大文件为什么不能一次性读入内存。
- JDK7/JDK8 中常见文本读写应该怎么写。
为什么缓冲能变快
没有缓冲时,程序可能每读一个字节就访问一次底层资源。访问磁盘、网络、系统调用都比访问内存慢得多。
flowchart TD
A["应用程序每次读 1 字节"] --> B["多次系统调用"]
B --> C["频繁访问磁盘或网络"]
C --> D["性能差"]有缓冲后,底层一次读一大块到内存缓冲区,应用程序后续从内存里慢慢取。
flowchart TD
A["应用程序读取"] --> B["内存缓冲区"]
B --> C{"缓冲区还有数据"}
C -- "有" --> D["直接从内存返回"]
C -- "没有" --> E["一次从磁盘读一批数据"]
E --> B缓冲不是让磁盘变快,而是减少慢操作次数。
输入缓冲和输出缓冲
| 类型 | 作用 | 常见类 |
|---|---|---|
| 输入缓冲 | 一次从底层读一批,程序逐步消费 | BufferedInputStream、BufferedReader |
| 输出缓冲 | 程序先写到内存,攒够一批再写底层 | BufferedOutputStream、BufferedWriter |
输出流为什么要 flush?
flowchart TD
A["out.write()"] --> B["写入输出缓冲区"]
B --> C{"缓冲区满了吗"}
C -- "否" --> D["数据可能还没到磁盘或网络"]
C -- "是" --> E["自动刷到底层"]
D --> F["flush() 或 close()"]
F --> Eflush 表示把缓冲区里的数据推到底层资源;close 通常会先 flush 再关闭资源。
不要误解:flush 不一定等于数据已经永久落到磁盘。操作系统和磁盘控制器还可能有自己的缓存。如果需要更强落盘保证,要看 FileChannel.force。
字节、字符、编码、解码
文件里存的永远是字节,不是 Java 字符。
| 概念 | 含义 |
|---|---|
| 字节 | 最底层的二进制数据 |
| 字符 | 人看到的文字,例如 中、A |
| 编码 | 字符转字节,例如 String 写入文件 |
| 解码 | 字节转字符,例如读取文件显示文本 |
flowchart TD
A["字符:中文"] --> B["按 UTF-8 编码"]
B --> C["字节序列"]
C --> D["按 UTF-8 解码"]
D --> E["字符:中文"]
C --> F["按 GBK 错误解码"]
F --> G["乱码"]乱码的本质是:
写入时用一种字符集编码,读取时用另一种字符集解码。
为什么 UTF-8 是默认建议
UTF-8 是现在服务端开发最常用的文本编码。它兼容 ASCII,能表示中文、英文、符号,适合跨系统传输。
商业项目建议统一:
- 源码文件使用 UTF-8。
- 配置文件使用 UTF-8。
- 数据库连接使用 UTF-8/utf8mb4。
- HTTP 请求响应声明 UTF-8。
- 文件读写显式指定 UTF-8。
不要依赖系统默认编码。Windows 老系统默认编码可能不是 UTF-8,Linux 也可能被环境变量影响。
JDK7/8 文本读取推荐写法
JDK7 开始可以使用 Files.newBufferedReader:
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);
}
}
}
}为什么不用下面这种?
new FileReader("orders.txt")因为 FileReader 使用平台默认编码。平台一变,结果可能就变。
JDK8 Files.lines
JDK8 可以用 Files.lines 返回 Stream:
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
很多初学者喜欢这样写:
byte[] bytes = Files.readAllBytes(Paths.get("big-file.zip"));小文件可以,大文件危险。因为它会把整个文件一次性放进堆内存。
flowchart TD
A["10GB 文件"] --> B["readAllBytes"]
B --> C["申请 10GB byte[]"]
C --> D{"堆内存够吗"}
D -- "不够" --> E["OutOfMemoryError"]
D -- "够" --> F["GC 压力巨大"]大文件应该分块处理:
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。更常见的做法是分批读取、分批校验、分批写入。
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 条的数据"]示例:
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。
