缓冲流与转换流
本教程共 100 篇 · 第 88 篇 · 更新于 2026-08-05 · 约 5 分钟阅读
本节目标:学会用缓冲流提速、用转换流架桥,掌握 Java I/O 里「包装」这一核心设计手法。
无缓冲的代价
前两章的例子里,每次 read() 都直接向操作系统要数据。
这就好比你去仓库搬砖,每次只搬一块。搬砖本身不费劲,来回跑才费劲。系统调用就是那段来回跑的路。
缓冲流的思路很朴素:准备一辆小推车,一次拉一整车回来。
缓冲流的四个类
按字节流/字符流、输入/输出组合,正好四个:
| 方向 | 字节流 | 字符流 |
|---|---|---|
| 输入 | BufferedInputStream | BufferedReader |
| 输出 | BufferedOutputStream | BufferedWriter |
它们都是处理流,不能单独存在,必须套在一个已有的流外面:
// 把无缓冲的 FileInputStream 包装成有缓冲的
InputStream in = new BufferedInputStream(new FileInputStream("data.bin"));
// 字符流同理
Reader reader = new BufferedReader(new FileReader("text.txt", StandardCharsets.UTF_8));
包装之后,你调用的还是同一套 read() 方法,用法完全不变。区别在于内部:缓冲区空了才真正去问操作系统要数据。
缓冲带来的差距
写个对比就能感受到:
import java.io.*;
public class BufferCompare {
public static void main(String[] args) throws IOException {
// 造一个 2MB 的文件
try (OutputStream out = new BufferedOutputStream(new FileOutputStream("test.dat"))) {
for (int i = 0; i < 2_000_000; i++) {
out.write(i % 128);
}
}
long t1 = System.currentTimeMillis();
try (InputStream in = new FileInputStream("test.dat")) {
while (in.read() != -1) { /* 逐字节,无缓冲 */ }
}
long t2 = System.currentTimeMillis();
try (InputStream in = new BufferedInputStream(new FileInputStream("test.dat"))) {
while (in.read() != -1) { /* 逐字节,有缓冲 */ }
}
long t3 = System.currentTimeMillis();
System.out.println("无缓冲耗时:" + (t2 - t1) + " ms");
System.out.println("有缓冲耗时:" + (t3 - t2) + " ms");
}
}
具体数字跟机器有关,但有缓冲那一行通常快一个数量级以上。同样是逐字节调 read(),差别全在系统调用次数上。
Tip缓冲流的默认缓冲区是 8192 字节。构造时可以传第二个参数自定义大小,但默认值对绝大多数场景已经够用,别急着调。
按行读取文本
BufferedReader 除了加速,还多了一个非常实用的方法:readLine()。
它一次读一整行,返回不含换行符的字符串。读到文件末尾返回 null。
import java.io.*;
import java.nio.charset.StandardCharsets;
public class ReadLines {
public static void main(String[] args) throws IOException {
try (BufferedWriter w = new BufferedWriter(
new FileWriter("names.txt", StandardCharsets.UTF_8))) {
w.write("张三");
w.newLine(); // 写入当前系统的换行符
w.write("李四");
w.newLine();
w.write("王五");
w.newLine();
}
try (BufferedReader reader = new BufferedReader(
new FileReader("names.txt", StandardCharsets.UTF_8))) {
String line;
int no = 1;
while ((line = reader.readLine()) != null) { // 注意判断的是 null
System.out.println(no++ + ". " + line);
}
}
}
}
输出:
1. 张三
2. 李四
3. 王五
Warning
readLine()的结束标志是null,不是-1。写成!= -1编译都过不了。这两个值别记混。
BufferedWriter.newLine() 会写入当前操作系统的换行符:Windows 是 \r\n,Linux 和 macOS 是 \n。比硬编码 "\n" 更稳妥。
BufferedReader 认所有三种换行符(\n、\r、\r\n),所以读别的系统生成的文件不会出问题。
转换流:字节与字符之间的桥
现在换个场景。你从网络连接拿到的是一个 InputStream(字节流),但传过来的内容是文本,你想按行读。
BufferedReader 只能包装 Reader,不能直接包装 InputStream。中间缺一环。
这一环就是转换流:
InputStreamReader:把InputStream转成ReaderOutputStreamWriter:把Writer的输出转成OutputStream
它们是字节世界和字符世界之间唯一的官方通道,转换时必须知道用什么编码,所以构造时可以传字符集。
import java.io.*;
import java.nio.charset.StandardCharsets;
public class BridgeDemo {
public static void main(String[] args) throws IOException {
// 模拟一个只能拿到 InputStream 的场景
byte[] raw = "第一行\n第二行\n".getBytes(StandardCharsets.UTF_8);
try (InputStream byteIn = new ByteArrayInputStream(raw);
Reader charIn = new InputStreamReader(byteIn, StandardCharsets.UTF_8);
BufferedReader reader = new BufferedReader(charIn)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("读到:" + line);
}
}
}
}
输出:
读到:第一行
读到:第二行
三层结构看清楚:最里面是字节流拿原始数据,中间转换流负责解码,最外面缓冲流负责提速和按行读。
FileReader 其实就是包装
理解了转换流,回头看 FileReader 就通透了。
FileReader 内部就是 FileInputStream + InputStreamReader。它只是把这个组合封装成了一个便捷类。
// 这两行效果等价
Reader r1 = new FileReader("a.txt", StandardCharsets.UTF_8);
Reader r2 = new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8);
FileWriter 与 OutputStreamWriter 的关系同理。
在 Java 11 之前,FileReader 不能指定字符集,那时候读非默认编码的文件只能老老实实写第二种形式。现在两种都行,选简洁的那个。
读取控制台输入
转换流有个经典用法:包装 System.in。
System.in 的类型是 InputStream,想按行读用户输入,就得先转换:
import java.io.*;
import java.nio.charset.StandardCharsets;
public class ConsoleInput {
public static void main(String[] args) throws IOException {
BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8));
System.out.print("请输入你的名字:");
String name = reader.readLine();
System.out.println("你好," + name);
}
}
Note
System.in由 JVM 管理,不要去close()它。关掉之后整个程序就再也读不到输入了。所以这里没用 try-with-resources。
包装流的嵌套与关闭
包装可以叠很多层,顺序有讲究:从里到外,依次是数据源、转换、缓冲、功能增强。
DataInputStream in = new DataInputStream( // 第四层:读基本类型
new BufferedInputStream( // 第三层:缓冲
new FileInputStream("x.dat"))); // 第一层:数据源
关闭时只需要关最外层。外层流的 close() 会沿着链条依次调用内层的 close()。
反过来只关内层是错的——外层缓冲区里没刷出去的数据会丢。
Tiptry-with-resources 里如果只写最外层那一个变量,也是完全正确的。写多个只是为了让每一层都有名字方便阅读。
缓冲区大小与 mark/reset
BufferedInputStream 默认缓冲区是 8192 字节,BufferedReader 默认是 8192 字符。这个默认值适合绝大多数场景,不用改。
构造时也可以自己指定:
// 第二个参数是缓冲区大小
BufferedReader reader = new BufferedReader(new FileReader("big.log"), 65536);
调大能减少读盘次数,但收益递减很快。从 8KB 调到 64KB 通常只快一点点,再往上基本没变化,反而多占内存。除非有实测数据支撑,否则别动。
缓冲流还带来一个附加能力:mark() 和 reset()。
mark(int limit) 在当前位置做个记号,reset() 回到记号处重新读。参数 limit 表示做完记号后最多还能读多少字节,超过这个量记号就失效了。
import java.io.*;
public class MarkReset {
public static void main(String[] args) throws IOException {
try (Writer w = new FileWriter("mark.txt")) {
w.write("ABCDEF");
}
try (BufferedReader r = new BufferedReader(new FileReader("mark.txt"))) {
System.out.println((char) r.read()); // A
r.mark(10); // 记住当前位置
System.out.println((char) r.read()); // B
System.out.println((char) r.read()); // C
r.reset(); // 回退到记号处
System.out.println((char) r.read()); // 又是 B
}
}
}
这个能力在做格式探测时有用:先读几个字节判断文件类型,判断完退回去,交给对应的解析器从头处理。
Note裸的
FileInputStream不支持mark,调用reset()会抛IOException。可以先用markSupported()判断。包一层缓冲流就支持了,这也是缓冲流的隐藏福利。
该不该每次都加缓冲
结论是:读写文件基本都该加。
代价只是一句包装和几 KB 内存,收益却是数量级的速度提升,这笔账怎么算都划算。
例外只有两种。一是本身已经带缓冲的类,比如 Files.newBufferedReader() 返回的对象,再包一层就是多余。二是内存流,ByteArrayInputStream 数据本来就在内存里,加缓冲纯属浪费。
本节小结
- 缓冲流用内存缓冲区减少系统调用,是性价比最高的一层包装。
- 四个缓冲类:
BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter。 readLine()按行读,结束返回null而不是-1。newLine()写入平台相关换行符,比硬编码可靠。- 转换流是字节流通往字符流的唯一桥梁,构造时指定字符集。
- 包装流从里到外叠加,关闭只需关最外层。