Skip to content

S06-02 IO流-IO流 ​

[TOC]

概述 ​

在 Java 中,IO 流(Input/Output Stream) 是处理设备之间数据传输的核心机制。无论是读取本地文件、向网络发送数据,还是在控制台输入输出,都离不开 IO 流。

Java 将这些数据传输操作抽象为“流(Stream)”,就像水流一样,数据从一个地方源源不断地流向另一个地方。

image-20260704172807099

IO 流分类 ​

Java 的 IO 流类库非常庞大,但核心逻辑非常清晰。通常我们可以从三个维度对流进行分类:

  1. 按流向划分:

    • 输入流(Input Stream): 把外部设备(文件、网络等)中的数据读取到内存中。
    • 输出流(Output Stream): 把内存中的数据写入到外部设备中。
  2. 按处理的数据单位划分:

    • 字节流(Byte Stream): 以 字节(8 个 bit) 为单位进行读写。适合处理所有类型的文件(如图片、视频、音频、可执行文件等二进制数据)。
    • 字符流(Character Stream): 以 字符(16 个 bit,即两个字节) 为单位进行读写。专门用来处理纯文本文件(如 .txt、.java、.json 等),会自动处理字符编码问题。
  3. 按流的角色划分:

    • 节点流(Node Stream): 直接与具体的数据源(如文件、内存、网络)相连的流。例如 FileInputStream。
    • 处理流(Processing Stream / 包装流): 建立在已存在的流之上,通过对其他流进行包装,来提供更强大的读写功能(如缓冲、序列化等)。例如 BufferedInputStream。

image-20260704173429741

抽象基类 ​

所有的 Java IO 流类共涉及 40 多个类,最顶层都是由以下四个抽象类派生出来的:

数据单位 \ 流向输入流 (Input)输出流 (Output)
字节流 (Byte)InputStreamOutputStream
字符流 (Character)ReaderWriter

image-20260704174203231

API: InputStream ​

构造方法 ​

  • InputStream InputStream():(),构造方法。受保护的构造方法,供子类在初始化时显式或隐式调用。

    java
    import java.io.IOException;
    import java.io.InputStream;
    public class ConstructorDemo {
      public static void main(String[] args) {
        // 1. 子类实例化时隐式调用父类受保护构造方法 InputStream()
        InputStream stream = new InputStream() {
          @Override
          public int read() throws IOException {
            return -1; // 模拟空流终止标识
          }
        };
        // 2. 输出实例类型以验证抽象基类初始化完成
        System.out.println("抽象基类子类化成功: " + stream.getClass().getSimpleName());
      }
    }

基础数据读取 ​

  • abstract int read():(),单字节读取。从输入流中读取下一个字节的数据,值以 0 至 255 的无符号 int 形式返回;若已达流末尾则返回 -1。方法在有数据到达、到达流末尾或抛出异常前保持阻塞。

  • int read():(byte[] b),多字节缓冲读取。从输入流中读取一定数量的字节并存入缓冲区数组 b 中,返回实际读入的字节数;若已达流末尾则返回 -1。

  • int read():(byte[] b, int off, int len),定长偏移读取。从输入流中最多读取 len 个字节存入缓冲区数组 b 中由 off 指定的起始偏移位置。

  • byte[] readAllBytes():(),全量字节读取。读取当前流中剩余的全部字节并封装为 byte 数组返回。

  • int readNBytes():(byte[] b, int off, int len),定额字节读取。阻塞读取指定数量 len 的字节至缓冲区数组 b 的 off 偏移处,直至读满、遇到 EOF 或发生异常。

  • byte[] readNBytes():(int len),指定长度读取。读取最多 len 个字节并返回实际包含读取内容的 byte 数组。

注意事项:

  1. read() 返回值类型为 int 而非 byte。如果使用 byte 接收返回值,合法的字节值 0xFF(即十进制 255、有符号 byte 的 -1)将无法与流终止标识符 -1 区分,导致数据被提前截断。
  2. read(byte[] b) 与 read(byte[] b, int off, int len) 并不能保证一次性填满传入的数组容量,其返回值表示本次实际读入的字节量。业务处理中必须严格依据实际读入长度截取有效区间,严禁直接使用 b.length 遍历数据。
  3. readAllBytes() 会在 JVM 堆内存中一次性分配连续数组。严禁在海量磁盘文件或持续流入的网络流上调用,否则会直接引发 OutOfMemoryError。
java
import java.io.ByteArrayInputStream;
import java.io.InputStream;
public class ReadApiDemo {
  public static void main(String[] args) throws Exception {
    byte[] mockBytes = "Stream Data Reading API Demonstration".getBytes();
    // 1. 单字节读取:返回无符号 int 值
    try (InputStream in = new ByteArrayInputStream(mockBytes)) {
      int first = in.read();
      System.out.println("read() 单字节: " + (char) first);
    }
    // 2. 块缓冲读取:读取到固定大小 byte 数组并获取有效长度
    try (InputStream in = new ByteArrayInputStream(mockBytes)) {
      byte[] buf = new byte[6];
      int len = in.read(buf, 0, buf.length);
      System.out.println("read(byte[], int, int) 长度: " + len + ",内容: " + new String(buf, 0, len));
    }
    // 3. 定额读取:阻塞读取指定字节量
    try (InputStream in = new ByteArrayInputStream(mockBytes)) {
      byte[] fixed = in.readNBytes(11);
      System.out.println("readNBytes(int) 内容: " + new String(fixed));
    }
    // 4. 全量读取:一次性返回剩余全部字节
    try (InputStream in = new ByteArrayInputStream(mockBytes)) {
      byte[] all = in.readAllBytes();
      System.out.println("readAllBytes() 总长度: " + all.length);
    }
  }
}

流状态控制 ​

  • int available():(),非阻塞可用字节估算。返回在不发生阻塞的情况下可从此输入流读取(或跳过)的估计字节数。

  • long skip():(long n),字节跳过。跳过并丢弃输入流中的至多 n 个字节,返回实际跳过的字节数。

  • void skipNBytes():(long n),定额字节跳过。精确跳过输入流中的 n 个字节,若在跳过完毕前到达流末尾则抛出 EOFException。

  • boolean markSupported():(),标记支持检测。测试当前输入流是否支持 mark() 和 reset() 机制。

  • synchronized void mark():(int readlimit),流位置标记。在此输入流中标记当前位置,使后续调用 reset() 能回到该位置;readlimit 指定在标记失效前允许读取的最大字节数。

  • synchronized void reset():(),流位置重置。将此流的读取指针重置到最后一次调用 mark() 时的位置。

注意事项:

  1. available() 方法返回的是非阻塞可读取字节估算值。在网络套接字(SocketInputStream)等低速 I/O 场景中,该方法仅反映已到达操作系统本地套接字缓冲区的数据大小,切勿将其作为数据流的实际总长度用于判断是否读完。
  2. mark() 与 reset() 并非所有流都支持。InputStream 基类默认实现中 markSupported() 返回 false,且 reset() 直接抛出 IOException。调用前必须经由 markSupported() 校验,或使用 BufferedInputStream、ByteArrayInputStream 等具备内存缓存能力的子类。
  3. skip(long n) 在发生底层分片或文件截断时可能返回小于 n 的实际跳过值;若需要严格跳过指定数量字节,应优先选用 skipNBytes(long n)。
java
import java.io.ByteArrayInputStream;
import java.io.InputStream;
public class StreamControlDemo {
  public static void main(String[] args) throws Exception {
    byte[] data = "0123456789".getBytes();
    try (InputStream in = new ByteArrayInputStream(data)) {
      // 1. 查询当前可无阻塞读取的字节估算量
      System.out.println("初始可读字节数: " + in.available());
      // 2. 检测并执行流标记机制
      if (in.markSupported()) {
        in.read(); // 读取 '0'
        in.mark(10); // 在位置 1 处打上标记
        System.out.println("标记后读取: " + (char) in.read()); // 读取 '1'
        // 3. 执行重置回溯到标记位置
        in.reset();
        System.out.println("reset 后重新读取: " + (char) in.read()); // 再次读取 '1'
      }
      // 4. 定额跳过指定字节并检查剩余
      in.skipNBytes(5);
      System.out.println("跳过后剩余首个字符: " + (char) in.read());
    }
  }
}

资源管控与传输 ​

  • void close():(),资源释放。关闭此输入流并释放与该流关联的所有底层系统资源(如文件描述符、网络句柄)。

  • long transferTo():(OutputStream out),流数据直传。从当前流中读取全部字节并顺序写入指定的目标输出流,返回传输的字节总数。

  • InputStream InputStream.nullInputStream():(),空流创建。返回一个已打开且不包含任何字节的输入流实例,常用于占位或重定向丢弃场景。

注意事项:

  1. InputStream 实现了 java.lang.AutoCloseable 与 java.io.Closeable 接口,必须始终在 try-with-resources 语句块中进行声明管理,严禁依赖 GC 回收阶段释放底层文件描述符。
  2. transferTo(OutputStream out) 内部采用默认 8192 字节的循环缓冲区进行阻塞式搬运。方法会持续读取直至当前流达到 EOF。若输入流为持续产生数据的长连接或无界流,该操作将发生永久阻塞。
java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
public class ResourceTransferDemo {
  public static void main(String[] args) throws Exception {
    // 1. 利用静态工厂创建空流实例进行安全占位
    try (InputStream emptyStream = InputStream.nullInputStream()) {
      System.out.println("空流读取 EOF 判定: " + emptyStream.read());
    }
    // 2. 利用 transferTo 实现流对流的高效搬运
    byte[] payload = "High performance payload transfer".getBytes();
    try (InputStream source = new ByteArrayInputStream(payload);
       ByteArrayOutputStream target = new ByteArrayOutputStream()) {
      long bytesTransferred = source.transferTo(target);
      System.out.println("传输总字节数: " + bytesTransferred);
      System.out.println("目标输出流内容: " + target.toString());
    }
    // 3. 流退出 try 块时自动触发 close() 释放资源
  }
}

API: OutputStream ​

构造方法 ​

  • OutputStream OutputStream():(),构造方法。受保护的构造方法,供子类在初始化时显式或隐式调用。

    java
    import java.io.IOException;
    import java.io.OutputStream;
    
    public class OutputStreamConstructorDemo {
      public static void main(String[] args) {
        // 1. 匿名子类在实例化时隐式调用父类受保护构造方法 OutputStream()
        OutputStream stream = new OutputStream() {
          @Override
          public void write(int b) throws IOException {
            // 抽象方法实现:模拟黑洞流丢弃字节
          }
        };
        // 2. 验证抽象基类初始化完成并输出实例类型
        System.out.println("抽象基类子类化成功: " + stream.getClass().getSimpleName());
      }
    }

基础数据写入 ​

  • abstract void write():(int b),单字节写入。向输出流中写入指定的单个字节。参数接收 32 位整型值,但底层仅将其低 8 位写入目标介质,高 24 位被自动截断丢弃;该方法在数据完全写入或产生异常前保持阻塞。

  • void write():(byte[] b),多字节缓冲写入。将指定的字节数组 b 中的全部内容写入此输出流。其底层默认直接委托调用 write(b, 0, b.length)。

  • void write():(byte[] b, int off, int len),定长偏移写入。将指定的字节数组 b 中从偏移量 off 开始的连续 len 个字节写入此输出流。

注意事项:

  1. write(int b) 仅写入低 8 位:传入参数是 int 类型,如果传入超过 0~255 的整数(例如 321,二进制 0x0141),高 24 位会被静默截断,实际写入的仅是 0x41(即字符 'A')。
  2. write(byte[] b, int off, int len) 存在严格的边界校验:当 off < 0、len < 0 或 off + len > b.length 时,会直接抛出 IndexOutOfBoundsException。在处理网络传输或切片数据时,必须确保传入边界参数合法。
  3. 阻塞性行为:OutputStream 的所有写入方法默认均为同步阻塞操作。若底层操作系统内核缓冲区已满或磁盘 I/O 拥塞,当前调用线程将被挂起等待,直至空间释放或写入超时。
java
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;

public class WriteApiDemo {
  public static void main(String[] args) throws Exception {
    byte[] sampleBytes = "OutputStream Data Writing Demonstration".getBytes(StandardCharsets.UTF_8);
    // 1. 单字节写入:传入 32 位整型并自动截断保留低 8 位
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      out.write(79); // 写入 ASCII 79 ('O')
      System.out.println("单字节写入结果: " + out.toString(StandardCharsets.UTF_8));
    }
    // 2. 多字节缓冲写入:写入整个字节数组
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      out.write(sampleBytes);
      System.out.println("多字节写入大小: " + out.size() + " 字节");
    }
    // 3. 定长偏移写入:仅截取指定偏移区间写入
    try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      out.write(sampleBytes, 0, 12); // 写入前 12 个字节 "OutputStream"
      System.out.println("定长偏移写入内容: " + out.toString(StandardCharsets.UTF_8));
    }
  }
}

资源管控 ​

  • void flush():(),缓冲区刷新。强制将此输出流中所有已缓冲的未写出字节同步刷新至目标底层物理介质或网络通道。

  • void close():(),资源释放。关闭此输出流并释放与该流关联的所有底层系统资源(如文件句柄、网络套接字)。

  • OutputStream OutputStream.nullOutputStream():(),空流创建。返回一个已打开且丢弃所有写入字节的空输出流实例,常用于静默丢弃输出或重定向占位场景。

注意事项:

  1. flush() 在不同流实现中的语义差异:在无缓冲的节点流(如 FileOutputStream)中,flush() 继承自基类的空实现,并不保证将数据真正物理落盘(需依赖 FileChannel.force() 或 FileDescriptor.sync());但在带缓冲的处理流(如 BufferedOutputStream、PrintStream)中,必须显式调用 flush() 或等待缓冲区写满,否则暂存在内存中的数据可能丢失或无法及时被对端接收。
  2. 关闭流的连带效应:在由多个装饰器流嵌套构成的管道链中,关闭最外层的流会自动沿链路自顶向下关闭内部所包装的所有底层流。在网络编程(如 Socket 通信)中,关闭由 socket.getOutputStream() 获取的流将直接导致整个底层的 Socket 物理连接被关闭;若仅需关闭输出半通道,应调用 socket.shutdownOutput()。
  3. 必须使用 try-with-resources:OutputStream 实现了 AutoCloseable 与 Closeable 接口,严禁依赖垃圾回收器在终结阶段释放底层操作系统分配的文件描述符或套接字。
java
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;

public class ResourceManagementDemo {
  public static void main(String[] args) throws Exception {
    // 1. 通过 flush 强制将内存缓冲队列中的数据下刷到底层节点
    ByteArrayOutputStream underlying = new ByteArrayOutputStream();
    try (BufferedOutputStream bufferedOut = new BufferedOutputStream(underlying)) {
      bufferedOut.write("Data payload in memory buffer".getBytes(StandardCharsets.UTF_8));
      System.out.println("flush 前底层节点大小: " + underlying.size() + " 字节");
      bufferedOut.flush();
      System.out.println("flush 后底层节点大小: " + underlying.size() + " 字节");
    } // 退出 try 块时自动触发 close() 释放资源
    // 2. 利用 nullOutputStream 创建静默丢弃数据的黑洞流
    try (OutputStream discardStream = OutputStream.nullOutputStream()) {
      discardStream.write(0xFF);
      discardStream.write("Silently dropped bytes".getBytes(StandardCharsets.UTF_8));
      System.out.println("nullOutputStream 正常接收并安全丢弃数据");
    }
  }
}

API: Reader ​

