首页 / Java 入门教程 / 缓冲流与转换流

Java 入门教程

缓冲流与转换流

本教程共 100 篇 · 第 88 篇 · 更新于 2026-08-05 · 约 5 分钟阅读

JavaJava 入门教程缓冲流BufferedReader转换流InputStreamReader

88. 缓冲流与转换流

本节目标:学会用缓冲流提速、用转换流架桥,掌握 Java I/O 里「包装」这一核心设计手法。

无缓冲的代价

前两章的例子里,每次 read() 都直接向操作系统要数据。

这就好比你去仓库搬砖,每次只搬一块。搬砖本身不费劲,来回跑才费劲。系统调用就是那段来回跑的路。

缓冲流的思路很朴素:准备一辆小推车,一次拉一整车回来。

缓冲流的四个类

按字节流/字符流、输入/输出组合,正好四个:

方向字节流字符流
输入BufferedInputStreamBufferedReader
输出BufferedOutputStreamBufferedWriter

它们都是处理流,不能单独存在,必须套在一个已有的流外面:

// 把无缓冲的 FileInputStream 包装成有缓冲的
InputStream in = new BufferedInputStream(new FileInputStream("data.bin"));

// 字符流同理
Reader reader = new BufferedReader(new FileReader("text.txt", StandardCharsets.UTF_8));
```bash

包装之后,你调用的还是同一套 `read()` 方法,用法完全不变。区别在于内部:缓冲区空了才真正去问操作系统要数据。

## 缓冲带来的差距

写个对比就能感受到:

```java
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);
            }
        }
    }
}
```text

输出:

```text
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 转成 Reader
  • OutputStreamWriter:把 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);
            }
        }
    }
}
```text

输出:

```text
读到:第一行
读到:第二行

三层结构看清楚:最里面是字节流拿原始数据,中间转换流负责解码,最外面缓冲流负责提速和按行读。

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);
```bash

`FileWriter` 与 `OutputStreamWriter` 的关系同理。

在 Java 11 之前,`FileReader` 不能指定字符集,那时候读非默认编码的文件只能老老实实写第二种形式。现在两种都行,选简洁的那个。

## 读取控制台输入

转换流有个经典用法:包装 `System.in`。

`System.in` 的类型是 `InputStream`,想按行读用户输入,就得先转换:

```java
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"))); // 第一层:数据源
```bash

关闭时只需要关最外层。外层流的 `close()` 会沿着链条依次调用内层的 `close()`。

反过来只关内层是错的——外层缓冲区里没刷出去的数据会丢。

> [!TIP]
> try-with-resources 里如果只写最外层那一个变量,也是完全正确的。写多个只是为了让每一层都有名字方便阅读。

## 缓冲区大小与 mark/reset

`BufferedInputStream` 默认缓冲区是 8192 字节,`BufferedReader` 默认是 8192 字符。这个默认值适合绝大多数场景,不用改。

构造时也可以自己指定:

```java
// 第二个参数是缓冲区大小
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
        }
    }
}
```bash

这个能力在做格式探测时有用:先读几个字节判断文件类型,判断完退回去,交给对应的解析器从头处理。

> [!NOTE]
> 裸的 `FileInputStream` 不支持 `mark`,调用 `reset()` 会抛 `IOException`。可以先用 `markSupported()` 判断。包一层缓冲流就支持了,这也是缓冲流的隐藏福利。

## 该不该每次都加缓冲

结论是:读写文件基本都该加。

代价只是一句包装和几 KB 内存,收益却是数量级的速度提升,这笔账怎么算都划算。

例外只有两种。一是本身已经带缓冲的类,比如 `Files.newBufferedReader()` 返回的对象,再包一层就是多余。二是内存流,`ByteArrayInputStream` 数据本来就在内存里,加缓冲纯属浪费。

## 本节小结

- 缓冲流用内存缓冲区减少系统调用,是性价比最高的一层包装。
- 四个缓冲类:`BufferedInputStream`、`BufferedOutputStream`、`BufferedReader`、`BufferedWriter`。
- `readLine()` 按行读,结束返回 `null` 而不是 `-1`。
- `newLine()` 写入平台相关换行符,比硬编码可靠。
- 转换流是字节流通往字符流的唯一桥梁,构造时指定字符集。
- 包装流从里到外叠加,关闭只需关最外层。