字节流

如果说文件是数据的归宿,那字节流(byte stream)就是数据流动的”水管”。计算机里的一切——文本、图片、视频、可执行文件——归根到底都是一串字节。Java 的字节流抽象让”读写字节”这件事变得统一:无论数据来自磁盘、网络还是内存,都用同一套 API 处理。

这一章,我们从最底层的 InputStream/OutputStream 抽象类讲起,一路到缓冲流、数据流,最后揭示一个被很多人忽视的设计之美——装饰器模式如何让 Java IO 拥有”乐高式”的组合能力。

一、为什么需要”流”

想象你有一份 10GB 的日志文件要处理。如果”一次性读入内存再处理”,内存直接爆炸。但用流的方式——一次读 1KB、处理、再读 1KB——10GB 也能在 512MB 内存的机器上跑完。

流的本质是:把数据当作”连续的水流”处理,而不是”一桶水”。你不必知道总量有多少,只需一勺一勺地舀。这种”边读边处理”的模式,是处理大数据、网络通信的基础。

Java 的字节流以 byte(8 位)为最小单位,对应抽象类 InputStream(读)与 OutputStream(写)。

二、InputStream:读字节的水管

InputStream 是所有字节输入流的抽象基类,定义了”读字节”的契约。

2.1 核心方法

public abstract class InputStream implements Closeable {
    public abstract int read() throws IOException;          // 读 1 个字节(返回 0~255,到末尾返回 -1)
    public int read(byte[] b) throws IOException;           // 批量读入 b.length 个字节
    public int read(byte[] b, int off, int len);            // 读入 len 个字节到 b[off..]
    public int available() throws IOException;              // 估计还能读多少字节(不阻塞)
    public long transferTo(OutputStream out);               // 把剩余数据全部转到 out(Java 9+)
    public void close() throws IOException;                 // 关闭流
}

关键细节read() 返回 int 而不是 byte——因为要用 -1 表示”读到末尾”。byte 是有符号的(-128127),无法表示 -1 与正常字节的区别;用 int 则 0255 表示字节值,-1 表示结束。

2.2 经典读取循环

InputStream in = ...;
byte[] buffer = new byte[1024];
int n;
while ((n = in.read(buffer)) != -1) {
    // 处理 buffer 的前 n 个字节
    // 注意:是 n,不是 buffer.length!最后一次通常不满
}
in.close();

这个 while ((n = in.read(buffer)) != -1) 模式是字节流的”hello world”——记下来,它会陪你写一辈子 IO 代码。

三、OutputStream:写字节的水管

OutputStreamInputStream 的镜像,定义”写字节”的契约:

public abstract class OutputStream implements Closeable, Flushable {
    public abstract void write(int b) throws IOException;        // 写 1 个字节(取低 8 位)
    public void write(byte[] b) throws IOException;              // 写整个数组
    public void write(byte[] b, int off, int len);               // 写 b[off..off+len]
    public void flush() throws IOException;                      // 把缓冲区数据强制写出
    public void close() throws IOException;
}

flush 的意义:很多输出流内部有缓冲区(先攒一波再写磁盘,性能更高)。flush() 强制把缓冲区的内容真正写出去——不调 flush 就关程序,缓冲区里的数据可能丢失。

四、FileInputStream / FileOutputStream:文件字节流

最常用的字节流——直接对接文件。

import java.io.*;

// 写
try (OutputStream out = new FileOutputStream("data.bin")) {
    out.write(new byte[]{1, 2, 3, 4, 5});
}

// 读
try (InputStream in = new FileInputStream("data.bin")) {
    byte[] buf = new byte[16];
    int n = in.read(buf);
    System.out.println("读到 " + n + " 字节");
    for (int i = 0; i < n; i++) System.out.print(buf[i] + " ");
}
// 输出:读到 5 字节
//      1 2 3 4 5

FileOutputStream 默认覆盖写——文件已存在会被截断。要”追加”得用两参构造器:

new FileOutputStream("log.txt", true)   // true = append

五、BufferedInputStream / BufferedOutputStream:缓冲流

直接用 FileInputStream 每读一个字节就调一次系统调用——慢得惊人。缓冲流在内存里维护一个数组(默认 8KB),一次从磁盘读一大块,之后 read() 从内存数组里取,性能提升几十倍。

// 慢:每次 read 都系统调用
InputStream slow = new FileInputStream("big.dat");

// 快:包一层 BufferedInputStream,内部有 8KB 缓冲区
InputStream fast = new BufferedInputStream(new FileInputStream("big.dat"));

注意写法——new BufferedInputStream(new FileInputStream(...))。这种”流包流”的写法是 Java IO 的标志,下一节揭示它的设计本质。

六、装饰器模式:IO 的乐高积木

Java IO 的类层次有个怪现象——BufferedInputStream 不是 FileInputStream 的子类,而是 InputStream 的子类,且内部持有一个 InputStream 字段

public class BufferedInputStream extends FilterInputStream {
    protected volatile InputStream in;   // 被装饰的底层流
    ...
}

这是经典的装饰器模式(Decorator Pattern):装饰器和被装饰者实现同一个接口,装饰器内部委托给被装饰者,并在前后加上新功能。

6.1 模式结构

InputStream(抽象组件)
   ├── FileInputStream(具体组件:从文件读)
   ├── ByteArrayInputStream(具体组件:从内存读)
   └── FilterInputStream(抽象装饰器)
          ├── BufferedInputStream(装饰:加缓冲)
          ├── DataInputStream(装饰:读基本类型)
          └── PushbackInputStream(装饰:可回退)

FilterInputStream 是所有装饰器的基类,它持有一个 in 字段,默认把所有方法转发给 in。子类只重写需要”加料”的方法。

6.2 像乐高一样组合

装饰器模式让你任意叠加功能

// 文件流 → 加缓冲 → 加数据读写
DataInputStream in = new DataInputStream(
    new BufferedInputStream(
        new FileInputStream("data.bin")));

// 读一个 int(4 字节,按 big-endian)
int n = in.readInt();

三层嵌套,每层加一个能力:底层读文件、中层加速、顶层读基本类型。你可以按需增减——只想要缓冲就只包两层,想要数据读写就再加一层。这种”组合优于继承”的设计,是面向对象设计的经典范例。

七、DataInputStream / DataOutputStream:基本类型读写

DataInput/DataOutput 接口定义了读写基本类型的方法:readInt/writeIntreadLong/writeLongreadDouble/writeDoublereadUTF/writeUTF(修改版 UTF-8)等。

import java.io.*;

// 写
try (DataOutputStream out = new DataOutputStream(
        new BufferedOutputStream(
            new FileOutputStream("record.bin")))) {
    out.writeInt(42);
    out.writeDouble(3.14159);
    out.writeUTF("你好,世界");
    out.writeBoolean(true);
}

// 读(顺序必须和写一致!)
try (DataInputStream in = new DataInputStream(
        new BufferedInputStream(
            new FileInputStream("record.bin")))) {
    int n = in.readInt();           // 42
    double d = in.readDouble();     // 3.14159
    String s = in.readUTF();        // 你好,世界
    boolean b = in.readBoolean();   // true
    System.out.printf("n=%d d=%f s=%s b=%b%n", n, d, s, b);
}

注意DataInputStream 读取的顺序必须和 DataOutputStream 写入的顺序完全一致——它不知道字段边界,只能按你给的顺序解读字节。这是”二进制协议”的典型工作方式。

字节序:Java 用 big-endian(高位在前)——writeInt(1) 写出的 4 字节是 00 00 00 01。这与网络字节序一致,但和 x86 机器内部的小端序相反。读写双方都得用 DataStream,不能一个用 DataStream 一个用别的。