构造方法 ​

  • protected Reader Reader():(),构造方法。受保护的默认构造方法,以当前 Reader 实例自身(this)作为关键操作的同步锁对象。

  • protected Reader Reader():(Object lock),构造方法。受保护的重载构造方法,使用指定的独立对象作为关键临界区的同步锁。

    java
    import java.io.IOException;
    import java.io.Reader;
    
    public class ReaderConstructorDemo {
      public static void main(String[] args) {
        // 1. 通过自定义同步锁对象初始化 Reader 匿名子类
        Object customLock = new Object();
        Reader customReader = new Reader(customLock) {
          @Override
          public int read(char[] cbuf, int off, int len) throws IOException {
            return -1; // 模拟无数据到达流末尾
          }
          @Override
          public void close() throws IOException {
            // 模拟关闭外部资源句柄
          }
        };
        // 2. 验证受保护构造方法调用与实例初始化完成
        System.out.println("自定义同步锁 Reader 初始化完成: " + customReader.getClass().getSimpleName());
      }
    }

基础数据读取 ​

  • int read():(),单字符读取。从输入流中读取下一个字符,值以 0 至 65535 的无符号 int 整数返回;若已达流末尾则返回 -1。

  • int read():(char[] cbuf),多字符缓冲读取。将字符读入目标字符数组 cbuf 中,返回实际读入的字符总数;若已达流末尾则返回 -1。

  • abstract int read():(char[] cbuf, int off, int len),定长偏移读取。从输入流中最多读取 len 个字符存入目标字符数组 cbuf 中由 off 指定的起始偏移位置。这是子类必须实现的底层核心抽象读取方法。

  • int read():(CharBuffer target),字符缓冲区读取。尝试将流中字符直接读入指定的 NIO 字符缓冲区 target 中,实现来自 java.lang.Readable 接口的统一契约。

注意事项:

  1. read() 返回值类型为 32 位 int 而非 16 位 char。Java 中 char 为无符号 16 位类型(0x0000 ~ 0xFFFF),不存在负数表示。如果强制使用 char 承接返回值,流终止标识符 -1 会被截断并转换为 Unicode 码点 0xFFFF(即 65535),造成循环无法退出。
  2. Unicode 增补字符(Supplementary Characters,如部分 Emoji 或生僻汉字)在 UTF-16 中是由两个 16 位的代理单元(Surrogate Pair:高代理与低代理)组成的。单次 read() 仅会读取其中的一半(一个 char),直接对其进行截断或单独处理会导致乱码,在处理全域 Unicode 文本时需结合 Character.isHighSurrogate() 进行代理对拼接。
  3. read(char[] cbuf, int off, int len) 是所有高级读取操作的委托根基。子类实现时必须严格保证:当 len == 0 时必须立即返回 0,不得发生阻塞或改变流游标。
java
import java.io.Reader;
import java.io.StringReader;
import java.nio.CharBuffer;

public class ReadApiDemo {
  public static void main(String[] args) throws Exception {
    // 1. 单字符读取:返回 0~65535 无符号 int 整数
    try (Reader reader = new StringReader("Hello Java 字符流")) {
      int firstChar = reader.read();
      System.out.println("read() 单字符结果: " + (char) firstChar + " (Unicode 码点: " + firstChar + ")");
    }
    // 2. 多字符缓冲读取:批量读入 char 数组
    try (Reader reader = new StringReader("Hello Java 字符流")) {
      char[] buffer = new char[5];
      int count = reader.read(buffer);
      System.out.println("read(char[]) 实际读取字符数: " + count + ",内容: " + new String(buffer, 0, count));
    }
    // 3. 定长偏移读取:指定目标数组存储区间
    try (Reader reader = new StringReader("Hello Java 字符流")) {
      char[] buffer = new char[10];
      int len = reader.read(buffer, 2, 5);
      System.out.println("read(char[], int, int) 长度: " + len + ",内容: " + new String(buffer, 2, len));
    }
    // 4. 适配 Readable 接口:读入 CharBuffer 缓冲区
    try (Reader reader = new StringReader("Hello Java 字符流")) {
      CharBuffer charBuffer = CharBuffer.allocate(16);
      int charsRead = reader.read(charBuffer);
      charBuffer.flip();
      System.out.println("read(CharBuffer) 读入数量: " + charsRead + ",缓冲区内容: " + charBuffer);
    }
  }
}

流状态控制 ​

  • boolean ready():(),流就绪状态检测。判断此流是否已处于可进行无阻塞读取的状态。若底层缓冲区非空或下一次调用不会发生线程挂起,则返回 true。

  • long skip():(long n),字符跳过。跳过流中的至多 n 个字符,返回实际跳过的有效字符数。

  • boolean markSupported():(),标记支持检测。测试当前字符输入流是否支持 mark() 和 reset() 机制。Reader 抽象基类默认固定返回 false。

  • void mark():(int readAheadLimit),流位置标记。在此流中记录当前读取指针位置;readAheadLimit 指定在标记失效前允许向前读取的最大字符数。

  • void reset():(),流位置重置。将此字符流的读取游标重置回最后一次调用 mark() 时的位置。

注意事项:

  1. ready() 与 InputStream.available() 的语义差异:ready() 返回的是布尔值,仅表示当前是否可以立即读取,不提供可读字符数量的估值。此外,ready() 返回 false 并不代表流已结束或下一次读取一定会发生阻塞(在部分网络/管道流中可能仅代表底层数据包尚未抵达)。
  2. mark() 与 reset() 默认不可用:Reader 基类中 mark() 与 reset() 默认直接抛出 IOException: mark() not supported。调用前必须经由 markSupported() 进行防御性校验,或显式选用 BufferedReader、StringReader、CharArrayReader 等具备内存缓存能力的实现类。
  3. skip(long n) 的传参约束:参数 n 必须非负;若传入负数,方法将直接抛出 IllegalArgumentException: skip value is negative。
java
import java.io.BufferedReader;
import java.io.Reader;
import java.io.StringReader;

public class StreamControlDemo {
  public static void main(String[] args) throws Exception {
    try (Reader reader = new BufferedReader(new StringReader("ABCDEFGHIJK"))) {
      // 1. 检测流是否就绪可无阻塞读取
      System.out.println("流是否就绪: " + reader.ready());
      // 2. 检测并执行标记回溯机制
      if (reader.markSupported()) {
        System.out.println("首字符: " + (char) reader.read());
        reader.mark(10);
        System.out.println("标记后读取: " + (char) reader.read());
        // 3. 执行重置回退至标记点
        reader.reset();
        System.out.println("reset 重置后读取: " + (char) reader.read());
      }
      // 4. 跳过指定数量字符
      long skipped = reader.skip(4);
      System.out.println("实际跳过字符数: " + skipped + ",剩余字符: " + (char) reader.read());
    }
  }
}

资源生命周期管理 ​

  • abstract void close():(),资源释放。关闭此字符流并释放关联的底层系统资源。一旦流被关闭,后续调用 read()、ready() 等操作将直接抛出 IOException。

  • long transferTo():(Writer out),字符流数据直传。从当前 Reader 中顺序读取所有剩余字符并直接写入指定的目标 Writer 中,返回传输的字符总数。

  • Reader Reader.nullReader():(),空字符流创建。返回一个已打开且不包含任何字符数据的空 Reader 实例,常用于空对象模式占位或静默丢弃场景。

注意事项:

  1. Reader 实现了 java.lang.AutoCloseable 与 java.io.Closeable 接口,必须严格置于 try-with-resources 代码块中声明,杜绝因异常抛出导致底层操作系统文件句柄或套接字连接泄漏。
  2. transferTo(Writer out) 内部采用默认 8192 字符大小的数组循环搬运,直到读至流末尾(返回 -1)。若用于持续产出数据的长连接无界字符流,将导致调用线程永久挂起。此外,该方法传输完成后不会自动关闭输入流或输出流。
  3. close() 方法的幂等性与传染性:关闭包装流(如 BufferedReader)会自动沿着装饰链路关闭内部所包装的底层 InputStreamReader 及原始 InputStream。若仅需要读取部分文本而保持底层网络连接打开,切勿直接调用最外层装饰流的 close()。
java
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;

public class ResourceLifecycleDemo {
  public static void main(String[] args) throws Exception {
    // 1. 利用静态工厂创建空流实例进行安全占位
    try (Reader emptyReader = Reader.nullReader()) {
      System.out.println("空字符流读取结果: " + emptyReader.read());
    }
    // 2. 使用 transferTo 将字符数据直接搬运到目标 Writer
    try (Reader source = new StringReader("High Performance Character Stream Transfer");
       StringWriter target = new StringWriter()) {
      long transferred = source.transferTo(target);
      System.out.println("传输总字符数: " + transferred);
      System.out.println("目标写入器内容: " + target.toString());
    }
    // 3. 退出 try-with-resources 代码块时自动调用 close() 释放底层资源
  }
}

API: Writer ​

构造方法 ​

  • protected Writer Writer():(),构造方法。受保护的默认构造方法,以当前 Writer 实例自身(this)作为关键操作的同步锁对象。

  • protected Writer Writer():(Object lock),构造方法。受保护的重载构造方法,使用指定的独立对象作为关键临界区的同步锁。

    java
    import java.io.IOException;
    import java.io.Writer;
    
    public class WriterConstructorDemo {
      public static void main(String[] args) {
        // 1. 通过自定义显式同步锁初始化 Writer 匿名子类
        Object syncLock = new Object();
        Writer writer = new Writer(syncLock) {
          @Override
          public void write(char[] cbuf, int off, int len) throws IOException {
            // 模拟向自定义介质写入字符
          }
          @Override
          public void flush() throws IOException {}
          @Override
          public void close() throws IOException {}
        };
        // 2. 验证受保护构造方法调用与实例初始化完成
        System.out.println("自定义锁 Writer 初始化完成: " + writer.getClass().getSimpleName());
      }
    }

基础字符写入 ​

  • void write():(int c),单字符写入。向输出流中写入单个字符。参数为 32 位整型,但底层仅写入低 16 位字符(0x0000 ~ 0xFFFF),高 16 位被自动忽略;在字符完全写出前保持阻塞。

  • void write():(char[] cbuf),多字符缓冲写入。将指定的字符数组 cbuf 中的全部字符写入此输出流。底层直接委托调用 write(cbuf, 0, cbuf.length)。

  • abstract void write():(char[] cbuf, int off, int len),定长偏移写入。将指定的字符数组 cbuf 中从偏移量 off 开始的连续 len 个字符写入此流。这是子类必须实现的底层核心抽象写方法。

  • void write():(String str),字符串写入。将指定的字符串完整写入此输出流。底层直接委托调用 write(str, 0, str.length())。

  • void write():(String str, int off, int len),定长字符串切片写入。将指定的字符串 str 中从偏移量 off 开始的连续 len 个字符写入此流。

注意事项:

  1. write(int c) 的低 16 位截断规则:传入的参数是 32 位 int,若传入超出 0x0000 ~ 0xFFFF(即 0 ~ 65535)范围的高位码点(例如部分包含辅助平面的 Emoji),高 16 位会被直接截断丢失,导致输出损坏。对于辅助平面字符,必须拆分为高低代理对(Surrogate Pair)依次写入两个 char。
  2. write(char[] cbuf, int off, int len) 与字符串切片写入的边界约束:当 off < 0、len < 0 或 off + len > cbuf.length(或 str.length())时,将抛出 IndexOutOfBoundsException。在处理网络传输或切片数据时,必须确保传入边界参数合法。
  3. write(String str) 的内部缓存优化:子类(如 BufferedWriter)通常会为字符串写入重写优化路径,避免在写出前将 String 显式转换为额外的 char[] 堆内存分配,因此在长文本写入时应优先传递 String 或切片,而非手动调用 str.toCharArray()。
java
import java.io.StringWriter;

public class WriteApiDemo {
  public static void main(String[] args) throws Exception {
    // 1. 单字符写入:传入 32 位整型并截断保留低 16 位字符
    try (StringWriter writer = new StringWriter()) {
      writer.write(65); // 写入 ASCII 65 ('A')
      System.out.println("单字符写入结果: " + writer.toString());
    }
    // 2. 字符数组切片写入:批量写入指定偏移区间
    try (StringWriter writer = new StringWriter()) {
      char[] chars = "Hello Writer".toCharArray();
      writer.write(chars, 0, 5); // 写入前 5 个字符 "Hello"
      System.out.println("字符数组切片写入: " + writer.toString());
    }
    // 3. 字符串全量与切片写入:规避字符数组重复内存分配
    try (StringWriter writer = new StringWriter()) {
      writer.write("Java I/O System");
      writer.write(" - 字符输出", 0, 7);
      System.out.println("字符串写入结果: " + writer.toString());
    }
  }
}

字符序列追加 ​

  • Writer append():(CharSequence csq),字符序列全量追加。将指定的字符序列追加写入此输出流,返回当前 Writer 自身以支持链式调用;若 csq 为 null 则追加写入字符串 "null"。

  • Writer append():(CharSequence csq, int start, int end),字符序列子序列追加。将指定的字符序列 csq 从索引 start 到 end 的子序列追加到此流中;若 csq 为 null 则视为 "null" 进行切片。

  • Writer append():(char c),单个字符追加。将指定的 16 位字符 c 追加到此输出流中,返回当前流自身。

注意事项:

  1. append() 遵从 java.lang.Appendable 接口规约,在遇到入参为 null 时不会抛出 NullPointerException,而是写入由 4 个字符构成的 "null" 文本。如果业务逻辑要求严格空值过滤,必须显式做前置判空。
  2. append(CharSequence csq, int start, int end) 具有左闭右开区间语义 [start, end)。当 start < 0、start > end 或 end > csq.length() 时,将直接抛出 IndexOutOfBoundsException。
  3. 返回值链式调用:append() 返回 Writer 引用本身,便于构建链式流式语法;但在异常处理或包含底层物理 I/O 刷新的链路中,链式调用掩盖了每个阶段的写入行为,调试排查需要格外注意。
java
import java.io.StringWriter;

public class AppendApiDemo {
  public static void main(String[] args) throws Exception {
    // 1. 链式连续追加不同类型的 CharSequence 序列
    try (StringWriter writer = new StringWriter()) {
      StringBuilder builder = new StringBuilder("BufferContent");
      writer.append("Header: ")
          .append(builder, 0, 6) // 追加子序列 "Buffer"
          .append('\n');
      System.out.print("链式追加结果:\n" + writer);
    }
    // 2. null 安全追加机制:入参为 null 自动转为 "null" 字符串
    try (StringWriter writer = new StringWriter()) {
      CharSequence nullSeq = null;
      writer.append(nullSeq);
      System.out.println("null 追加结果: " + writer);
    }
  }
}

资源状态管理 ​

  • abstract void flush():(),缓冲区刷新。强制将此流中所有已缓冲的字符数据立即写出到底层物理目标介质或管道中。

  • abstract void close():(),资源释放。关闭此字符输出流并释放与该流关联的所有底层物理资源(如文件描述符)。关闭后再次调用写操作将抛出 IOException。

  • Writer Writer.nullWriter():(),空写入器创建。返回一个已打开且丢弃所有写入字符的空 Writer 实例,常用于静默丢弃输出或空对象设计模式。

注意事项:

  1. flush() 在装饰器链中的传播:调用外层装饰流(如 BufferedWriter)的 flush() 会逐层向下调用内层流的 flush(),最终触发底层流将数据提交给操作系统。但对于文件流而言,flush() 仅确保数据从 Java 用户态缓冲区进入操作系统页面缓存(Page Cache),并不保证瞬时完成物理磁盘落盘(完全落盘需依赖通道同步机制)。
  2. close() 的先刷新语义与级联效应:规范实现中,close() 在断开底层资源句柄之前必须先隐式调用一次 flush()。同时,关闭最外层流会自动连锁关闭链条中的所有底层流;在网络通信(如 Socket 输出)中,关闭 Writer 会连带关闭整个 Socket 连接,若仅需半关闭连接应调用 Socket.shutdownOutput()。
  3. 必须在 try-with-resources 语句中进行资源管控:Writer 实现了 AutoCloseable 与 Closeable,切勿在非防御性代码中手动依赖 GC 回收底层文件句柄。
