首页 / Java 入门教程 / 字节流

Java 入门教程

字节流

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

JavaJava 入门教程字节流InputStreamOutputStream文件读写

86. 字节流

本节目标:掌握 InputStream 和 OutputStream 的核心用法,能用字节流完成任意文件的读取与复制。

字节流处理的是原始数据

字节流是 Java I/O 的地基。所有其他流,包括字符流,底层都建立在字节流之上。

它搬运的单位是字节,不做任何解释。一张 JPG 图片、一个 ZIP 压缩包、一段 MP4 视频,在字节流眼里都只是一长串 0 到 255 的数字。

两个抽象基类分工明确:InputStream 负责读,OutputStream 负责写。

InputStream 的 read 方法

InputStream 最核心的方法只有一个:

public abstract int read() throws IOException;
```bash

这个方法读一个字节,然后返回。

新手第一个疑问通常是:既然读的是字节,为什么返回类型是 `int` 而不是 `byte`?

原因在于要留一个值表示「读完了」。Java 的 `byte` 是有符号的,取值范围 -1281278 位全被占满,挤不出一个特殊值。

换成 `int` 后,正常读到的字节被提升为 0255 的正数,剩下的负数区间就空出来了。Java 选用 `-1` 表示流已到末尾。

> [!WARNING]
> 千万别写 `byte b = input.read();`。这样一来 `-1` 会被截断成 `(byte) -1`,也就是 `0xFF`,跟正常读到的 255 撞车,循环永远停不下来。

## 逐字节读取文件

把上面的规则写成循环,就是最基础的读文件代码:

```java
import java.io.*;

public class ReadBytes {
    public static void main(String[] args) throws IOException {
        // 先造一个文件,保证示例可以独立运行
        try (OutputStream out = new FileOutputStream("bytes.txt")) {
            out.write(new byte[] {72, 101, 108, 108, 111}); // Hello
        }

        try (InputStream input = new FileInputStream("bytes.txt")) {
            int n;
            while ((n = input.read()) != -1) { // 读到 -1 就结束
                System.out.print((char) n);
            }
        }
        System.out.println();
    }
}

运行:

javac ReadBytes.java
java ReadBytes
```bash

输出 `Hello`

`while ((n = input.read()) != -1)` 这个写法把「读取」和「判断」合并成一行,是 Java I/O 的固定套路,看多了就习惯了。

## 用缓冲区批量读取

逐字节读虽然能跑,但慢得离谱。每调一次 `read()`,底层就可能触发一次系统调用,开销远大于搬运一个字节本身。

`InputStream` 提供了两个重载方法来一次读一批:

- `int read(byte[] b)`:尽量填满数组 `b`,返回实际读到的字节数
- `int read(byte[] b, int off, int len)`:指定填充的起始位置和最大长度

注意返回值含义变了:不再是字节的值,而是**这次读了多少个字节**。返回 `-1` 仍然表示到达末尾。

```java
import java.io.*;

public class BufferedRead {
    public static void main(String[] args) throws IOException {
        try (OutputStream out = new FileOutputStream("big.dat")) {
            for (int i = 0; i < 5000; i++) {
                out.write(i % 256);
            }
        }

        try (InputStream input = new FileInputStream("big.dat")) {
            byte[] buffer = new byte[1024]; // 1KB 缓冲区
            int n;
            int total = 0;
            while ((n = input.read(buffer)) != -1) {
                total += n; // n 是本次实际读到的字节数
            }
            System.out.println("共读取 " + total + " 字节");
        }
    }
}

输出:

共读取 5000 字节
```bash

> [!TIP]
> 缓冲区大小取 1024、4096、8192 这类 2 的幂比较常见,跟操作系统的页大小对齐,效率好。太小起不到作用,太大浪费内存。

## OutputStream 的写入

`OutputStream` 的核心方法是 `write()`,同样有三个版本:

- `write(int b)`:写一个字节,只取参数的低 8 位
- `write(byte[] b)`:写整个数组
- `write(byte[] b, int off, int len)`:写数组的一部分

还有一个 `flush()` 方法值得单独说。

出于效率考虑,很多输出流会先把数据攒在内存里,攒够一批再真正写出去。`flush()` 的作用是「别攒了,现在就写」。

`close()` 内部会自动调用 `flush()`,所以正常关闭的流不会丢数据。但如果流要长期开着(比如网络连接),就需要在关键时刻手动 `flush()`。

## 追加写入

`FileOutputStream` 默认会清空原文件重新写。想在末尾追加,构造时传入第二个参数 `true`:

```java
import java.io.*;