八、try-with-resources:自动关闭流

流持有操作系统资源(文件句柄、网络连接),用完必须关闭。Java 7 引入的 try-with-resources 让这件事自动化:

// 旧写法:冗长易错
InputStream in = null;
try {
    in = new FileInputStream("a.txt");
    // 使用 in
} catch (IOException e) {
    // ...
} finally {
    if (in != null) {
        try { in.close(); } catch (IOException e) { /* 吞掉 */ }
    }
}

// 新写法:try-with-resources
try (InputStream in = new FileInputStream("a.txt")) {
    // 使用 in
}   // 这里自动调 in.close(),即使抛异常也会关

原理:try-with-resources 要求资源实现 AutoCloseable 接口(Closeable 是它的子接口)。退出 try 块时(无论正常或异常),JVM 自动调用 close()。多个资源用分号隔开:

try (InputStream in = new FileInputStream("src");
     OutputStream out = new FileOutputStream("dst")) {
    in.transferTo(out);
}   // 先关 out,再关 in(逆序关闭)

逆序关闭是个细节——后开的先关,符合”栈”的后进先出语义,避免依赖关系出问题。

Java 9+ 改进:可以引用外部已存在的 final 变量,不必在 try 内重新声明:

InputStream in = new FileInputStream("a.txt");
try (in) {   // Java 9+
    in.transferTo(out);
}

九、实战:文件复制(多种方式对比性能)

下面我们用三种方式复制文件,对比性能——基础循环、transferTo、Files.copy:

Java · 在线运行

性能对比要点

  • 裸流(方式 1)每次 read/write 都系统调用,慢但稳定。
  • 缓冲流(方式 2)批量读写,性能通常高一个数量级。
  • Files.copy(方式 3)内部用 NIO 通道,往往最快且代码最简。

实际项目中:能写一行 Files.copy 就别写十行流循环——简洁往往意味着正确。

十、字节流的局限

字节流处理”原始字节”很合适,但处理文本就别扭——你得自己关心编码、自己拆行、自己处理中文字符。比如读 UTF-8 的中文文本:

// 别扭:字节流读中文,遇到多字节字符容易截断乱码
try (InputStream in = new FileInputStream("poem.txt")) {
    byte[] buf = new byte[16];
    int n = in.read(buf);   // 16 字节可能正好切断一个汉字
    String s = new String(buf, 0, n);   // 乱码风险
}

文本要交给”字符流”——Reader/Writer,那是下一章的主题。规则很简:字节流管二进制(图片、视频、序列化数据),字符流管文本

十一、本章速查表

流类型用途关键方法
FileInputStream / FileOutputStream文件读写read / write
BufferedInputStream / BufferedOutputStream加缓冲,提速内部维护缓冲区
DataInputStream / DataOutputStream读写基本类型readInt / writeInt / readUTF
ByteArrayInputStream / ByteArrayOutputStream内存中读写字节数组当源/目标
ObjectInputStream / ObjectOutputStream对象序列化readObject / writeObject(下一章详解)

结语:流的设计之美

Java 的字节流抽象有两层美:

  • 统一抽象InputStream/OutputStream 把”数据从哪来、到哪去”封装成同一个接口——文件、网络、内存都用一样的 read/write。你写的处理逻辑可以无缝切换数据源。
  • 装饰器组合BufferedDataPushback 这些装饰器像乐高积木,按需叠加。你不需要”缓冲文件输入数据流”这种特化类——new DataInputStream(new BufferedInputStream(new FileInputStream(...))) 用三个通用积木拼出来。

这种”小类多组合”的设计,是面向对象设计的精髓。try-with-resources 又把”资源关闭”这个最容易出 bug 的环节自动化了——配合装饰器,多个资源逆序关闭,干净利落。

字节流是 IO 的根基。下一章我们看字符流——它建立在字节流之上,解决了”字节到字符”的编码问题,让文本处理变得优雅。