java
import java.io.BufferedWriter;
import java.io.StringWriter;
import java.io.Writer;

public class ResourceLifecycleDemo {
  public static void main(String[] args) throws Exception {
    // 1. 验证缓冲流中的 flush 强制刷新行为
    StringWriter target = new StringWriter();
    try (BufferedWriter buffered = new BufferedWriter(target)) {
      buffered.write("Data in buffer");
      System.out.println("flush 前底层内容: [" + target.toString() + "]");
      buffered.flush(); // 强制刷入底层节点流
      System.out.println("flush 后底层内容: [" + target.toString() + "]");
    } // 退出 try 块时自动调用 close()
    // 2. 利用静态工厂创建空 Writer 实例安全丢弃数据
    try (Writer discard = Writer.nullWriter()) {
      discard.write("This content is safely discarded.");
      System.out.println("nullWriter 正常写入并安全丢弃");
    }
  }
}

常用功能流 ​

为了提高性能或实现特定的高级功能,Java 提供了多种处理流。

缓冲流 ​

缓冲流(Buffered Streams)—— 性能优化利器:

普通的文件流每读写一个字节/字符,就会与磁盘进行一次交互,效率极低。缓冲流在内存中创建了一个缓冲区(默认 8KB),数据先存入缓冲区,攒够了再统一与磁盘交互,极大地减少了磁盘 IO 次数。

  • 字节缓冲流: BufferedInputStream / BufferedOutputStream
  • 字符缓冲流: BufferedReader(提供特有方法 readLine() 读取一行) / BufferedWriter(提供特有方法 newLine() 换行)

转换流 ​

转换流(Intermediary Streams)—— 字符与字节的桥梁:

当你手头只有字节流,但需要处理纯文本,或者需要指定特定的字符编码(如 UTF-8、GBK)时,就需要用到转换流。

  • InputStreamReader: 将字节输入流转换为字符输入流(解码)。
  • OutputStreamWriter: 将字符输出流转换为字节输出流(编码)。

对象流 ​

对象流(Object Streams)—— 序列化与反序列化:

如果你想把一个 Java 对象直接保存到硬盘上,或者通过网络发送给另一台电脑,就需要用到对象流。

  • ObjectOutputStream(序列化): 将 Java 对象转换为字节序列。
  • ObjectInputStream(反序列化): 将字节序列恢复为 Java 对象。
  • 前提: 该对象对应的类必须实现 Serializable 接口。

示例:标准的流操作 ​

在 Java 7 之前,关闭流的操作非常臃肿(需要在 finally 块中判断并手动 close)。现代 Java 开发中,强烈推荐使用 try-with-resources 语句,它会自动帮我们关闭实现了 AutoCloseable 接口的流,避免内存泄漏。

下面是一个使用缓冲字符流复制文本文件的经典示例:

java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;

public class IOCopyDemo {
  public static void main(String[] args) {
    // 在 try() 中声明的流,会在代码块结束后自动关闭
    try (
      BufferedReader br = new BufferedReader(new FileReader("source.txt"));
      BufferedWriter bw = new BufferedWriter(new FileWriter("target.txt"))
    ) {
      String line;
      // 循环读取每一行,直到文件末尾(返回 null)
      while ((line = br.readLine()) != null) {
        bw.write(line);
        bw.newLine(); // 写入换行符
      }
      System.out.println("文件复制成功!");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

最佳实践 ​

  1. 用完必须关闭: IO 流属于系统资源,不属于 JVM 的垃圾回收范畴,不手动关闭会导致内存泄漏或文件被长期锁定。

  2. 记得 flush(): 缓冲输出流在缓冲区未满时不会自动写入磁盘。虽然 close() 触发时会自动刷出缓冲区,但在一些长连接或持续写入的场景中,手动调用 flush() 能保证数据及时落地。

  3. 明确区分字节与字符: 只要是文本文件,优先用字符流/缓冲字符流;只要是文本之外的其他媒体文件(图片、MP3、视频等),必须用字节流。

节点流-文件流 ​

在 Java 中,文件流(File Streams) 是 IO 流体系中最常用的分支,专门用于读写本地磁盘上的文件。

文件流直接与物理磁盘上的文件建立连接,属于节点流。根据处理数据单位的不同,文件流同样分为字节文件流和字符文件流。

四大核心类 ​

Java 针对文件操作提供了四个核心的流类,它们分别继承自四大抽象流:

数据类型 \ 流向输入流(读文件)输出流(写文件)
字节文件流FileInputStreamFileOutputStream
字符文件流FileReaderFileWriter
  • 字节文件流:以字节(Byte)为单位进行读写,由于计算机中所有文件(包括文本、图片、视频、压缩包等)底层都是字节,因此字节文件流可以处理任何类型的文件。

  • 字符文件流:以字符(Character)为单位进行读写。它在由磁盘读取字节后,会根据指定的字符编码表(如 UTF-8、GBK)自动将其转换为字符。因此,它只能用来处理纯文本文件(如 .txt, .json, .java)。

字符文件流~ ​

在 Java 的 IO 流世界中,如果说字节流是处理一切文件的“万能瑞士军刀”,那么字符文件流就是专门用来雕刻文本的“手术刀”。

计算机底层只认识字节(0 和 1),但人类使用的是文字(字符)。字符文件流的出现,就是为了在“字节”与“字符”之间搭建一座自动转换的桥梁,让我们在读写纯文本文件(如 .txt, .json, .xml, .java)时,再也不用手动去处理复杂的字符编码(如 UTF-8、GBK)和断字问题。

继承体系 ​

字符文件流的核心类只有两个:FileReader(用于读文本)和 FileWriter(用于写文本)。它们分别继承自 Reader 和 Writer 这两个抽象基类。

工作机制 ​

FileReader 工作机制:

从继承关系可以看出,FileReader 实际上是 InputStreamReader 的子类。它的底层工作原理是:

  1. 先通过文件系统读取底层的 原始字节(Bytes)。

  2. 内部通过一个 解码器(Decoder),根据指定的字符集(如 UTF-8)将这些字节转换成 字符(Chars)。

  3. 最终让你以字符的形式读取出来。

FileWriter 工作机制:

由于 FileWriter 继承自 OutputStreamWriter,它的底层核心逻辑是:

  1. 接收程序传入的 字符(Chars) 或 字符串(Strings)。

  2. 内部通过一个 编码器(Encoder),根据指定的字符集(如 UTF-8)将字符转换为底层的 原始字节(Bytes)。

  3. 暂时将字节存放在内存的字节缓冲区中。

  4. 当缓冲区满了,或者手动刷新/关闭流时,将字节真正写入磁盘文件中。

API: FileReader ​

构造方法 ​
  • FileReader FileReader():(String fileName),构造方法。根据传入的文件名字符串构建字符输入流,使用平台默认字符集(JDK 18+ 默认 UTF-8)。

  • FileReader FileReader():(File file),构造方法。根据传入的 File 抽象路径构建字符输入流,使用平台默认字符集。适用于已持有 File 对象的场景。

  • FileReader FileReader():(FileDescriptor fd),构造方法。根据传入的已打开底层文件描述符构建字符输入流,使用平台默认字符集。适用于标准输入或原生句柄对接等特定底层场景。

  • FileReader FileReader():(String fileName, Charset charset),构造方法。根据指定文件名和显式字符集构建字符输入流(JDK 11+)。可避免跨操作系统部署时的乱码隐患。

  • FileReader FileReader():(File file, Charset charset),构造方法。根据 File 对象和显式字符集构建字符输入流(JDK 11+)。是生产环境中标准的文件字符流创建方式。

注意事项:

  1. 传入的文件路径若不存在或为目录,构造方法会直接抛出 java.io.FileNotFoundException(其为 IOException 的子类)。
  2. 在 JDK 11 之前,若需要指定字符编码,禁止直接使用 FileReader,必须改用 new InputStreamReader(new FileInputStream(file), charset)。
  3. FileDescriptor 构造器创建的流不会关闭传入的外部文件描述符的所有权,需由调用方统一生命周期管理。
java
// 1. 显式指定 UTF-8 字符集构建读取流(JDK 11+ 生产标准)
try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
  int ch = reader.read();
  System.out.println("Read: " + (char) ch);
} catch (IOException e) {
  e.printStackTrace();
}
// 2. 依赖平台默认编码构建读取流(历史遗留风格,跨平台易产生乱码)
try (FileReader legacyReader = new FileReader(file)) {
  int ch = legacyReader.read();
  System.out.println("Read legacy: " + (char) ch);
} catch (IOException e) {
  e.printStackTrace();
}
继承:Reader ​

mark, markSupported, nullReader, read, read, reset, skip, transferTo

继承:InputStreamReader ​

close, getEncoding, read, read, ready

API: FileWriter ​

构造方法 ​
  • FileWriter FileWriter():(String fileName),构造方法。根据传入的文件名字符串构建字符输出流,使用平台默认字符集(JDK 18+ 默认 UTF-8),默认覆写模式。

  • FileWriter FileWriter():(String fileName, boolean append),构造方法。根据传入的文件名字符串构建字符输出流,可指定是否以追加(append)模式写入,使用平台默认字符集。

  • FileWriter FileWriter():(File file),构造方法。根据传入的 File 对象构建字符输出流,使用平台默认字符集,默认覆写模式。

  • FileWriter FileWriter():(File file, boolean append),构造方法。根据传入的 File 对象构建字符输出流,可指定是否以追加模式写入,使用平台默认字符集。

  • FileWriter FileWriter():(FileDescriptor fd),构造方法。根据传入的已打开底层文件描述符构建字符输出流,使用平台默认字符集。适用于对接已有原生文件句柄或标准输出。

  • FileWriter FileWriter():(String fileName, Charset charset),构造方法。根据指定文件名和显式字符集构建字符输出流(JDK 11+),默认覆写模式。规避跨平台默认编码不一致隐患。

  • FileWriter FileWriter():(String fileName, Charset charset, boolean append),构造方法。根据指定文件名、显式字符集和追加模式标志构建字符输出流(JDK 11+)。

  • FileWriter FileWriter():(File file, Charset charset),构造方法。根据 File 对象和显式字符集构建字符输出流(JDK 11+),默认覆写模式。

  • FileWriter FileWriter():(File file, Charset charset, boolean append),构造方法。根据 File 对象、显式字符集和追加模式标志构建字符输出流(JDK 11+)。是生产环境中标准的文件字符输出方式。

注意事项:

  1. 目标文件的父级目录如果不存在,构造方法会直接抛出 java.io.FileNotFoundException,构造器本身不会自动创建缺失的父级目录。
  2. boolean append 参数为 false(或未指定)时,若目标文件已存在,底层将会截断(truncate)清空文件原始内容;若为 true,则会将指针移动至文件末尾进行追加写入。
  3. 在 JDK 11 之前,FileWriter 不支持显式指定字符编码;若需指定编码,必须回退至 new OutputStreamWriter(new FileOutputStream(file, append), charset)。
  4. 使用 FileDescriptor 构建流时,该构造器不会获取外部文件描述符的唯一所有权,需注意外部资源的释放边界。
java
// 1. 显式指定 UTF-8 字符集以追加模式构建写入流(JDK 11+ 生产标准)
try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8, true)) {
  writer.write("追加写入核心日志信息\n");
} catch (IOException e) {
  e.printStackTrace();
}

// 2. 依赖平台默认编码构建写入流(历史遗留风格,跨平台易产生乱码)
try (FileWriter legacyWriter = new FileWriter(file)) {
  legacyWriter.write("默认编码写入");
} catch (IOException e) {
  e.printStackTrace();
}
继承:Writer ​

append, close, flush, nullWriter, write

继承:OutputStreamWriter ​

close, flush, getEncoding, write

缓冲区机制 ​

很多初学者在使用 FileWriter 时会遇到一个诡异的问题:明明调用了 write() 方法,程序也没有报错,但打开文件一看,里面却空空如也。

原因就在于缓冲区:

FileWriter 内部维护着一个字节缓冲区。当你调用 write("abc") 时,数据只是被写到了内存里的缓冲区,并没有直接去碰磁盘。

数据落盘的三个触发时机:

  1. 缓冲区满了: 缓冲区装不下新数据时,会自动把老数据刷入磁盘。

  2. 调用了 flush(): 手动强行把当前缓冲区的所有数据“挤”进磁盘。

  3. 调用了 close(): 流关闭时,会进行最后的清理,自动调用一次 flush()。

💡 防坑指南: 务必将 FileWriter 放在 try-with-resources 中,或者在 finally 块中确保 close() 被执行。如果程序中途异常崩溃且流未关闭,缓冲区的数据将会全部丢失。

结合 BufferedReader / BufferedWriter ​

虽然 FileReader 和 FileWriter 很好用,但在实际企业级开发中,我们很少“单兵作战”,通常会给它们套上一层缓冲流(处理流):

  • FileReader ── 升级为 ──> BufferedReader:获得了 readLine() 方法,可以实现按行读取文本,这在处理日志、CSV、配置文件时简直是神器。
  • FileWriter ── 升级为 ──> BufferedWriter:获得了 newLine()方法,可以写入一个跨平台通用换行符(Windows 是\r\n,Linux 是 \n),同时自带 8KB 缓冲区,大幅提升读写吞吐量。

示例:BufferedReader / BufferedWriter 标准示例:

java
try (BufferedReader br = new BufferedReader(new FileReader("input.txt", StandardCharsets.UTF_8));
   BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt", StandardCharsets.UTF_8))) {

  String line;
  // 优雅地按行读取,读到末尾返回 null
  while ((line = br.readLine()) != null) {
    bw.write("【备份】" + line);
    bw.newLine(); // 自动根据当前操作系统写入换行符
  }
} catch (IOException e) {
  e.printStackTrace();
}

最佳实践 ​

  1. 严禁读写二进制文件: 绝不能用 FileWriter 去写图片、视频或加密文件,否则编码器会强行破坏底层的二进制结构,导致文件损坏。
  2. 目录不存在问题:
    • 如果文件不存在,FileWriter 会帮你创建文件;
    • 但如果文件所在的文件夹(目录)不存在,它会直接抛出 FileNotFoundException(系统找不到指定的路径)。
    • 在写入前,应先确保父目录存在(可用 file.getParentFile().mkdirs() 创建目录)。
  3. 编码一致性: 写入文件时用了什么编码(如 UTF-8),后续用 FileReader 或其他工具读取时,也必须指定相同的编码,否则会看到一堆乱码。

字节文件流~ ​

在 Java 的 IO 流世界中,字节文件流(Byte File Streams) 是最底层、最通用、最不可或缺的基石。

无论我们在电脑上看到的是文本、图片、音频、视频,还是可执行程序,在计算机底层的存储形式统统都是 字节(Byte,即 8 位的二进制数 0 或 1)。因此,字节文件流可以说是文件 IO 中的“万能工具”,它能够处理磁盘上的任何文件。

继承体系 ​

字节文件流的核心类只有两个:FileInputStream(字节输入流)和 FileOutputStream(字节输出流)。它们分别继承自整个字节流体系的最高抽象基类:InputStream 和 OutputStream。

  • FileInputStream:把磁盘文件中的数据,以字节为单位读取到程序(内存)中。
  • FileOutputStream:把程序(内存)中的字节数据,写入到磁盘文件中。

API: FileInputStream ​