public class AppendDemo {
    public static void main(String[] args) throws IOException {
        try (OutputStream out = new FileOutputStream("log.txt")) {
            out.write("第一行\n".getBytes("UTF-8"));
        }
        // 第二个参数 true 表示追加,不覆盖
        try (OutputStream out = new FileOutputStream("log.txt", true)) {
            out.write("第二行\n".getBytes("UTF-8"));
        }

        try (InputStream in = new FileInputStream("log.txt")) {
            System.out.println(new String(in.readAllBytes(), "UTF-8"));
        }
    }
}

输出:

第一行
第二行
```bash

`readAllBytes()` 是 Java 9 加进来的便捷方法,一次性读完整个流。方便,但只适合小文件。

## 常用实现类

`FileInputStream` 和 `FileOutputStream` 连接硬盘文件,是用得最多的一对。

`ByteArrayInputStream` 和 `ByteArrayOutputStream` 连接内存中的 `byte` 数组。写单元测试时很好用——不用真的建文件,就能构造出一个流:

```java
import java.io.*;

public class MemoryStream {
    public static void main(String[] args) throws IOException {
        byte[] data = {74, 97, 118, 97}; // Java
        try (InputStream input = new ByteArrayInputStream(data)) {
            int n;
            while ((n = input.read()) != -1) {
                System.out.print((char) n);
            }
        }
        System.out.println();
    }
}

这里体现了一个重要原则:方法参数尽量声明成 InputStream 而不是 FileInputStream。这样同一个方法既能处理文件,也能处理内存数据和网络数据。

复制文件的标准写法

把读和写拼起来,就是文件复制:

import java.io.*;

public class CopyFile {
    public static void main(String[] args) throws IOException {
        try (OutputStream seed = new FileOutputStream("source.bin")) {
            seed.write("待复制的内容".getBytes("UTF-8"));
        }

        try (InputStream in = new FileInputStream("source.bin");
             OutputStream out = new FileOutputStream("target.bin")) {
            byte[] buffer = new byte[4096];
            int n;
            while ((n = in.read(buffer)) != -1) {
                out.write(buffer, 0, n); // 必须传 n,不能写整个数组
            }
        }
        System.out.println("复制完成");
    }
}
```bash

> [!WARNING]
> `out.write(buffer, 0, n)` 里的 `n` 不能省。最后一次读取往往填不满缓冲区,直接写整个数组会把上一轮的残留数据也写进去,文件末尾就多出一段垃圾。这是复制文件时最经典的 bug。

try-with-resources 括号里可以用分号声明多个资源,关闭顺序与声明顺序相反。

## 跳过与探测:skip 和 available

除了读和写,`InputStream` 还有两个偶尔会用到的方法。

`long skip(long n)` 表示跳过 `n` 个字节不读。比如某种文件格式的前 128 字节是文件头,你只关心正文,就可以先跳过去。它的返回值是实际跳过的字节数,可能比要求的少,因为流可能提前结束了。

`int available()` 返回当前不阻塞就能读到的字节数。对本地文件来说,这个值通常等于剩余长度;但对网络流来说,它只反映此刻网卡缓冲区里的数据量,跟对方总共要发多少毫无关系。

> [!WARNING]
> 很多教程会写 `byte[] buf = new byte[in.available()]` 一次性读完,这个写法在文件上碰巧能跑通,换成网络流就会读到半截数据。养成用循环读的习惯,别依赖 `available()` 判断长度。

## 字节流适合什么、不适合什么

判断标准其实很简单:看你关不关心内容的「含义」。

处理图片、音频、视频、压缩包、可执行文件这类二进制数据,字节流是唯一正确的选择。这些数据本来就没有「字符」的概念,逐字节原样搬运才不会损坏。

做文件复制、上传下载、加密解密时也一样。中间环节根本不需要理解内容,转成字符反而多此一举,还有编码出错的风险。

反过来,如果要读一个 UTF-8 的配置文件、按行处理日志、拼接字符串输出,那就该用下一节的字符流。用字节流硬读中文,很容易在缓冲区边界把一个汉字劈成两半,拼出乱码。

> [!NOTE]
> 一个汉字在 UTF-8 下占 3 个字节。假设缓冲区是 1024 字节,某次刚好读到某个汉字的第 2 个字节就截断了,把这一批转成字符串必然出现问号。字符流之所以存在,就是为了替你处理这类边界问题。

## 本节小结

- `read()` 返回 `int`,`-1` 表示流结束,绝不能用 `byte` 接收。
- 逐字节读效率极低,实际开发一律用 `byte[]` 缓冲区批量读。
- 批量读时返回值是本次读到的字节数,写出时必须带上这个长度。
- `FileOutputStream` 第二个参数 `true` 表示追加写入。
- `close()` 会自动 `flush()`,长期开着的流才需要手动刷新。
- 方法参数用抽象类型 `InputStream`,让代码适配所有数据源。