构造方法 ​
  • FileInputStream FileInputStream():(String name),构造方法。通过指定的文件系统绝对或相对路径名字符串创建输入流。若文件不存在或为目录则抛出异常。

  • FileInputStream FileInputStream():(File file),构造方法。通过指定的物理文件抽象对象创建输入流。生产开发中最常用的标准构建形式。

  • FileInputStream FileInputStream():(FileDescriptor fdObj),构造方法。通过已打开的现存有效文件描述符创建输入流。适用于与现有原生句柄或标准输入对接。

注意事项:

  1. 若传入的物理文件不存在、目标路径指向一个已存在的目录、或者当前进程缺乏操作系统层面的读权限,构造函数均会抛出 java.io.FileNotFoundException。
  2. 使用 FileDescriptor 构造输入流时,如果该描述符无效,将直接抛出 NullPointerException。且该输入流关闭时是否级联释放底层文件句柄需注意生命周期托管风险。
java
// 1. 基于 File 抽象路径安全创建流对象
try (FileInputStream fis = new FileInputStream(file)) {
  System.out.println("Stream initialized successfully.");
} catch (IOException e) {
  e.printStackTrace();
}

// 2. 基于文件路径字符串创建流对象
try (FileInputStream pathFis = new FileInputStream("config/app.bin")) {
  int firstByte = pathFis.read();
} catch (IOException e) {
  e.printStackTrace();
}
字节数据读取 ​
  • int read():(),单字节读取。从输入流中读取单个字节的数据。返回 0 ~ 255 范围内的无符号整数;若到达流的末尾则返回 -1。

  • int read():(byte[] b),数组批量读取。从输入流中读取最多 b.length 个字节并存入目标数组中。返回实际读取的字节数;若到达流末尾返回 -1。

  • int read():(byte[] b, int off, int len),区间受限读取。从输入流中读取最多 len 个字节并存入数组指定偏移量 off 处。返回实际读取字节数,到达末尾返回 -1。

  • long skip():(long n),跳过字节读取。从输入流中跳过并丢弃 n 个字节的数据。返回实际跳过的字节数;若 n 为负数则不跳过任何字节并返回 0。

注意事项:

  1. read(byte[] b) 与 read(byte[] b, int off, int len) 并不保证填满整个缓冲数组,只要底层内核有至少 1 字节数据即刻返回。必须以其实际返回的整数判断有效数据长度。
  2. 严禁使用 while (fis.read(buffer) > 0) 判断循环终止,当读到文件尾部时返回值是 -1,该条件会导致无限死循环;正确条件必须是 != -1。
java
// 1. 批量缓冲区读取模式
int readLength;
while ((readLength = fis.read(buffer)) != -1) {
  processChunk(buffer, 0, readLength);
}

// 2. 指定区间偏移读取模式
int specificRead = fis.read(buffer, 4, 128);

// 3. 跳过前导无用字节
long skipped = fis.skip(16);
状态探测与底层资源 ​
  • int available():(),可用字节预估。返回在不发生阻塞的情况下可从当前输入流读取(或跳过)的剩余字节数估算值。

  • void close():(),关闭输入流。关闭当前流并释放与该流关联的所有系统资源与底层操作系统文件句柄。

  • FileChannel getChannel():(),获取文件通道。返回与当前文件输入流关联的唯一 FileChannel 对象,可用于 NIO 零拷贝传输或文件锁操作。

  • final FileDescriptor getFD():(),获取文件描述符。返回表示与当前输入流关联的打开文件的原生底层 FileDescriptor 对象。

注意事项:

  1. close() 方法具备幂等性,多次调用不会产生副作用。流一旦关闭,后续调用 read() 等方法将抛出 IOException: Stream Closed。
  2. getChannel() 返回的通道与当前 FileInputStream 共享同一个底层文件位置偏移指针(Position)。对通道执行位置变更会同步影响输入流的读取位置。
java
// 1. 探测当前可无阻塞读取的剩余估算字节数
int remainingEstimate = fis.available();

// 2. 获取通道以便后续进行高阶通道操作
FileChannel channel = fis.getChannel();

// 3. 检查底层文件描述符有效性
boolean isValid = fis.getFD().valid();
继承:InputStream ​

mark, markSupported, nullInputStream, readAllBytes, readNBytes, reset, transferTo

API: FileOutputStream ​

构造方法 ​
  • FileOutputStream FileOutputStream():(String name),构造方法。根据指定的文件路径字符串创建文件输出流,默认采用覆写模式。

  • FileOutputStream FileOutputStream():(String name, boolean append),构造方法。根据指定的文件路径字符串创建文件输出流,可指定是否为追加写入模式。

  • FileOutputStream FileOutputStream():(File file),构造方法。根据传入的 File 对象创建文件输出流,默认采用覆写模式。

  • FileOutputStream FileOutputStream():(File file, boolean append),构造方法。根据传入的 File 对象创建文件输出流,可指定是否为追加写入模式。

  • FileOutputStream FileOutputStream():(FileDescriptor fdObj),构造方法。通过已打开的现存有效文件描述符创建文件输出流。

注意事项:

  1. 若目标文件的父级目录不存在,构造函数会直接抛出 java.io.FileNotFoundException。FileOutputStream 不会自动递归创建缺失的父级目录。
  2. boolean append 为 false 时,若目标文件已存在,底层会在创建流的瞬间直接清空(截断为 0 字节)该文件。若后续发生异常提前退出,原文件数据也会完全丢失。
  3. 使用 FileDescriptor 构造流时,如果该描述符无效,会抛出 NullPointerException。通过此构造器实例化的流不会获得底层文件描述符的独占所有权,必须谨慎管理其关闭生命周期。
java
// 1. 基于 File 对象以默认覆写模式构建
try (FileOutputStream fos = new FileOutputStream(file)) {
  fos.write(65);
}

// 2. 基于文件路径字符串以末尾追加模式构建
try (FileOutputStream appendFos = new FileOutputStream("output.log", true)) {
  appendFos.write(66);
}

// 3. 基于已存在有效文件描述符构建流
try (FileOutputStream fdFos = new FileOutputStream(existingFd)) {
  fdFos.write(67);
}
字节数据输出 ​
  • void write():(int b),单字节输出。将指定的字节(保留传入整型的低 8 位)写入当前文件输出流。

  • void write():(byte[] b),数组批量输出。将指定字节数组中的所有字节顺序写入当前文件输出流。

  • void write():(byte[] b, int off, int len),区间受限输出。从指定字节数组的偏移量 off 开始,将连续 len 个字节写入当前文件输出流。

注意事项:

  1. write(int b) 仅输出参数 b 的最低 8 位(b & 0xFF),高 24 位被自动舍弃,无法直接传入大于 255 的整型数据并期望完整输出。
  2. write(byte[] b, int off, int len) 会进行边界检查,若 off < 0、len < 0 或 off + len > b.length,将抛出 IndexOutOfBoundsException。
  3. write 系列方法均为阻塞调用,未完成内核拷贝前当前线程不会返回;在挂载网络文件系统(NFS)或磁盘 I/O 饱和时,可能导致线程阻塞停顿。
java
// 1. 批量写入完整缓冲区
fos.write(buffer);

// 2. 单字节输出(仅取低 8 位)
fos.write(0xFF);

// 3. 从偏移量开始输出受限长度数据
fos.write(buffer, 4, 128);
底层资源管理 ​
  • void close():(),关闭输出流。关闭当前文件输出流并释放与该流关联的系统资源及底层文件描述符。

  • FileChannel getChannel():(),获取文件通道。返回与此文件输出流关联的唯一 FileChannel 对象。

  • final FileDescriptor getFD():(),获取文件描述符。返回表示与此文件输出流连接的底层文件描述符对象。

注意事项:

  1. close() 方法具备幂等性,多次调用不会产生异常。流一旦关闭,后续对 write() 等方法的调用将直接抛出 IOException: Stream Closed。
  2. getChannel() 返回的 FileChannel 与当前 FileOutputStream 共享同一个底层系统文件游标(Position),通道位置变化会同步影响后续流式写入的目标物理位置。
  3. 关闭 FileOutputStream 时会级联关闭其派生的 FileChannel;反之,关闭 FileChannel 亦会导致底层流失效。
java
// 1. 显式释放所有底层系统句柄
fos.close();

// 2. 获取与该流绑定的唯一 NIO 文件通道
FileChannel channel = fos.getChannel();

// 3. 获取底层物理文件描述符以进行状态校验或硬件刷盘
FileDescriptor fd = fos.getFD();
继承:OutputStream ​

flush, nullOutputStream

中文乱码问题 ​

虽然字节流被称为“万能流”,但在单独使用字节流去读取包含中文的纯文本文件时,会有一个巨大的硬伤——乱码。

为什么会乱码:

在现代常用的 UTF-8 编码中,一个英文字符占 1 个字节,而一个中文字符通常占 3 个字节。

  1. 如果你使用 read() 单字节读取,每次只读 1/3 个汉字,强行转换成字符就会变成乱码。

  2. 即使你用 read(byte[] buffer) 成批读取,如果缓冲区的边界刚好切在了一个汉字的中间(例如一个汉字的 3 个字节中,前 2 个字节在本次 buffer 里,第 3 个字节在下次 buffer 里),就会发生断字,导致前后两个汉字全部变成乱码。

救兵是谁?

  • 如果是专门操作纯文本,应该使用 字符文件流(FileReader/FileWriter)。
  • 如果必须用字节流处理文本,应该在外层套上 转换流(InputStreamReader),或者一次性把文件的所有字节读入内存(如 Files.readAllBytes(),仅限小文件)。

实战:文件复制 ​

在开发中,最能体现字节文件流价值的场景就是文件复制(如复制图片、音乐、视频、压缩包等二进制文件)。

下面这个模板是 Java IO 中最经典的文件复制标准写法:

java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class FileCopyDemo {
  public static void main(String[] args) {
    String srcPath = "source.mp4"; // 源文件(大视频)
    String destPath = "dest_copy.mp4"; // 目标文件

    long startTime = System.currentTimeMillis();

    // 同时打开输入流和输出流
    try (FileInputStream fis = new FileInputStream(srcPath);
       FileOutputStream fos = new FileOutputStream(destPath)) {

      // 1. 定义一个缓冲区(通常为 1KB 到 8KB 之间)
      byte[] buffer = new byte[1024 * 8]; // 8KB 缓冲区
      int len;

      // 2. 边读边写
      while ((len = fis.read(buffer)) != -1) {
        // 必须使用 write(buffer, 0, len)
        // 读到多少字节,就写多少字节,防止最后一次读取不满时写入旧数据
        fos.write(buffer, 0, len);
      }

      long endTime = System.currentTimeMillis();
      System.out.println("视频复制成功!耗时:" + (endTime - startTime) + " 毫秒");

    } catch (IOException e) {
      e.printStackTrace();
      System.err.println("复制失败:" + e.getMessage());
    }
  }
}

最佳实践 ​

  1. 务必使用缓冲区(byte[] buffer):千万不要写出 while(fis.read() != -1) 这样一次只读一个字节的代码。如果不加缓冲区,每读一个字节程序就要向操作系统发起一次硬件磁盘 IO,这会导致程序运行速度慢上成百上千倍!

  2. 安全关闭资源:流是极其消耗操作系统资源的。一旦打开,必须关闭。强烈建议在开发中全部使用 try-with-resources 结构,让 Java 替你自动、安全地关闭流。

  3. write(b, 0, len) 的金律:在复制或写入文件时,永远使用带有三个参数的 write 方法。如果只用 write(b),最后一次读取不满缓冲区时,尾部会残留上一次读取的旧数据,导致复制出来的文件比源文件变大或损坏。

  4. 字节流的升级版:如果要追求更高的性能,可以在 FileInputStream 外面再包裹一层 BufferedInputStream(字节缓冲流)。它在内存中搭建了二次缓冲区,能够进一步压榨磁盘的读写吞吐量。

处理流-缓冲流 ​

在 Java 的 IO 流世界中,缓冲流(Buffered Streams) 属于高级的处理流(包装流)。它是整个 Java IO 体系中的“性能加速器”。

传统的节点流(如 FileInputStream、FileReader)就像是用汤匙一口一口地喝汤,每次都要向操作系统和磁盘发起请求,效率极低。而缓冲流则像是在汤匙和嘴之间加了一个大碗(缓冲区),先把碗装满,再从碗里高效地喝汤,极大地减少了硬件 I/O 的次数。

工作原理 ​

缓冲流的核心思想就是空间换时间。

它的内部自带了一个字节或字符数组作为缓冲区(默认大小通常是 8192,即 8KB)。

  • 输入缓冲流:当程序调用 read() 时,缓冲流会一次性从文件中读取 8KB 的数据填满自己的缓冲区。接下来的读取操作都会直接在内存缓冲区中进行,直到这 8KB 数据被消费完,才会再次去读磁盘。
  • 输出缓冲流:当程序调用 write() 时,数据并不会立刻写入磁盘,而是先暂存在内存缓冲区中。只有当缓冲区满了、或者显式调用了 flush()、或者流被关闭(close())时,流才会把这 8KB 的数据一次性写入磁盘。

四大核心类 ​

缓冲流并不直接连接文件,它必须包裹在普通节点流的外面。根据处理数据的类型,缓冲流分为以下四种:

基础流分类对应的输入缓冲流对应的输出缓冲流核心优势与特有功能
字节流 (Byte)BufferedInputStreamBufferedOutputStream极大提升图片、视频、压缩包等二进制文件的复制和读写性能。
字符流 (Char)BufferedReaderBufferedWriter极大提升文本文件的读写性能;支持按行读写。

字节缓冲流~ ​

在 Java 的 IO 流体系中,字节缓冲流(Byte Buffered Streams) 是专门用来提升二进制文件(如图片、音频、视频、压缩包等)读写性能的高级处理流(也称包装流)。

如果直接使用 FileInputStream 和 FileOutputStream,程序每次读写字节都需要直接与操作系统和磁盘进行交互,这会产生大量的硬件系统调用,导致性能低下。而字节缓冲流在内部维护了一个内存缓冲区,通过减少磁盘物理读写次数,极大地压榨了 I/O 的吞吐量。

核心家族成员 ​

字节缓冲流只有两个核心类,它们分别继承自 FilterInputStream 和 FilterOutputStream(间接继承自 InputStream 和 OutputStream):

  1. BufferedInputStream(字节缓冲输入流):提升读取字节数据的效率。

  2. BufferedOutputStream(字节缓冲输出流):提升写入字节数据的效率。

它们属于处理流,无法直接连接具体的磁盘文件,必须“包裹”在普通的节点流(如 FileInputStream / FileOutputStream)之外才能工作。

API: BufferedInputStream ​

构造方法 ​
  • BufferedInputStream BufferedInputStream():(InputStream in),标准包装构造。创建一个带有默认 8192 字节内部缓冲区的缓冲输入流。

  • BufferedInputStream BufferedInputStream():(InputStream in, int size),自定义容量构造。根据指定的缓冲区容量 size 创建一个缓冲输入流。

注意事项:

  1. 缓冲区大小并不是越大越好。在一般机械磁盘或 SSD 上,8KB 到 16KB 是平衡吞吐量与堆内存开销的黄金分割点。如果面对数 GB 的超大文件读写流,可以手动将其调整为 64KB (65536) 或 128KB。
java
import java.io.FileInputStream;
import java.io.BufferedInputStream;
import java.io.IOException;

public class BisConstructorDemo {
  public static void main(String[] args) {
    try (FileInputStream fis = new FileInputStream("data.bin")) {
      // 1. 使用默认 8192 字节缓冲区进行基础包装
      BufferedInputStream bisDefault = new BufferedInputStream(fis);

      // 2. 针对大数据量场景,定制 64KB 缓冲区
      BufferedInputStream bisCustom = new BufferedInputStream(fis, 64 * 1024);

      bisDefault.close();
      bisCustom.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字节读取 ​
  • int read():(),从缓冲区读取单字节。从此输入流中读取一个字节。如果缓冲区为空,则先触发底层填充。若到达流末尾返回 -1。

  • int read():(byte[] b, int off, int len),片段读取至目标数组。将缓冲区内的字节批量读入到目的字节数组 b 中,从偏移量 off 开始,最多读取 len 个字节。

注意事项:

  1. 虽然使用了 BufferedInputStream,但依然不推荐频繁在大循环内调用无参的 read()。即使没有物理 I/O 损耗,频繁的方法栈帧压栈与指针越界校验依旧会消耗 CPU 性能。最佳实践是使用 read(byte[] b, int off, int len) 再次进行批量承接。
java
import java.io.FileInputStream;
import java.io.BufferedInputStream;
import java.io.IOException;

public class BisReadDemo {
  public static void main(String[] args) {
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("data.bin"))) {
      byte[] appBuffer = new byte[1024]; // 上层应用层缓冲区
      int actualBytes;

      // 此时调用优先从内存块 buf 中复制数据,效率极高
      while ((actualBytes = bis.read(appBuffer, 0, appBuffer.length)) != -1) {
        // 仅处理 0 到 actualBytes 的有效数据段
        System.out.println("从高速流成功读取块容量: " + actualBytes);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
标记与流控制 ​
  • void mark():(int readlimit),流位置标记。在此输入流的当前位置放置一个标记。参数 readlimit 告知此流在标记位置失效之前允许读取的最大字节数。

  • void reset():(),流位置重置。将当前读取指针重新定位到最后一次对该流调用 mark() 方法时的位置。

  • boolean markSupported():(),测试标记支持。测试此流是否支持 mark 和 reset。对于 BufferedInputStream 实例而言,恒定返回 true。

  • long skip():(long n),跳过指定字节。跳过并丢弃此流中的 n 个字节,返回实际跳过的字节数。

  • int available():(),获取立即可读字节数。返回不需要阻塞即可从此流中读取的剩余字节数的估计值。

  • void close():(),关闭流资源。关闭此缓冲流,并无条件级联关闭其内部封装的底层节点流,物理释放系统描述符。

注意事项:

  1. mark(int readlimit) 的底层实现极其特殊。如果在 mark() 之后读取的字节数超出了 readlimit,或者超出了 BufferedInputStream 内部缓冲区的大小,内部会触发 buf 数组的重新扩容。为防止内存突变或 OutOfMemoryError,不要在标记期间读取超长的数据。
  2. 关闭 BufferedInputStream 会自动关闭它包裹的底层 FileInputStream。在 try-with-resources 块中,只需声明包裹流即可。
java
import java.io.FileInputStream;
import java.io.BufferedInputStream;
import java.io.IOException;

public class BisControlDemo {
  public static void main(String[] args) {
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("data.bin"))) {

      // 1. 验证标记流支持度
      if (bis.markSupported()) {
        System.out.println("当前流完美支持回溯操作");
        // 标记当前起点位置,设定允许向后探索 10 字节
        bis.mark(10);
      }

      // 2. 读取部分探针数据
      int firstByte = bis.read();
      System.out.println("探针首字节: " + firstByte);

      // 3. 执行流位置重置,将指针归位到 mark 处
      bis.reset();

      // 4. 再次读取,结果将与上面一致
      int verifiedByte = bis.read();
      System.out.println("重置后再次读取首字节: " + verifiedByte);

      // 5. 其他控制 API 的协同
      long skipped = bis.skip(5);
      int remain = bis.available();
      System.out.println("跳过: " + skipped + " 字节, 剩余可预测读取: " + remain);

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

API: BufferedOutputStream ​

构造方法 ​
  • BufferedOutputStream BufferedOutputStream():(OutputStream out),标准包装构造。创建一个带有默认 8192 字节内部缓冲区的缓冲输出流,用于包装指定的底层字节输出流。

  • BufferedOutputStream BufferedOutputStream():(OutputStream out, int size),自定义容量构造。根据指定的缓冲区容量 size 创建一个缓冲输出流。在超大文件串行写入或高吞吐量网络通信场景中,可手动调大该值以进一步优化 I/O 效率。

注意事项:

  1. 传入的自定义缓冲区大小 size 必须大于 0,否则会抛出 IllegalArgumentException。在实际工业级调优中,通常将其设置为底层操作系统文件块(Block Size)的整数倍(如 16384 或 65536 字节)。
java
import java.io.FileOutputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;

public class BosConstructorDemo {
  public static void main(String[] args) {
    try (FileOutputStream fos = new FileOutputStream("archive.bin")) {
      // 1. 使用默认 8KB 缓冲区进行基础流包装
      BufferedOutputStream bosDefault = new BufferedOutputStream(fos);

      // 2. 针对大数据量高并发落盘场景,定制 64KB 的大型缓冲区
      BufferedOutputStream bosCustom = new BufferedOutputStream(fos, 64 * 1024);

      bosDefault.close();
      bosCustom.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字节写入与刷新 ​
  • void write():(int b),写入单字节至缓冲区。将指定的字节(传入参数 b 的低 8 位)写入内部字节缓冲数组中。数据此时仅滞留在内存中。

  • void write():(byte[] b, int off, int len),片段写入缓冲区。将指定字节数组 b 中从偏移量 off 开始的 len 个字节高速复制到内部缓冲数组中。

  • void flush():(),强制刷新流缓冲区。强行将内部缓冲区中当前留存的所有字节数据完全写入下层的物理输出流中,并清空缓冲区。

  • void close():(),关闭流资源。关闭此缓冲输出流。在物理关闭之前,该方法会自动隐式触发一次 flush() 操作,随后级联关闭其封装的底层物理流并释放关联的物理句柄。

注意事项:

  1. 潜在的数据丢失风险:如果通过 write 写入的数据总量没有填满 8KB 缓冲区(例如仅写入了 500 个字节),且程序在异常结束前没有显式调用 close() 或 flush(),那么滞留在内存缓冲区中的尾部数据将无法写入磁盘,从而造成物理文件内容的永久残缺。
  2. 级联关闭机制:关闭装饰器流 BufferedOutputStream 时,其内部实现会通过 finally 块确保下层包裹的 FileOutputStream 被安全关闭。开发中严禁编写双重关闭逻辑,直接关闭最外层的流即可。
java
import java.io.FileOutputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;

public class BosWriteDemo {
  public static void main(String[] args) {
    byte[] serverSignal = {0x02, 0x1F, 0x3D, 0x4E};

    // 推荐使用 try-with-resources 语法,其在编译期会自动生成 close() 的安全闭环逻辑
    try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("stream_signal.bin"))) {

      // 1. 写入单个字节数据至内存缓冲数组 buf
      bos.write(0x01);

      // 2. 将特定的字节片段高速复制到内部缓冲区中
      bos.write(serverSignal, 0, serverSignal.length);

      // 3. 显式刷新缓冲区,确保小数据量也能立即刷出到下层操作系统通道
      bos.flush();

    } catch (IOException e) {
      System.err.println("持久化数据块发生故障: " + e.getMessage());
    }
  }
}

实战:高清视频的高效复制 ​

在开发中,字节缓冲流最经典的应用场景就是大文件复制。下面是结合 try-with-resources 的标准企业级写法:

java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class ByteBufferedStreamDemo {
  public static void main(String[] args) {
    String sourceFile = "movie.mp4"; // 源大文件
    String destFile = "movie_copy.mp4"; // 目标文件

    long startTime = System.currentTimeMillis();

    // 1. 包装节点流。外层是缓冲流,内层是文件流
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(sourceFile));
       BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destFile))) {

      // 2. 结合用户自定义的数组缓冲区(双重加速)
      byte[] buffer = new byte[1024 * 4]; // 4KB 的用户缓冲区
      int len;

      // bis.read 会优先从 8KB 的内存缓冲区中取数据,取不到了再批量读磁盘
      while ((len = bis.read(buffer)) != -1) {
        bos.write(buffer, 0, len);
      }

      long endTime = System.currentTimeMillis();
      System.out.println("视频复制完成!耗时:" + (endTime - startTime) + " 毫秒");

    } catch (IOException e) {
      e.printStackTrace();
      System.err.println("复制失败:" + e.getMessage());
    }
  }
}

双重缓冲:buffer+ 缓冲流 ​

这是面试中非常高频的一个核心痛点问题。很多同学会问:“我自己都定义了一个 byte[] buffer = new byte[1024] 一次读 1KB 了,为什么还要套一层 BufferedInputStream?”

这两者的缓冲维度和优化层次是不同的:

  1. 你定义的 byte[] buffer(用户缓冲区):

    解决的是程序内部循环次数的问题。如果你一次读一个字节,大文件需要循环几百万次;定义了数组,循环次数减少到 1/1024。

  2. 缓冲流内部的 buf[](流缓冲区/系统级优化):

    解决的是减少用户态到内核态的物理 I/O 切换。

    即使你在代码中用 read(new byte[1]) 一次只读一个字节,BufferedInputStream 也会自动帮底层的物理磁盘发起 8KB 的批量预读。它保证了无论你的上层代码写得多么糟糕(例如单字节循环读取),底层的磁盘 I/O 依然保持高吞吐量的批量交互。

结论:在实际开发中,“缓冲流” 加上 “自定义 byte[] 数组” 双剑合璧,才能达到最佳的 I/O 性能。

最佳实践 ​

  1. 资源关闭的顺序(先开后闭):

    由于缓冲流包裹了文件流,关闭时只需要关闭最外层的缓冲流即可。缓冲流的 close() 方法内部会自动调用内层节点流的 close()。如果手动去挨个关闭,不仅代码冗余,还容易抛出 Stream Closed 异常。使用 try-with-resources 结构则无需担心此问题。

  2. 切记输出流的 flush():

    由于 BufferedOutputStream是攒满 8KB 才写磁盘,如果数据只有 1KB 且程序没有正常关闭流,数据就会憋在内存里造成丢失。在长连接传输、或者关键数据写入后,应手动调用bos.flush() 强制将数据刷入磁盘。

  3. 不要用它处理中文文本:

    虽然字节缓冲流也可以读写 .txt 文件,但它毕竟是以字节为单位。如果中文字符在读取时被截断(例如 UTF-8 的 3 字节汉字,前 2 个字节在本次读取中,第 3 个字节在下次中),就会产生乱码。纯文本文件请右转使用字符缓冲流(BufferedReader / BufferedWriter)。

字符缓冲流~ ​

在 Java 的 IO 流体系中,字符缓冲流(Character Buffered Streams) 是专门用来高效处理纯文本文件(如 .txt, .json, .xml, .csv, .java)的高级处理流(也称包装流)。

如果说普通的字符流(FileReader / FileWriter)是徒手一砖一瓦地搬运文本,那么字符缓冲流就是开着一辆带有自动化传送带的翻斗车。它不仅通过内部的内存缓冲区极大提升了读写吞吐量,还带来了诸如“按行读取”、“跨平台换行”等企业级开发中必不可少的“神级功能”。

核心家族成员 ​

字符缓冲流并不直接连接具体的磁盘文件,它们必须依托于底层的普通字符流(节点流)才能工作。整个家族的核心成员只有两个:

  1. BufferedReader(字符缓冲输入流):继承自 Reader,用于高效读取文本。

  2. BufferedWriter(字符缓冲输出流):继承自 Writer,用于高效写入文本。

工作原理 ​

字符缓冲流之所以性能强劲,核心在于其内部维护了一个字符数组缓冲区(char[] cb),默认大小为 8192。

注意内存大小的区别:

字节缓冲流的默认大小是 8192 字节(8KB)。

字符缓冲流的默认大小是 8192 个字符(在 Java 中一个 char 占 2 字节,因此其内部缓冲区实际占用约 16KB 的内存空间)。

BufferedReader 的读机制:

当程序调用 read() 或 readLine() 时,它会一次性从物理文件中读取 8192 个字符填满这个内部数组。后续的读取操作直接在内存数组中进行。直到这 8192 个字符被消耗完毕,才会发起下一次磁盘物理 I/O。

BufferedWriter 的写机制:

程序写入的字符不会立刻磕磁盘,而是先按顺序塞进 8192 字符的内存数组中。只有当数组被塞满、或者手动调用 flush()、或者流被关闭(close()) 时,流才会把缓冲区里的字符一次性“倾倒”进底层的物理文件中。

API: BufferedReader ​

构造方法 ​
  • BufferedReader BufferedReader():(Reader in),标准包装构造。创建一个使用默认大小(8192 个字符)输入缓冲区的缓冲字符输入流,用于包装指定的字符输入流。

  • BufferedReader BufferedReader():(Reader in, int sz),自定义容量构造。根据指定的缓冲区容量 sz 创建一个缓冲字符输入流。

注意事项:

  1. 传入的底层字符流 Reader 对象不能为 null,否则会引发 NullPointerException。
  2. 自定义容量 sz 必须大于 0。在日常业务中默认的 8192 字符大小已足够应对绝大多数文本解析场景;但在面对超长文本行(如巨型单行 JSON 报文或混淆后的前端代码文件)时,可适当将容量调大以防止行边界计算引发频繁扩容。
java
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class BrConstructorDemo {
  public static void main(String[] args) {
    try (FileReader fr = new FileReader("workspace.log")) {

      // 1. 经典的标准包装方式(开辟默认 8192 字符的缓冲区)
      BufferedReader brDefault = new BufferedReader(fr);

      // 2. 自定义大型文本解析场景下的缓冲区(开辟 32768 字符的缓冲区)
      BufferedReader brCustom = new BufferedReader(fr, 32 * 1024);

      brDefault.close();
      brCustom.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
核心读取与流控制方法 ​
  • int read():(),从缓冲区读取单字符。从内部字符缓冲区中读取并返回下一个字符。若缓冲区无数据,则触发底层 Reader 填充。若到达文本末尾(EOF),则返回 -1。

  • int read():(char[] cbuf, int off, int len),片段读取至目标数组。将缓冲区内的字符批量复制到目的字符数组 cbuf 中,从偏移量 off 开始,最多读取 len 个字符。

  • String readLine():(),高阶文本行读取。读取并返回一个完整的文本行。当读取到换行符(\n)、回车符(\r)或回车换行符(\r\n)时判定当前行结束。若已到达文件流末尾,则返回 null。

  • boolean ready():(),检查流就绪状态。告知此流是否已准备好被读取。若缓冲区不为空,或者底层字符流已就绪,则返回 true。

  • void close():(),关闭字符流。关闭此缓冲流,并无条件级联关闭其内部包裹的底层 Reader 物理流,释放系统文件描述符。

注意事项:

  1. 核心避坑点:readLine() 方法返回的字符串中绝不包含行终止符(即丢弃了尾部的 \n、\r 或 \r\n)。如果在文本文件复制或网络文本重组的业务中直接使用 write(line),会导致最终写入的数据变成无换行的单行文本,必须在写入时手动补充跨平台的换行符。
  2. 使用 try-with-resources 结构管理 BufferedReader 时,只需要显式闭合最外层的包装流。外层流的 close() 内部具有完善的 finally 级联释放机制,能够安全地连同底层的物理节点流一并释放。
java
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class BrReadDemo {
  public static void main(String[] args) {
    // 利用 try-with-resources 语法保障流的生命周期安全
    try (BufferedReader br = new BufferedReader(new FileReader("workspace.log"))) {

      // 1. 在读取前利用 ready() 做无阻塞检查
      if (br.ready()) {
        System.out.println("数据流状态:处于就绪态");
      }

      String logLine;
      // 2. 经典的按行循环迭代解析模式,直至返回 null 标志文本终止
      while ((logLine = br.readLine()) != null) {
        // 注意:此时拿到的 logLine 内部没有附带尾部的换行符
        System.out.println("提取物理行: " + logLine);
      }

    } catch (IOException e) {
      System.err.println("解析流中途触发异常: " + e.getMessage());
    }
  }
}
函数式流处理方法 ​
  • Stream<String> lines():(),获取惰性求值流。返回一个由该 BufferedReader 延迟读取的文本行组成的函数式 Stream<String> 序列。(自 JDK 8 引入)

注意事项:

  1. lines() 方法返回的是一个惰性求值(Lazy Evaluation)的 Stream。在其上的管道操作(如 filter、map)只有在触发终止操作(如 forEach、collect)时才会真正驱动 BufferedReader 进行磁盘字符读取。
  2. 如果在利用 lines() 进行流式管道处理的过程中发生 IOException,该异常会被自动包装在运行时异常 UncheckedIOException 中抛出。
java
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.stream.Stream;

public class BrStreamDemo {
  public static void main(String[] args) {
    try (BufferedReader br = new BufferedReader(new FileReader("workspace.log"))) {

      // 获取高度工程化的 Stream 字符流
      Stream<String> lineStream = br.lines();

      // 函数式声明式处理:过滤出带有 [ERROR] 标识的日志行,并将其直接输出
      lineStream.filter(line -> line.contains("[ERROR]"))
            .forEach(System.out.println);

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

API: BufferedWriter ​

构造方法 ​
  • BufferedWriter BufferedWriter():(Writer out),标准包装构造。创建一个使用默认大小(8192 个字符)输出缓冲区的缓冲字符输出流,用于包装指定的字符输出流。

  • BufferedWriter BufferedWriter():(Writer out, int sz),自定义容量构造。根据指定的缓冲区容量 sz 创建一个缓冲字符输出流。

注意事项:

  1. 传入的底层字符流 Writer 实例不允许为 null,否则构造时将直接引发 NullPointerException。
  2. 自定义缓冲区大小 sz 必须大于 0,否则会抛出 IllegalArgumentException。在常规业务中默认的 8192 字符大小已足够高效;但在超大规模批量文本数据生成(如服务端离线导出百万级报文文件)时,可手动调大该值(如 32768 或 65536)以进一步压榨物理磁盘的顺序写入性能。
java
import java.io.FileWriter;
import java.io.BufferedWriter;
import java.io.IOException;

public class BwConstructorDemo {
  public static void main(String[] args) {
    try (FileWriter fw = new FileWriter("app_config.json")) {

      // 1. 标准流包装方式(开辟默认 8192 字符的内部缓冲区)
      BufferedWriter bwDefault = new BufferedWriter(fw);

      // 2. 高吞吐自定义容量包装方式(开辟 16384 字符的内部缓冲区)
      BufferedWriter bwCustom = new BufferedWriter(fw, 16384);

      bwDefault.close();
      bwCustom.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字符与字符串写入 ​
  • void write():(int c),写入单字符至缓冲区。向内部字符缓冲区写入单个字符。参数 c 的低 16 位被写入,高 16 位被忽略。

  • void write():(char[] cbuf, int off, int len),片段写入字符数组至缓冲区。从字符数组 cbuf 的偏移量 off 开始,连续将长度为 len 的字符批量复制到内部缓冲区中。

  • void write():(String s, int off, int len),片段写入字符串至缓冲区。从字符串 s 的偏移量 off 开始,连续将长度为 len 的字符批量复制到内部缓冲区中。

    java
    import java.io.FileWriter;
    import java.io.BufferedWriter;
    import java.io.IOException;
    
    public class BwWriteDemo {
      public static void main(String[] args) {
        char[] segment = {'J', 'a', 'v', 'a', ' ', 'I', 'O'};
        String message = "Architecture_Note_2026";
    
        try (BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt"))) {
    
          // 1. 写入单个字符(字符 'H' 的 ASCII 值为 72)
          bw.write(72);
    
          // 2. 批量写入字符数组的指定片段(仅写入 "Java")
          bw.write(segment, 0, 4);
    
          // 3. 批量写入高层字符串的指定片段(仅写入 "Architecture")
          bw.write(message, 0, 12);
    
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    }
换行与刷新控制 ​
  • void newLine():(),跨平台换行符写入。向流中写入一个行分隔符。该换行符由系统属性 line.separator 动态决定,在 Windows 环境下会自动写入 \r\n,在 Linux/Unix 环境下会自动写入 \n。

  • void flush():(),冲刷字符流缓冲区。强制将当前滞留在内部字符缓冲区中的所有数据刷出到底层流中,从而触发物理持久化。

  • void close():() silent。关闭字符流。关闭此流。在物理关闭前系统会自动隐式先调用一次 flush(),随后释放与之关联的操作系统资源,并级联关闭底层物理节点流。

注意事项:

  1. 核心缺陷防范(丢失数据风险):由于 BufferedWriter 存在内部用户态缓冲区,如果向流中写入字符后既没有填满 8192 字符空间,又没有显式执行 flush() 或 close() 闭合操作,这部分滞留在堆内存中的字符将永远不会真正落盘,进而导致生成的文件内容残缺、丢失。
  2. 换行标准规范:在处理纯文本文件时,强烈建议放弃使用固定的 \n 或 \r\n 字符串,统一改用 newLine() 方法。这可以完全消除由于跨平台部署(如 Windows 平台研发,Linux 生产环境运行)因底层换行标识符不一致而导致的文本解析错误、错行问题。
java
import java.io.FileWriter;
import java.io.BufferedWriter;
import java.io.IOException;

public class BwControlDemo {
  public static void main(String[] args) {
    try (BufferedWriter bw = new BufferedWriter(new FileWriter("runtime.log"))) {

      bw.write("Line 1: System Bootstrapped.");
      // 1. 写入契合当前操作系统底层规范的换行符
      bw.newLine();

      bw.write("Line 2: Service Discovered.");
      // 2. 手动进行缓冲区强制刷新,使得当前内容在磁盘立即实体可见
      bw.flush();

    } catch (IOException e) {
      e.printStackTrace();
    } // 3. try-with-resources 块结束时会自动调用 close(),安全闭合流通道
  }
}

实战:文本复制与过滤 ​

在实际企业级开发中,我们通常将两者结合使用。以下是一个经典的文本备份与数据清洗(过滤)的标准模板:

java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class TextFilterCopyDemo {
  public static void main(String[] args) {
    String source = "raw_data.csv";
    String target = "clean_data.csv";

    // 双剑合璧:同时开启读、写字符缓冲流
    try (BufferedReader br = new BufferedReader(new FileReader(source, StandardCharsets.UTF_8));
       BufferedWriter bw = new BufferedWriter(new FileWriter(target, StandardCharsets.UTF_8))) {

      String line;
      int count = 0;

      // 边读边处理
      while ((line = br.readLine()) != null) {
        // 示例业务:过滤掉包含 "TEST" 关键字的脏数据
        if (!line.contains("TEST")) {
          bw.write(line);   // 写入干净的数据
          bw.newLine();     // 补上被 readLine 剥离的换行符
          count++;
        }
      }
      System.out.println("数据清洗完毕,成功导出 " + count + " 行有效数据!");

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

面试题 ​

Q1:字符缓冲流和字节缓冲流有什么区别

  • 处理单元不同:字节缓冲流处理的是 8 位的 byte,面向的是二进制数据(图音视、压缩包);字符缓冲流处理的是 16 位的 char,面向的是人类可读的纯文本。
  • 编码转换:字符缓冲流在底层依托于 Reader/Writer,内部带有字符解码器/编码器,能把字节自动转为指定字符集(如 UTF-8)的字符,彻底避免了字节流读文本时因“断字”产生的乱码问题。

Q2:使用完缓冲流后,关闭资源的顺序是怎样的

结论:只需要关闭最外层的缓冲流即可。

在 try-with-resources 或手动关闭时,你无需显式关闭内层的 FileReader/FileWriter。因为外层包装流(如 BufferedReader)的 close() 方法内部,第一步就是去调用它所包裹的内层流的 close()。如果手动去挨个关,代码臃肿且容易触发 Stream Closed 异常。


Q3:为什么 BufferedWriter 写完数据后文件是空的

原因:数据被憋在 8192 字符的缓冲区里了。

如果你写入的字符数很少(比如就几十个字),且程序在运行过程中非正常退出,或者忘记调用 close() 方法,这些数据就不会被推入物理磁盘。

  • 解法:养成使用 try-with-resources 的好习惯,它能在程序退出代码块时自动闭流并触发 flush()。

最佳实践 ​

在 Java IO 开发中,有一条不成文的“金科律令”:只要是读写文件,除非文件极其微小,否则永远要在节点流的外面套上一层缓冲流。

  • 传文件、图音视:FileInputStream ──> 套上 ──> BufferedInputStream

  • 读写文本文档:FileReader ──> 套上 ──> BufferedReader

处理流-转换流 ​

在 Java 的 IO 流世界中,转换流(Conversion Streams) 扮演着“随身翻译官”的角色。

计算机底层只懂二进制的字节(Byte),而人类只看得懂文字字符(Character)。转换流的核心使命,就是在字节流和字符流之间搭建一座桥梁。它能够把底层的字节流转换成高层的字符流,或者反过来,并且在转换过程中,允许我们显式指定字符编码集(如 UTF-8、GBK),从而彻底解决令人头疼的文本乱码问题。

继承体系 ​

转换流属于处理流(包装流)。从底层的类继承关系来看,它们直接继承自字符流的最高抽象基类 Reader 和 Writer,而我们常用的 FileReader 和 FileWriter 在底层其实是它们的子类。

image-20260711175924162

整个转换流家族只有两个核心类:

  1. InputStreamReader

    • 方向:把输入的字节流转为字符流。

    • 本质:解码 (decoding)。它把磁盘或网络传过来的、计算机听得懂的二进制“字节”,翻译成人类看得懂的“文字字符”。

  2. OutputStreamWriter

    • 方向:把输出的字符流转为字节流。

    • 本质:编码 (encoding)。它把程序在内存中处理的“文字字符”,打包、翻译成计算机底层用来存储或传输的二进制“字节”。

转换流的作用 ​

在旧版本的 Java 中(Java 11 之前),传统的文本节点流 FileReader 和 FileWriter 有一个巨大的硬伤:它们无法在构造函数中指定字符编码,只能死板地使用操作系统的默认编码。

这就导致了一个灾难性的场景:

一个在 Linux 下用 UTF-8 编码保存的文本文件,如果拿到默认编码为 GBK 的 Windows 电脑上用 FileReader 直接读取,屏幕上立刻就会喷出满屏的乱码。

转换流就是解决这个问题的终极武器。它在包裹住字节流(如 FileInputStream)的同时,允许你强行指定一个编码格式。

API: InputStreamReader ​

构造方法 ​

  • InputStreamReader InputStreamReader():(InputStream in),构造方法。根据传入的底层字节输入流创建对象,使用平台默认字符集(JDK 18+ 默认统一为 UTF-8)。

  • InputStreamReader InputStreamReader():(InputStream in, String charsetName),构造方法。根据传入的字节输入流及指定的字符集名称创建对象;若传入不受支持的编码名称则抛出 UnsupportedEncodingException。

  • InputStreamReader InputStreamReader():(InputStream in, Charset cs),构造方法。根据传入的字节输入流及标准 Charset 对象创建对象。强类型安全,推荐的生产标准构造形式。

  • InputStreamReader InputStreamReader():(InputStream in, CharsetDecoder dec),构造方法。根据传入的字节输入流及自定义 CharsetDecoder 解码器创建对象。适用于需要定制畸变或无法映射字节处理策略的场景。

注意事项:

  1. 传入的底层 InputStream 参数不能为 null,否则会直接抛出 NullPointerException。
  2. charsetName 字符串参数必须有效;若名称非法会抛出 UnsupportedEncodingException,相比强类型的 Charset 对象更易引发运行时错误。
  3. 无参字符集构造方法在 JDK 18 之前受各平台运行环境默认编码影响,跨操作系统部署易出现解析不一致。
java
// 1. 使用平台默认编码初始化(JDK 18+ 默认为 UTF-8)
try (InputStreamReader isr1 = new InputStreamReader(inStream)) {
  int data = isr1.read();
}

// 2. 通过强类型标准字符集 Charset 初始化(推荐标准)
try (InputStreamReader isr2 = new InputStreamReader(inStream, StandardCharsets.UTF_8)) {
  int data = isr2.read();
}

// 3. 通过字符串指定字符集名称初始化
try (InputStreamReader isr3 = new InputStreamReader(inStream, "GBK")) {
  int data = isr3.read();
}

// 4. 通过高级定制 CharsetDecoder 初始化
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
    .onMalformedInput(CodingErrorAction.IGNORE);
try (InputStreamReader isr4 = new InputStreamReader(inStream, decoder)) {
  int data = isr4.read();
}

字符数据读取 ​

  • int read():(),单字符读取。从输入流中读取单个字符。返回解码后的字符对应无符号整数(0 ~ 65535),若到达流的末尾则返回 -1。

  • int read():(char[] cbuf, int offset, int length),区间受限字符读取。从输入流中读取最多 length 个字符并解码存入目标字符数组 cbuf 的 offset 偏移位置。返回实际读取的字符数;若已达流末尾则返回 -1。

注意事项:

  1. read(char[] cbuf, int offset, int length) 返回的是实际解码并存储的字符个数,并非底层读取的字节数。
  2. read() 返回的是 int 类型,接收单字符时必须先与 -1 进行判断,确认非末尾后再强转为 (char),否则 -1 强转为 char 会变成 \uFFFF 导致死循环。
  3. 读取操作是同步阻塞的,当底层流数据未就绪且未到 EOF 时,当前线程将被挂起。
java
// 1. 单字符循环逐个读取模式
int ch;
while ((ch = isr.read()) != -1) {
  char character = (char) ch;
}

// 2. 批量字符数组指定偏移量读取模式
char[] buffer = new char[1024];
int readCount;
while ((readCount = isr.read(buffer, 0, buffer.length)) != -1) {
  processChars(buffer, 0, readCount);
}

状态查询与生命周期管理 ​

  • String getEncoding():(),获取字符编码名称。返回当前流正在使用的字符编码规范历史名称(如 UTF8);若流已被关闭则可能返回 null。

  • boolean ready():(),读取就绪检测。判断当前流是否已准备好进行无阻塞读取。若内部缓冲区非空或底层字节流已就绪则返回 true。

  • void close():(),关闭输入流。关闭当前字符转换读取流,并级联关闭其包装的底层 InputStream 字节流,释放底层句柄。

注意事项:

  1. close() 方法具备幂等性,重复调用不会抛出异常。流关闭后再次调用 read() 或 ready() 将抛出 IOException: Stream closed。
  2. close() 会级联触发底层 InputStream 的 close()。如果不希望包装流关闭底层字节通道(例如复用网络连接),严禁直接调用包装类的 close()。
  3. ready() 返回 false 并不能保证下一次调用 read() 一定会阻塞,仅表示当前无法保证无阻塞读取。
java
// 1. 查询当前转换流绑定的编码标识
String encoding = isr.getEncoding();

// 2. 探查流是否已就绪可无阻塞读取
boolean canRead = isr.ready();

// 3. 显式关闭流并释放关联的底层资源
isr.close();

继承:Reader ​

mark, markSupported, nullReader, read, read, reset, skip, transferTo

示例:指定编码读取文件 ​

这是 Java IO 中非常经典的一种组合拳写法:FileInputStream(读字节) ──> InputStreamReader(转字符并指定编码) ──> BufferedReader(按行缓冲读取)。

java
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

public class InputStreamReaderDemo {
  public static void main(String[] args) {
    // 假设这是一个从外部传来的、明确为 UTF-8 编码的文件
    String filePath = "utf8_file.txt";

    // 1. 打开字节流 -> 2. 用转换流包裹并强行指定 UTF-8 解码 -> 3. 套上缓冲流畅快按行读取
    try (FileInputStream fis = new FileInputStream(filePath);
       InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
       BufferedReader br = new BufferedReader(isr)) {

      String line;
      while ((line = br.readLine()) != null) {
        System.out.println(line);
      }

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

API: OutputStreamWriter ​

构造方法 ​

  • OutputStreamWriter OutputStreamWriter():(OutputStream out),构造方法。使用系统默认字符集(JDK 18+ 默认统一为 UTF-8)创建将字符转换为字节的输出流。

  • OutputStreamWriter OutputStreamWriter():(OutputStream out, String charsetName),构造方法。使用指定的字符集名称创建转换输出流;若传入不受支持的名称则抛出 UnsupportedEncodingException。

  • OutputStreamWriter OutputStreamWriter():(OutputStream out, Charset cs),构造方法。使用指定的强类型 Charset 对象创建转换输出流。类型安全,生产环境推荐使用。

  • OutputStreamWriter OutputStreamWriter():(OutputStream out, CharsetEncoder enc),构造方法。使用自定义配置的 CharsetEncoder 编码器创建转换输出流。适用于需要精准控制不可映射字符或畸变字符策略的高级场景。

注意事项:

  1. 传入的底层 OutputStream 参数不能为 null,否则构造器会直接抛出 NullPointerException。
  2. charsetName 字符串参数必须有效且被 JVM 支持;若名称非法或拼写错误将抛出 UnsupportedEncodingException(Checked Exception),推荐优先使用强类型的 Charset 重载版本。
  3. 无参字符集构造方法在 JDK 18 之前强依赖运行平台的本地默认编码,在跨系统部署时极易产生乱码,应尽量显式声明字符编码。
java
// 1. 使用平台默认编码初始化(JDK 18+ 默认为 UTF-8)
try (OutputStreamWriter writer1 = new OutputStreamWriter(outStream)) {
  writer1.write("Default");
}

// 2. 通过强类型标准字符集 Charset 初始化(推荐规范)
try (OutputStreamWriter writer2 = new OutputStreamWriter(outStream, StandardCharsets.UTF_8)) {
  writer2.write("UTF-8");
}

// 3. 通过字符集字符串名称初始化
try (OutputStreamWriter writer3 = new OutputStreamWriter(outStream, "GBK")) {
  writer3.write("GBK");
}

// 4. 通过高级定制 CharsetEncoder 初始化
CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder()
    .onUnmappableCharacter(CodingErrorAction.REPLACE);
try (OutputStreamWriter writer4 = new OutputStreamWriter(outStream, encoder)) {
  writer4.write("Custom Encoder");
}

字符数据写入 ​

  • void write():(int c),单字符写入。将单个字符写入输出流。传入的整型数值仅低 16 位被写入(强转为 char),高 16 位被自动舍弃。

  • void write():(char[] cbuf, int off, int len),字符数组切片写入。将字符数组 cbuf 中从偏移量 off 开始的连续 len 个字符编码并写入底层输出流。

  • void write():(String str, int off, int len),字符串切片写入。将字符串 str 中从偏移量 off 开始的连续 len 个字符编码并写入底层输出流。

注意事项:

  1. write(int c) 写入的是 16 位 Unicode 字符而不是单个字节。若该字符在目标编码下由多个字节组成(如中文在 UTF-8 下占 3 字节),底层会自动将其展开并生成多个字节。
  2. write(char[] cbuf, int off, int len) 与 write(String str, int off, int len) 会进行严格的数组/字符串边界校验,若 off < 0、len < 0 或 off + len 超出源数据边界,将抛出 IndexOutOfBoundsException。
  3. 所有 write 操作在执行期间均会持有内部 lock 互斥锁,多线程并发调用同一实例的写入方法具备线程安全性,但会引发线程排队等待。
java
// 1. 写入单个 Unicode 字符(支持保留低 16 位的 int)
writer.write('A');

// 2. 批量写入字符数组的子区间
char[] charArray = new char[]{'J', 'a', 'v', 'a', 'I', 'O'};
writer.write(charArray, 0, 4);

// 3. 截取写入字符串的指定子区间
writer.write("Hello World", 0, 5);

缓冲区刷新与生命周期管理 ​

  • void flush():(),刷新输出缓冲区。强制将内部编码转换缓冲区(StreamEncoder)中的所有待输出字节刷入挂载的底层 OutputStream 中。

  • void close():(),关闭输出流。首先刷新内部编码缓冲区,随后关闭底层的 OutputStream 字节流并释放关联的系统句柄。

  • String getEncoding():(),获取字符编码名称。返回当前流正在使用的字符编码的历史规范名称;若流已关闭可能返回 null。

注意事项:

  1. close() 方法具备幂等性,重复调用不会抛出异常。流关闭后再次调用 write() 或 flush() 将抛出 IOException: Stream closed。
  2. close() 会级联触发底层 OutputStream 的关闭。若底层字节流需要复用(例如常驻的网络 Socket 连接或共享的 ByteArrayOutputStream),严禁直接关闭外层的 OutputStreamWriter,应改用显式 flush()。
  3. flush() 仅能清空 StreamEncoder 的内部缓冲并提交到底层 OutputStream,并不等同于物理介质的强制落盘(fsync)。
java
// 1. 强制推送编码缓冲区至底层字节流
writer.flush();

// 2. 获取当前流的实际生效编码规范名称
String activeEncoding = writer.getEncoding();

// 3. 关闭外层转换流并级联释放底层字节流句柄
writer.close();

继承:Writer ​

append, append, append, nullWriter, write, write

示例:指定编码写入文件 ​

同样地,写文件时我们也可以强制指定编码,确保不管在什么系统上运行,生下来的文件都是指定的格式。

java
import java.io.BufferedWriter;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;

public class OutputStreamWriterDemo {
  public static void main(String[] args) {
    String filePath = "gbk_output.txt";

    // 创建一个强制以 GBK 编码向磁盘写入数据的输出流
    try (FileOutputStream fos = new FileOutputStream(filePath);
       OutputStreamWriter osw = new OutputStreamWriter(fos, "GBK"); // 可以传入字符串别名
       BufferedWriter bw = new BufferedWriter(osw)) {

      bw.write("这段文字将被强制以 GBK 编码写进磁盘。");
      bw.newLine();
      bw.flush();
      System.out.println("写入成功!");

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

现代 Java 中的转换流 ​

现代 Java 还需要转换流吗

需要,但文件读写场景变少了。

从 Java 11 开始,Java 官方终于给 FileReader 和 FileWriter 的构造函数加上了 Charset 参数(例如 new FileReader("file.txt", StandardCharsets.UTF_8))。这意味着如果只是单纯读写本地文件,你已经不需要手动去套一层 InputStreamReader 了。


转换流的“不可替代”场景:网络编程与标准输入输出

尽管文件操作被简化了,但在处理网络套接字(Network Socket)或系统标准流时,转换流依然是唯一的王牌。

例如,获取键盘输入:

java
// System.in 是一个底层的字节输入流(InputStream)
// 如果想把它升级成可以按行读取的字符流,必须通过转换流连接
BufferedReader keyboardReader = new BufferedReader(new InputStreamReader(System.in));

在网络编程中,从服务器获取到的网络流 socket.getInputStream() 也是纯字节流。想要把它当成文本高效处理,同样必须先用 InputStreamReader 翻译成字符流。

总结:转换流避坑口诀 ​

  1. 见字如面:看到 Reader 就是要把字节解码成文字,看到 Writer 就是要把文字编码成字节。

  2. 两端对齐:读取文件时指定的编码,必须与文件原本保存时的编码一模一样,否则必出乱码。

  3. 级联关闭:和缓冲流一样,关闭时只需要关闭最外层的 BufferedReader / BufferedWriter 即可,内层的转换流和底层文件流会被连带安全关闭。

处理流-对象流 ​

在 Java 的 IO 流世界中,对象流(Object Streams) 是实现对象持久化存储和跨网络传输的终极武器。

如果你想保存一个游戏的复杂地图、玩家的所有状态,或者通过网络把一个完整的内存对象发送给另一台服务器,单纯用数据流(DataInputStream)去一个个读写基本类型就会极其痛苦。对象流允许我们直接将一个完整的 Java 对象当作一个整体进行读写。

实现这一过程的底层技术,就是我们熟知的 序列化(Serialization) 与 反序列化(Deserialization)。

核心概念与工作流 ​

对象流家族主要由两个高级处理流组成:

  • ObjectOutputStream:负责序列化。将 JVM 内存中的 Java 对象,转换为与平台无关的二进制字节流。
  • ObjectInputStream:负责反序列化。将二进制字节流重新装配、还原为内存中的 Java 对象。

image-20260716114209930

序列化/反序列化~ ​

底层工作原理 ​

当我们在 Java 中序列化一个对象时,JVM 并不是简单地把它的字段值倒出来。

对象图

一个对象往往会引用其他对象,被引用的对象又会引用另外的对象。这一系列纵横交错的引用关系网,在 JVM 中被称为对象图。

  • 当你调用 oos.writeObject(obj) 时,Java 序列化引擎会自动深度遍历整张对象图。
  • 它会把 obj 依赖的所有对象全部连带序列化。只要整张图上有一个类没有实现 Serializable,整个序列化过程就会立刻崩溃并抛出 NotSerializableException。

循环引用与引用复用

如果 A 引用了 B,B 也引用了 A,或者同一个 A 对象被一个 List 添加了两次,序列化时会产生两份 A 的数据吗?

  • 答案是:不会。
  • Java 序列化引擎在内部维护了一个哈希表(称为 Wire Handle 表)。每当一个对象被写入流时,引擎都会在表里给它分配一个唯一的 “句柄(Handle)”(可以理解为对象的 ID 编号)。
  • 如果后续发现同一个对象再次被引用,引擎绝不重复序列化它,而是仅仅往二进制流里写入一个“指向该句柄的指针”。这不仅节省了空间,还完美解决了对象间的循环引用(Circular Reference)问题。

序列化条件 ​

要想让一个 Java 对象能够安全地在对象流中穿梭,它的类必须满足以下三个硬性条件:

实现 Serializable 接口 ​

这是一个标记接口(Marker Interface),内部没有任何方法。它仅仅是给 JVM 看的一张“通行证”,告诉 JVM:“我这个类已经做好了被序列化的准备。”

警告: 如果尝试使用对象流去读写一个没有实现 Serializable 接口的类,Java 会毫不留情地抛出 NotSerializableException。

显式声明 serialVersionUID ​

这是最容易被新手忽略的终极大坑。

在类中,应当显式声明一个静态常量 serialVersionUID:

java
private static final long serialVersionUID = 1L; // 或者是任意生成的 64 位 Long 值
  • 必须声明为 static final long,权限则没有要求。
  • 要保证值是唯一的。

为什么它如此重要?

当你反序列化时,JVM 会对比文件里保存的 serialVersionUID 与你当前类中的 serialVersionUID 是否一致。

  • 如果你没有显式声明:Java 编译器会根据当前类的属性、方法等自动算出一个哈希值。这就意味着,只要你对类进行了一丁点微调(比如改了个变量名,或者加了个空格),自动算出的版本号就会改变。 此时去反序列化旧数据,就会直接抛出 InvalidClassException 导致程序崩溃。
  • 如果你声明了:即使类结构后续发生了轻微改变(如新增了非关键字段),旧的数据依然可以安全地反序列化回来,极大地提高了兼容性。
善用 transient 关键字 ​

在序列化对象时,有些数据我们不希望被保存。例如:

  • 敏感信息:用户的密码、银行卡号等。
  • 无关状态/临时对象:数据库连接、线程对象、一些临时的计算缓存。

只要在字段前加上 transient 关键字(意思是“瞬时的/短暂的”),该字段就会被序列化机制自动忽略。反序列化回来时,这些字段会被初始化为其对应类型的默认值(对象为 null,数值为 0,布尔值为 false)。

示例:实现对象的保存与恢复 ​

下面是一个展示如何将一个包含敏感字段和版本控制的 User 对象进行序列化与反序列化。

java
import java.io.*;

// 1. 准备实体类,必须实现 Serializable
class User implements Serializable {
  // 强制声明版本号,防止类演进时崩溃
  private static final long serialVersionUID = 554321987654321L;

  private String username;

  // 敏感信息,使用 transient 标记,不参与序列化
  private transient String password;

  private int age;

  public User(String username, String password, int age) {
    this.username = username;
    this.password = password;
    this.age = age;
  }

  @Override
  public String toString() {
    return "User{username='" + username + "', password='" + password + "'(transient), age=" + age + "}";
  }
}

public class ObjectStreamDemo {
  public static void main(String[] args) {
    String filepath = "user_backup.obj";
    User user = new User("JohnDoe", "MySuperPassword123", 28);

    // 2. 序列化过程:内存对象 -> 磁盘文件
    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filepath))) {

      oos.writeObject(user); // 这一步直接将整个对象及其关联引用的整张图全部序列化
      System.out.println("【成功】对象已序列化写入文件:" + user);

    } catch (IOException e) {
      e.printStackTrace();
    }

    // 3. 反序列化过程:磁盘文件 -> 内存对象
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filepath))) {

      // readObject 返回的是 Object 类型,需要进行强转
      User restoredUser = (User) ois.readObject();

      System.out.println("\n【成功】对象反序列化读取完成:");
      System.out.println(restoredUser);
      // 注意:因为 password 标了 transient,读回来会变成 null!

    } catch (IOException | ClassNotFoundException e) {
      e.printStackTrace();
    }
  }
}

自定义序列化 ​

很多开发者以为序列化是一个“全自动且无法插手”的过程。实际上,JVM 留出了四个特定的私有方法接口。只要我们在类中声明了这些方法,序列化引擎就会通过反射优先调用它们,让我们能够完全掌控对象的“生老病死”。

writeObject / readObject ​

如果你想在序列化前后对某些字段进行加密/解密,或者执行一些特殊的格式转换,你可以重写这两个私有方法:

java
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
  // 1. 默认序列化:自动处理那些没有被 transient 修饰的常规字段
  out.defaultWriteObject();
  // 2. 自定义写入:比如给密码字段进行异或加密后手动写入
  String encryptedPassword = encrypt(this.password);
  out.writeUTF(encryptedPassword);
}

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
  // 1. 默认反序列化
  in.defaultReadObject();
  // 2. 手动读取并解密
  String encryptedPassword = in.readUTF();
  this.password = decrypt(encryptedPassword);
}
writeReplace / readResolve ​

这两个方法更加玄学。它们甚至允许你在序列化和反序列化时,用另一个完全不同的对象替换掉当前对象!

  • writeReplace():在序列化之前被调用。你可以决定把当前对象打包替换成另一个中介对象写入流。

  • readResolve():在反序列化重组对象之后、最终返回给用户之前被调用。它是防止单例模式被反序列化破坏的唯一护城河。

    java
    // 在单例类中加入此方法
    private Object readResolve() throws ObjectStreamException {
      // 丢弃刚刚反序列化出来的新对象,强制返回我们在内存中唯一的单例实例
      return INSTANCE;
    }

Externalizable 接口 ​

除了继承 Serializable,Java 还提供了另一个更硬核的接口:java.io.Externalizable。

很多初学者容易混淆它们,我们通过一个表格进行精准对比:

维度Serializable (系统掌控)Externalizable (完全自治)
控制权自动为主。JVM 负责深度遍历和写入类元数据。完全手动。必须由程序员在代码中一行行写明怎么读写。
底层接口方法无(标记接口)。可通过私有方法定制。必须实现 writeExternal 和 readExternal。
构造方法要求不需要无参构造方法。强制要求类必须提供一个 public 的无参构造方法。
性能较慢。底层需要通过大量反射获取字段和类信息。极快。直接调用方法,没有任何反射开销。
文件大小较大。包含大量类元数据和版本标记。极小。只保存程序员写入的纯数据。

示例:Externalizable 接口

java
import java.io.*;

public class FastUser implements Externalizable {
  private String name;
  private int age;

  // 必须提供 public 无参构造方法,反序列化时 JVM 会首选调用它创建空对象
  public FastUser() {}

  public FastUser(String name, int age) {
    this.name = name;
    this.age = age;
  }

  @Override
  public void writeExternal(ObjectOutput out) throws IOException {
    // 完全手动控制写什么、怎么写
    out.writeUTF(name);
    out.writeInt(age);
  }

  @Override
  public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
    // 按照完全相同的顺序手动读回来赋值
    this.name = in.readUTF();
    this.age = in.readInt();
  }
}

继承关系中的序列化 ​

当类与类之间存在继承关系时,序列化的行为会变得有些微妙:

  1. 父类可序列化,子类自动可序列化:

    如果父类实现了 Serializable,那么子类无需任何声明,自动获得序列化通行证。

  2. 父类未序列化,子类强行序列化:

    如果父类没有实现 Serializable,而子类实现了。

    • 序列化时:子类可以正常序列化,但父类拥有的字段不会被写入流中。
    • 反序列化时:为了重建父类的状态,JVM 会强制调用父类的无参构造方法来初始化父类字段。如果父类没有无参构造方法,运行时直接抛出 InvalidClassException 崩溃。

反序列化的注意事项 ​

作为一名优秀的研发人员,你需要知道 Java 原生反序列化机制的三个冷知识和安全痛点:

  1. 反序列化不走构造方法:

    当 ObjectInputStream 重构一个对象时,它完全不会调用该类的任何构造方法(Constructor)。JVM 是直接通过底层的反射机制,按照字节流里的数据,强行抹平并填满对象的成员变量。这意味着你在构造方法里写的任何安全校验、初始化逻辑,在反序列化时通通失效。

  2. 反序列化安全漏洞:

    Java 原生的反序列化机制在安全界名声极其狼狈。

    • 危险原理:在调用 readObject() 时,如果传入的二进制字节流是被恶意篡改过的,攻击者可以构造一种特殊的“小工具链(Gadget Chains)”,在 JVM 还没有完全重建完对象之前,就强行触发某些恶意代码的执行。
    • 业界共识:绝对不要反序列化任何来自不可信来源(如公网用户上传、未加密的网络包)的二进制对象数据。
  3. 原生序列化的性能缺陷:

    Java 原生的序列化机制虽然简单好用,但它有着难以调和的劣势:

    • 体积臃肿:为了保证跨平台和类安全,保存的文件里包含了大量的 Java 类元数据,冗余度高。
    • 速度慢:底层重度依赖反射,CPU 消耗大,不适合高并发的网络微服务。

现代替代方案:

在现在的企业级架构(如 Spring Cloud、Dubbo、网络传输)中,我们几乎全面淘汰了 Java 原生的对象流。取而代之的是性能更好、体积更小、更安全的序列化框架:

  • 文本流向:JSON(Jackson / Gson / Fastjson2)
  • 高性能二进制:Protobuf(谷歌)、Kryo、Hessian 等。

API: ObjectOutputStream ​

构造方法 ​

  • ObjectOutputStream ObjectOutputStream():(OutputStream out),构造方法。使用指定的底层字节输出流创建一个 ObjectOutputStream。常用于将序列化元数据绑定到文件、内存数组或网络 Socket 通道。

注意事项:

  1. 实例化 ObjectOutputStream(OutputStream out) 时,构造方法内部会立即向底层流写出 4 字节的 Stream Header(Magic 和 Version)。因此,在网络通信(Socket)场景中,必须先实例化对端的 ObjectInputStream 或是确保输出流及时 flush(),否则如果双端都同步阻塞在未接收到 Header 的状态,将导致分布式死锁。
java
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.IOException;

public class ConstructorGroupDemo {
  public static void main(String[] args) {
    // 构建物理文件节点流作为底层承载
    try (FileOutputStream fos = new FileOutputStream("stream_init.dat");
       ObjectOutputStream oos = new ObjectOutputStream(fos)) {

      System.out.println("ObjectOutputStream 构造完毕,已隐式刷出流协议头数据。");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

对象与基本类型写入 ​

  • void writeObject():(Object obj),对象图序列化写入。将指定的对象完整状态写入流中。包括该对象的类元数据、非静态与非 transient 属性,以及级联引用的其他对象图。

  • void writeInt():(int val),基本类型 int 写入。向流中写入一个 4 字节的 32 位 int 数值。

  • void writeBoolean():(boolean val),基本类型 boolean 写入。向流中写入一个 1 字节的 boolean 布尔状态。

  • void writeUTF():(String str),修改版 UTF-8 字符串写入。以符合 Java DataOutput 规范的修改版 UTF-8 编码格式写出字符串,前 2 字节用于存储字符串的物理字节长度。

  • void flush():(),刷新流缓冲区。强行刷新当前输出流,将内部可能滞留的所有缓冲字节强制推入到底层关联的物理流中。

  • void close():(),释放并关闭流资源。关闭此对象输出流,并自动隐式级联关闭下层绑定的物理流句柄。

注意事项:

  1. 传入 writeObject() 的对象如果未实现 Serializable 接口,运行时将无条件抛出 NotSerializableException 异常。
  2. 被 transient 关键字修饰的成员变量属于敏感或临时变量,其状态在序列化时会被彻底忽略(数值型变量反序列化后恢复默认值 0,对象型恢复为 null)。
  3. 同一引用多次写入机制:ObjectOutputStream 内部维护着一个引用哈希表。如果将同一个对象实例连续写出多次,它在第二次及以后写出时只会写出该对象的句柄编号(Handle)而非真实属性图。因此,如果在写出对象后修改了其属性再次调用 writeObject(),修改后的属性不会被重新写出。
java
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.IOException;
import java.io.Serializable;

public class WriteGroupDemo {
  static class NodeEntity implements Serializable {
    private static final long serialVersionUID = 123456L;
    private String nodeName;
    // transient 修饰的属性不参与序列化
    private transient String sessionToken;

    public NodeEntity(String nodeName, String sessionToken) {
      this.nodeName = nodeName;
      this.sessionToken = sessionToken;
    }
  }

  public static void main(String[] args) {
    NodeEntity entity = new NodeEntity("Cluster_Node_01", "SK_99210_TRCE");

    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("node_snapshot.dat"))) {
      // 1. 串行写入基本元数据
      oos.writeUTF("Snapshot_v1.0");
      oos.writeInt(2026);

      // 2. 物理写入复杂对象图
      oos.writeObject(entity);

      // 3. 强制刷出到硬件通道
      oos.flush();
      System.out.println("复合业务指标块写入成功。");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

高级序列化定制与自定义 ​

  • void defaultWriteObject():(),执行默认序列化行为。将当前类的非静态和非 transient 字段写入流。该方法只能在类内部重写的自定义 writeObject(ObjectOutputStream out) 方法中被显式调用。

  • ObjectOutputStream.PutField putFields():(),获取自定制字段缓冲对象。获取一个用于专门缓冲要写入流中的持久字段的对象,允许开发人员完全脱离原有属性结构的物理限制。

  • void writeFields():(),物理写出自定制字段。将通过 putFields() 方法积攒在 PutField 缓冲器中的字段值物理写出到对象输出流中。

  • void reset():(),重置流缓存表。清空流内部维护的所有写出对象的引用哈希缓存表。重置后再次写出的对象将被视作全新的实体重新全量输出。

注意事项:

  1. 如果在大批次、长连接的流或密集循环中频繁通过同一包装流写出数据(如网络长连接),由于内部哈希表对所有写出的对象强引用,会导致这些对象无法被 JVM GC 垃圾回收,进而诱发灾难性的 OutOfMemoryError 内存溢出。在此种场景下,必须在合适的业务周期内定期调用 reset() 方法释放流缓存。
java
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.IOException;
import java.io.Serializable;

public class AdvancedControlGroupDemo {
  static class CustomizedProfile implements Serializable {
    private static final long serialVersionUID = 2L;
    private String rawTitle;
    private int rank;

    public CustomizedProfile(String rawTitle, int rank) {
      this.rawTitle = rawTitle;
      this.rank = rank;
    }

    // 重写私有方法接管默认序列化流程
    private void writeObject(ObjectOutputStream out) throws IOException {
      // 1. 获取自定义字段操作器
      ObjectOutputStream.PutField fields = out.putFields();
      // 2. 强行干预写入逻辑(可以对敏感属性加密或重组结构)
      fields.put("rawTitle", "SECURE_" + rawTitle);
      fields.put("rank", rank * 10);
      // 3. 将拦截拼装后的字段写出
      out.writeFields();
    }
  }

  public static void main(String[] args) {
    CustomizedProfile profile = new CustomizedProfile("RootCore", 5);

    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("advanced.dat"))) {
      // 写入自定义代理对象
      oos.writeObject(profile);

      // 核心避坑动作:重置流状态,切断强引用缓存计数,防御长生命周期流引发的内存泄露
      oos.reset();
      System.out.println("高级重写序列化任务及流复位完毕。");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

API: ObjectInputStream ​

构造方法 ​

  • ObjectInputStream ObjectInputStream():(InputStream in),构造方法。创建一个向指定底层字节输入流读取数据的 ObjectInputStream。用于从物理文件、内存数组或 Socket 接收的数据中恢复 Java 对象。

注意事项:

  1. ObjectInputStream 的构造方法在执行时会阻塞并立即读取底层流中的前 4 字节协议头。如果配对的 ObjectOutputStream 尚未创建或尚未刷写(flush())物理数据,此构造方法将无条件阻塞,并在网络 Socket 通信中极易诱发分布式死锁。
java
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.io.IOException;

public class OisConstructorDemo {
  public static void main(String[] args) {
    // 绑定底层的物理文件字节流进行反序列化流初始化
    try (FileInputStream fis = new FileInputStream("object_data.bin");
       ObjectInputStream ois = new ObjectInputStream(fis)) {

      System.out.println("ObjectInputStream 初始化成功,已完成流协议头校验。");
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

对象与基本类型读取 ​

  • Object readObject():(),读取对象图。从流中读取并重建一个完整的 Java 对象实体。

  • int readInt():(),读取基本类型 int。从流中连续读取 4 个字节,恢复为一个 32 位的 int 数值。

  • boolean readBoolean():(),读取基本类型 boolean。从流中读取 1 个字节,恢复为一个 boolean 值。

  • String readUTF():(),读取修改版 UTF-8 字符串。读取符合 Java DataInput 规范的修改版 UTF-8 编码字符串。

  • void close():(),关闭输入流。释放此对象流关联的所有系统资源,并隐式级联关闭下层绑定的物理节点流。

注意事项:

  1. readObject() 返回的类型是统一的 Object,上层业务必须进行显式的向下类型转换(Downcasting)。若本地类路径中缺少该类的类定义文件,会抛出检查型异常 ClassNotFoundException。
  2. 反序列化时,成员变量恢复的顺序和写入时的调用顺序必须严格对齐。如果写入时先写 int 再写 String,读取时若先调用 readUTF() 再调用 readInt(),将引发二进制结构错乱,导致抛出 OptionalDataException 或 StreamCorruptedException。
java
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.io.IOException;

public class OisReadDemo {
  public static void main(String[] args) {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("data_record.bin"))) {

      // 1. 严格按照写入顺序反序列化基础数据类型
      String version = ois.readUTF();
      int id = ois.readInt();

      // 2. 反序列化复杂对象并进行向下类型转换
      Object restoredObject = ois.readObject();

      System.out.println("成功恢复元数据 - 版本: " + version + ", 编号: " + id);
      System.out.println("恢复的对象运行时类名称: " + restoredObject.getClass().getName());

    } catch (IOException | ClassNotFoundException e) {
      System.err.println("反序列化读取事务失败: " + e.getMessage());
    }
  }
}

反序列化控制与深度定制 ​

  • void defaultReadObject():(),执行默认反序列化。从流中读取当前类的非静态和非 transient 字段。该方法只能在可序列化类内部重写的私有 readObject(ObjectInputStream in) 方法中被级联调用。

  • ObjectInputStream.GetField readFields():(),读取持久化字段组。从流中提取所有命名的持久字段,并将其封装进一个 GetField 对象中。用于脱离类物理属性结构的限制,实现高阶的手动定制化解码恢复。

  • Class<?> resolveClass():(ObjectStreamClass desc),解析类元数据对象。子类通过重写此方法,可以自定义本地类文件的寻找和加载逻辑。常用于跨类加载器或网络动态加载类的场景。

注意事项:

  1. 反序列化绕过构造方法风险:反序列化在恢复实现了 Serializable 接口的类实例时,不会执行该类的任何实例构造方法(包括无参构造方法)和实例初始化块。它仅通过底层反射直接还原内存状态。但如果该类的父类没有实现 Serializable 接口,则反序列化时无条件会触发该非序列化父类的无参构造方法。
  2. 被 transient 关键字修饰的属性在反序列化后,其值会自动初始化为对应类型的默认初始值(数字型变量恢复为 0/0.0,布尔型恢复为 false,引用对象类型统一恢复为 null)。
java
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.io.IOException;
import java.io.Serializable;

public class OisAdvancedDemo {
  static class SecurityProfile implements Serializable {
    private static final long serialVersionUID = 42L;
    private String username;
    private transient String decryptedToken; // transient 属性

    // 重写私有反序列化方法接管默认行为
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
      // 1. 获取高度定制的持久字段提取器
      ObjectInputStream.GetField fields = in.readFields();

      // 2. 手动提取字段
      this.username = (String) fields.get("username", "ANONYMOUS");

      // 3. transient 属性在流中不存在,必须由业务逻辑显式恢复初始状态
      this.decryptedToken = "INIT_DEFAULT_TOKEN";
    }
  }

  public static void main(String[] args) {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("security.bin"))) {
      SecurityProfile profile = (SecurityProfile) ois.readObject();
      System.out.println("用户特征信息还原成功。");
    } catch (IOException | ClassNotFoundException e) {
      e.printStackTrace();
    }
  }
}

常见高频面试避坑题 ​

Q:如果一个类继承了非序列化的父类,子类序列化会发生什么

答: 子类依然可以成功序列化。但是,在反序列化重组对象时,由于父类没有实现 Serializable,JVM 会强制调用父类的无参构造方法来初始化父类所拥有的成员变量。因此,此时父类的那些字段会丢失序列化前的状态,被重置为默认值。如果父类连无参构造方法都没有,直接报错。


Q:如何防止序列化破坏单例模式

答: 默认情况下,反序列化每次都会在堆内存中强行创建一个全新的对象实例,这会直接摧毁单例。

  • 解决方案:在你的单例类中,加入一个特定的、私有的 readResolve() 方法:

    java
    private Object readResolve() {
      return INSTANCE; // 直接返回已有的单例实例,抛弃反序列化新生成的那个
    }

JVM 会在反序列化完成前自动调用该方法,并用它的返回值替换掉刚刚重构出的新对象。

其他流【 ​

数据流【 ​

标准输入/输出流【 ​

打印流【 ​