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

常用功能流

为了提高性能或实现特定的高级功能,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(用于写文本)。它们分别继承自 ReaderWriter 这两个抽象基类。

工作机制

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 用于从文件中读取字符数据。它在读取文件时,会自动查找系统默认的字符集(或你指定的字符集),将磁盘上的字节解码为内存中的 char

构造方法
  • FileReader FileReader()(String fileName)路径初始化。根据指定的文件路径名称创建一个新的 FileReader,使用 JVM 的默认字符集。

  • FileReader FileReader()(File file)文件对象初始化。根据指定的 File 对象创建一个新的 FileReader,使用 JVM 的默认字符集。

  • FileReader FileReader()(String fileName, Charset charset)指定字符集路径初始化。根据指定的文件路径和字符集创建一个新的 FileReader。(自 JDK 11 引入)

  • FileReader FileReader()(File file, Charset charset)指定字符集文件对象初始化。根据指定的 File 对象和字符集创建一个新的 FileReader。(自 JDK 11 引入)

注意事项

  1. 如果指定路径的文件不存在、是一个目录或者由于其他权限原因无法打开,构造方法将抛出 FileNotFoundException
  2. 在 JDK 11 之前,FileReader 无法显式指定字符集,只能使用 JVM 默认编码(例如 Windows 上通常是 GBK,Linux 上通常是 UTF-8)。这极易导致跨平台部署时出现中文乱码问题。若在低版本 JDK 中需要指定字符集,必须使用 new InputStreamReader(new FileInputStream(file), "UTF-8") 代替。自 JDK 11 开始,推荐直接在构造方法中传入 Charset
java
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FileReaderConstructorDemo {
  public static void main(String[] args) {
    File targetFile = new File("config.txt");

    try {
      // 确保测试文件存在
      if (!targetFile.exists()) {
        targetFile.createNewFile();
      }

      // 1. 使用系统默认字符集创建(不推荐跨平台文本读取)
      FileReader readerByDefault = new FileReader("config.txt");

      // 2. 显式指定字符集创建,保证跨平台兼容性(JDK 11 及以上支持)
      FileReader readerWithCharset = new FileReader(targetFile, StandardCharsets.UTF_8);

      // 释放资源
      readerByDefault.close();
      readerWithCharset.close();

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字符读取与关闭
  • int read()()读取单字符。读取单个字符。返回读取到的字符对应的 int 码值(0 到 65535 之间);如果已到达流的末尾,则返回 -1

  • int read()(char[] cbuf)批量读取字符。将字符读入指定的字符数组 cbuf 中。返回实际读取的字符个数;如果已到达流的末尾,则返回 -1

  • int read()(char[] cbuf, int off, int len)片段读取字符。将字符读入字符数组的指定部分,从偏移量 off 开始,最多读取 len 个字符。

  • void close()()关闭字符流。关闭该流并释放与之关联的所有操作系统资源。

注意事项

  1. 单字符读取方法 read() 每次执行都会引发一次底层操作系统的文件间接访问,在处理大文件时效率极低。在实际开发中,必须采用 read(char[] cbuf) 缓冲区批量读取方式,或将其包装在 BufferedReader 中以提升性能。
  2. 必须在使用完毕后显式调用 close()。更优雅、安全的实践是利用 try-with-resources 块,它能在编译期自动生成 finally 块并安全释放资源,即使在读取过程中发生异常也能防止文件句柄泄露
java
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FileReaderReadDemo {
  public static void main(String[] args) {
    // 1. 使用 try-with-resources 自动管理流资源
    try (FileReader fr = new FileReader("config.txt", StandardCharsets.UTF_8)) {
      // 2. 定义 1KB 的字符缓冲区(可容纳 1024 个 char)
      char[] charBuffer = new char[1024];
      int actualReadLength;

      // 3. 循环批量读取数据到缓冲区,直到返回 -1
      while ((actualReadLength = fr.read(charBuffer)) != -1) {
        // 4. 将读取到的字符数组片段转换为字符串并打印
        System.out.print(new String(charBuffer, 0, actualReadLength));
      }
    } catch (IOException e) {
      System.err.println("文件读取过程中发生异常: " + e.getMessage());
    }
  }
}
示例:高效读取文本
java
import java.io.FileReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FileReaderDemo {
  public static void main(String[] args) {
    // 显式指定 UTF-8 编码,防止在 Windows(默认 GBK)上跑时乱码
    try (FileReader reader = new FileReader("test.txt", StandardCharsets.UTF_8)) {
      char[] buffer = new char[1024]; // 1KB 的字符缓冲区
      int len;

      // 循环读取,每次读满 buffer
      while ((len = reader.read(buffer)) != -1) {
        // 将读取到的字符数组片段转换为字符串打印
        System.out.print(new String(buffer, 0, len));
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

API:FileWriter

FileWriter 用于向文件中写入字符数据。它在写入时,会将内存中的字符编码为字节再存入磁盘。

构造方法

FileWriter 的构造方法非常丰富,主要控制两个核心行为:是否追加内容 以及 使用何种字符编码(Java 11+)。

  • FileWriter FileWriter()(String fileName)路径覆盖模式初始化。根据指定的文件路径名称创建一个新的 FileWriter。若文件已存在,其原有内容将被清空。默认使用系统字符集。

  • FileWriter FileWriter()(String fileName, boolean append)路径追加模式初始化。若 append 参数为 true,则新写入的数据会被连接到文件末尾,而不会清空原文件。

  • FileWriter FileWriter()(File file, Charset charset)指定字符集文件对象初始化。根据指定的 File 对象和字符集编码创建覆盖模式的 FileWriter。(自 JDK 11 引入)

  • FileWriter FileWriter()(File file, Charset charset, boolean append)指定字符集追加模式初始化。根据指定的 File 对象和字符集编码创建 FileWriter,并指定是否为追加模式。(自 JDK 11 引入)

注意事项

  1. 如果指定路径的文件是一个目录由于没有权限无法创建,或者父级目录不存在,构造方法将抛出 IOException(具体通常是 FileNotFoundException)。
  2. 在 JDK 11 之前,FileWriter 无法在构造方法中显示指定字符集,它无条件使用当前 JVM 的默认编码。如果程序在 Windows 平台(默认通常是 GBK)写入,再到 Linux 平台(默认 UTF-8)读取,极易产生乱码。因此在旧版本 JDK 中,若需指定字符集,必须放弃使用 FileWriter,改用 new OutputStreamWriter(new FileOutputStream(file), "UTF-8")
java
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FwConstructorDemo {
  public static void main(String[] args) {
    File reportFile = new File("report.txt");

    try {
      // 1. 经典覆盖模式:每次运行都会清空文件重新写入
      FileWriter fwOverride = new FileWriter("report.txt");

      // 2. 现代推荐模式:显式指定 UTF-8 字符集并开启追加模式(JDK 11 及以上支持)
      FileWriter fwAppendWithCharset = new FileWriter(reportFile, StandardCharsets.UTF_8, true);

      // 显式释放资源
      fwOverride.close();
      fwAppendWithCharset.close();

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字符写入与刷新
  • void write()(int c)写入单字符。要写入的字符包含在给定整数值的低 16 位中,高 16 位被忽略。

  • void write()(char[] cbuf)批量写入字符数组:将字符数组 cbuf 中的所有字符写入此输出流。

  • void write()(char[] cbuf, int off, int len)片段写入字符数组:从字符数组 cbuf 的偏移量 off 开始,连续写入长度为 len 的字符。

  • void write()(String str)常用,写入字符串。直接将一个完整的字符串写入此流中。

  • void flush()()刷新缓冲区:强行将当前留存在字符转换缓冲区中的所有数据刷出到下层的字节流中,并触发物理磁盘写入。

  • void close()()关闭字符流。在关闭之前,系统会自动先调用一次 flush(),随后释放与之关联的操作系统资源(如文件句柄)。

注意事项

  1. 必须显式刷新或关闭流。因为 FileWriter 具有内部编码缓冲区,如果写入数据后既没有调用 flush(),也没有调用 close(),留在缓冲区中的数据将永远不会落盘,导致生成的文件内容为空或残缺。
  2. 连续频繁地手动调用 flush() 会增加磁盘 I/O 次数,大幅降低写入性能。常规做法是利用 try-with-resources 块进行包装,让其在结束时自动调用 close() 刷出数据。
java
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FwWriteDemo {
  public static void main(String[] args) {
    char[] contextArray = {'J', 'a', 'v', 'a', ' ', 'I', 'O'};

    // 使用 try-with-resources 语法,确保流无论如何都会被关闭并刷新
    try (FileWriter fw = new FileWriter("output.txt", StandardCharsets.UTF_8)) {

      // 1. 写入单字符(字符 'A' 的 ASCII 值为 65)
      fw.write(65);
      fw.write('\n'); // 换行

      // 2. 写入整个字符数组
      fw.write(contextArray);
      fw.write('\n');

      // 3. 写入字符数组的一部分 ("Java")
      fw.write(contextArray, 0, 4);
      fw.write('\n');

      // 4. 直接写入高层字符串(最常用)
      fw.write("高级技术笔记:FileWriter 核心指南");

      // 5. 手动刷新缓冲区,此时磁盘文件已可见上述内容
      fw.flush();

    } catch (IOException e) {
      System.err.println("持久化数据时发生异常: " + e.getMessage());
    }
  }
}
示例:安全写入文本
java
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class FileWriterDemo {
  public static void main(String[] args) {
    // 创建一个追加模式、UTF-8 编码的 FileWriter
    try (FileWriter writer = new FileWriter("output.txt", StandardCharsets.UTF_8, true)) {

      writer.write("第一行:Hello Java 字符流!\n");
      writer.write("第二行:直接写字符串真的很方便。");

      // 注意:使用 try-with-resources 时,close() 会自动触发 flush()
      // 如果是在长连接或大量循环写入中,建议手动 flush() 一下防止积压
      writer.flush();
      System.out.println("数据写入成功!");

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

缓冲区机制

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

原因就在于缓冲区:

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

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

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

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

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

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

结合 BufferedReader / BufferedWriter

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

  • 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(字节输出流)。它们分别继承自整个字节流体系的最高抽象基类:InputStreamOutputStream

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

API:FileInputStream

FileInputStream 用于把磁盘文件中的数据,以字节为单位读取到程序(内存)中

构造方法
  • FileInputStream FileInputStream()(String name)基于路径初始化。根据指定的文件路径名称打开一个连接,创建文件字节输入流。

  • FileInputStream FileInputStream()(File file)基于文件对象初始化。根据指定的 File 对象打开一个连接,创建文件字节输入流。

注意事项

  1. 如果指定路径的文件不存在、对应的实体是一个目录而非标准物理文件,或者由于系统权限不足无法读取,构造方法将无条件抛出 FileNotFoundException
  2. 每创建一个 FileInputStream 实例,操作系统都会分配一个文件描述符。如果频繁创建而不及时关闭,将导致系统的物理文件句柄耗尽(File descriptor leak),引发安全隐患。
java
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class FisConstructorDemo {
  public static void main(String[] args) {
    File targetFile = new File("data.bin");

    try {
      // 确保测试文件存在
      if (!targetFile.exists()) {
        targetFile.createNewFile();
      }

      // 1. 通过字符串文件路径创建输入流
      FileInputStream fisByPath = new FileInputStream("data.bin");

      // 2. 通过 File 对象创建输入流
      FileInputStream fisByFile = new FileInputStream(targetFile);

      // 显式释放资源
      fisByPath.close();
      fisByFile.close();

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字节读取与关闭
  • int read()()读取单字节。从输入流中读取下一个字节的数据。返回的 int 范围在 0255 之间;如果因到达文件末尾(EOF)而没有可读字节,则返回 -1

  • int read()(byte[] b)批量读取字节。从输入流中读取一定数量的字节,并将其存储在缓冲区数组 b 中。返回实际读取的字节总数;如果已到达文件末尾,则返回 -1

  • int read()(byte[] b, int off, int len)片段读取字节。从输入流中最多读取 len 个字节的数据,并从缓冲区数组 boff 偏移量位置开始存入。

  • void close()()关闭字节流。关闭此文件输入流并释放与此流关联的所有系统资源。

注意事项

  1. 绝对不要在生产环境中使用无参的 read() 方法循环读取大文件。该方法每次读取一个字节都会触发一次用户态到内核态的上下文切换,I/O 性能极低。实际开发中必须传入 byte[] 缓冲区进行块级读取。
  2. 当使用 read(byte[] b) 时,由于最后一次读取可能无法填满缓冲区,切记不能直接操作整个数组,而应结合其返回值(实际读取的字节数)对数组的 0返回值 范围进行处理。
java
import java.io.FileInputStream;
import java.io.IOException;

public class FisReadDemo {
  public static void main(String[] args) {
    // 使用 try-with-resources 确保流被无条件关闭
    try (FileInputStream fis = new FileInputStream("data.bin")) {

      // 1. 声明一个 1KB 的块级缓冲区
      byte[] byteBuffer = new byte[1024];
      int actualBytesRead;

      // 2. 循环分块读取,直至返回 -1 标识文件终点
      while ((actualBytesRead = fis.read(byteBuffer)) != -1) {
        // 业务处理逻辑:此时应仅处理从 0 到 actualBytesRead 长度的有效数据
        System.out.println("成功调取数据块,本次吞吐量: " + actualBytesRead + " 字节");
      }

    } catch (IOException e) {
      System.err.println("数据流读取故障: " + e.getMessage());
    }
  }
}
流控制与通道获取
  • int available()()获取估计可读字节数。返回下一次对此输入流调用的方法可以不受阻塞地从此输入流读取(或跳过)的剩余字节数的估计值。

  • long skip()(long n)跳过指定字节。从输入流中跳过并丢弃 n 个字节的数据。返回实际跳过的字节数。

  • FileChannel getChannel()()获取通道。返回与此文件输入流关联的唯一的 FileChannel 对象(用于 NIO 操作)。

注意事项

  1. 不要尝试使用 byte[] buf = new byte[fis.available()] 一次性为大文件分配内存空间。因为 available() 返回的是估计值,且对于大文件而言,一次性在堆内存分配过大的数组极易诱发 OutOfMemoryError 内存溢出。
  2. skip(long n) 方法的返回值可能小于请求跳过的字节数 n(例如在即将到达文件末尾时)。因此在业务逻辑中,必须对其返回值进行校验,而不能盲目信任传入的参数值。
java
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;

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

      // 1. 获取当前流中立即可读的估计字节大小
      int remainingBytes = fis.available();
      System.out.println("剩余可读字节估计值: " + remainingBytes);

      // 2. 跳过前 10 个字节的数据
      long skippedBytes = fis.skip(10);
      System.out.println("实际跳过字节数: " + skippedBytes);

      // 3. 获取底层 NIO 文件通道,用于后续的零拷贝或高级通道锁操作
      FileChannel nioChannel = fis.getChannel();
      System.out.println("NIO 通道是否开启: " + nioChannel.isOpen());

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
示例:读取一个文件的内容
java
import java.io.FileInputStream;
import java.io.IOException;

public class FileInputStreamDemo {
  public static void main(String[] args) {
    // 使用 try-with-resources 确保流用完后自动关闭
    try (FileInputStream fis = new FileInputStream("test.jpg")) {

      // 创建一个 1KB 的字节数组缓冲区
      byte[] buffer = new byte[1024];
      int len; // 记录每次实际读取到的字节数

      // 循环读取,直到 read 返回 -1(文件末尾)
      while ((len = fis.read(buffer)) != -1) {
        // 在这里处理读取到的字节数据,例如发往网络、或者写入另一个文件
        System.out.println("成功读取了 " + len + " 字节的数据");
      }

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

API:FileOutputStream

FileOutputStream 用于把程序(内存)中的字节数据,写入到磁盘文件中。如果目标文件不存在,Java 会自动帮你创建该文件;如果父目录不存在,则会报错。

构造方法
  • FileOutputStream FileOutputStream()(String name)基于路径覆盖模式初始化。创建向指定名称的文件中写入数据的文件输出流。若物理文件已存在,其原有内容将被完全清空并覆盖。

  • FileOutputStream FileOutputStream()(String name, boolean append)基于路径追加模式初始化。若 append 参数为 true,则新写入的字节将被连接到文件的末尾,实现内容的追加。

  • FileOutputStream FileOutputStream()(File file)基于文件对象覆盖模式初始化。根据指定的 File 对象创建文件输出流,并清空原文件内容。

  • FileOutputStream FileOutputStream()(File file, boolean append)基于文件对象追加模式初始化。根据指定的 File 对象创建文件输出流,并根据 append 参数决定是否在文件末尾追加。

注意事项

  1. 如果指定路径的文件不存在,FileOutputStream 会尝试自动创建该文件;但如果文件所在的父级目录不存在,则会无条件抛出 FileNotFoundException
  2. 如果目标路径对应的实体是一个已存在的目录,或者由于操作系统权限限制无法写入,构造方法同样会引发 FileNotFoundException
java
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

public class FosConstructorDemo {
  public static void main(String[] args) {
    File targetFile = new File("secure_vault.dat");

    try {
      // 1. 基于字符串路径的覆盖写入模式
      FileOutputStream fos1 = new FileOutputStream("secure_vault.dat");

      // 2. 基于 File 对象且开启追加写入模式(不破坏原文件)
      FileOutputStream fos2 = new FileOutputStream(targetFile, true);

      // 显式闭合资源
      fos1.close();
      fos2.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
字节写入与刷新
  • void write()(int b)写入单字节。将指定的字节(传入 intb 的低 8 位)写入此文件输出流。高 24 位将被自动忽略。

  • void write()(byte[] b)批量写入字节数组。将指定字节数组中的整个 b.length 个字节一次性写入此文件输出流。

  • void write()(byte[] b, int off, int len)片段写入字节数组。从指定字节数组的 off 偏移量开始,连续写入长度为 len 的个字节。

  • void flush()()刷新输出流。刷新此输出流并强制将任何缓冲的输出字节被写出。虽然 FileOutputStream 本身没有实现复杂的内部用户态缓冲,但调用此方法能确保数据被递交给操作系统的内核页缓存。

  • void close()()关闭输出流。关闭此文件输出流并释放与此流有关的所有底层系统资源。

注意事项

  1. 严禁在密集循环中调用 write(int b) 来写入大量数据。该操作每次执行都会引发一次昂贵的物理系统调用,导致磁盘 I/O 效率严重退化。应优先选用 write(byte[] b, int off, int len) 进行块级合并写入。
  2. 必须将流放置在 try-with-resources 块中,否则若写入期间发生异常导致 close() 未能执行,操作系统的文件句柄将发生物理泄露,进而可能引发系统出现 "Too many open files" 错误。
java
import java.io.FileOutputStream;
import java.io.IOException;

public class FosWriteDemo {
  public static void main(String[] args) {
    byte[] metaHeader = {0x7F, 'E', 'L', 'F'}; // 模拟二进制文件头
    byte[] payload = new byte[2048];           // 2KB 业务负载数据

    // 使用 try-with-resources 语法自动管理生命周期
    try (FileOutputStream fos = new FileOutputStream("artifact.bin")) {

      // 1. 写入单字节数据
      fos.write(0x10);

      // 2. 批量写入完整的文件头字节数组
      fos.write(metaHeader);

      // 3. 片段写入:仅将 payload 数组中从索引 100 开始的 512 个字节写入磁盘
      fos.write(payload, 100, 512);

      // 4. 显式推入操作系统缓存层
      fos.flush();

    } catch (IOException e) {
      System.err.println("持久化数据流失败: " + e.getMessage());
    }
  }
}
高级控制与通道
  • FileChannel getChannel()()获取文件通道。返回与此文件输出流关联的唯一的 FileChannel 对象。可用于高级 NIO 异步操作、文件锁或零拷贝(Zero-Copy)技术。

  • FileDescriptor getFD()()获取文件描述符。返回与此流关联的底层物理文件描述符 FileDescriptor 对象。

注意事项

  1. 通过 getChannel() 获得的 FileChannel 其读写指针位置与当前的 FileOutputStream 是强绑定的。
  2. FileOutputStream 实例被关闭时,与之关联的 FileChannel 也会被同步关闭。反之亦然。
java
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.FileDescriptor;
import java.nio.channels.FileChannel;

public class FosAdvancedDemo {
  public static void main(String[] args) {
    try (FileOutputStream fos = new FileOutputStream("nio_target.dat")) {

      // 1. 获取底层物理文件描述符,可用于校验文件合法性或实施底层同步
      FileDescriptor fd = fos.getFD();
      System.out.println("文件描述符是否有效: " + fd.valid());

      // 2. 导出 NIO 核心组件 FileChannel
      FileChannel channel = fos.getChannel();
      System.out.println("NIO 管道当前容量: " + channel.size() + " 字节");

    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}
示例:将数据写入文件
java
import java.io.FileOutputStream;
import java.io.IOException;

public class FileOutputStreamDemo {
  public static void main(String[] args) {
    // 创建一个追加模式的字节输出流
    try (FileOutputStream fos = new FileOutputStream("log.dat", true)) {

      String msg = "Hello Java Byte Stream!\n";
      // 字符串需要转换成字节数组才能由字节流写入
      byte[] bytes = msg.getBytes();

      // 写入整个字节数组
      fos.write(bytes);
      System.out.println("数据写入成功!");

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

中文乱码问题

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

为什么会乱码

在现代常用的 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 体系中的“性能加速器”。

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

工作原理

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

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

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

四大核心类

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

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

字节缓冲流~

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

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

核心家族成员

字节缓冲流只有两个核心类,它们分别继承自 FilterInputStreamFilterOutputStream(间接继承自 InputStreamOutputStream):

  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()()测试标记支持。测试此流是否支持 markreset。对于 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。在其上的管道操作(如 filtermap)只有在触发终止操作(如 forEachcollect)时才会真正驱动 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),从而彻底解决令人头疼的文本乱码问题

继承体系

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

image-20260711175924162

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

  1. InputStreamReader

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

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

  2. OutputStreamWriter

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

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

转换流的作用

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

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

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

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

API:InputStreamReader

构造方法

  • InputStreamReader InputStreamReader()(InputStream in)标准默认字符集构造。创建一个使用当前 JVM 系统默认字符集的转换输入流。通常用于确信底层字节流编码与本地环境完全一致的场景。

  • InputStreamReader InputStreamReader()(InputStream in, String charsetName)字符集别名包装构造。创建使用指定名称字符集的转换输入流。当传入系统不支持的别名时会引发异常。

  • InputStreamReader InputStreamReader()(InputStream in, Charset cs)推荐字符集对象包装构造。创建使用指定强类型 Charset 对象的转换输入流。具备极高的编译期安全性。

  • InputStreamReader InputStreamReader()(InputStream in, CharsetDecoder dec)高阶解码器定制构造。创建使用定制化字符解码器 CharsetDecoder 的转换输入流,适用于需要对错乱字符精细过滤或替换的复杂工业场景。

注意事项

  1. 构造方法传入的 InputStream 实例绝对不能为 null,否则构造期将无条件抛出 NullPointerException
  2. 使用以字符串指定别名的构造方法 InputStreamReader(InputStream in, String charsetName) 时,必须强制显式捕获或声明抛出检查型异常 UnsupportedEncodingException
  3. 从 JDK 18 开始,Java 平台的默认编码改为 UTF-8,但在 JDK 17 及之前的旧版本中,默认编码依赖于宿主操作系统(如 Windows 常为 GBK)。因此,在多平台协同部署的分布式系统中,必须禁止使用不带字符集参数的默认构造,统一显式声明 StandardCharsets.UTF_8
java
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class ConstructorGroupDemo {
  public static void main(String[] args) {
    byte[] rawBytes = {0x4A, 0x61, 0x76, 0x61}; // "Java" 的 ASCII 码
    InputStream in = new ByteArrayInputStream(rawBytes);

    // 1. 标准默认构造(依赖平台默认编码)
    InputStreamReader isr1 = new InputStreamReader(in);

    // 2. 字符集别名构造(需防范不支持的编码异常)
    try {
      InputStreamReader isr2 = new InputStreamReader(in, "UTF-8");
      isr2.close();
    } catch (UnsupportedEncodingException e) {
      System.err.println("当前平台不支持该字符集别名");
    } catch (Exception e) {
      e.printStackTrace();
    }

    // 3. 字符集对象包装构造(推荐使用,无检查异常风险)
    InputStreamReader isr3 = new InputStreamReader(in, StandardCharsets.UTF_8);

    // 4. 通过动态查找的 Charset 对象构造
    InputStreamReader isr4 = new InputStreamReader(in, Charset.forName("GBK"));

    try {
      isr1.close();
      isr3.close();
      isr4.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
    System.out.println("所有类型转换桥梁构造成功。");
  }
}

字符读取与状态控制

  • int read()()单字符同步读取。从流中解码并读取单个字符。返回解出的 16 位字符的无符号码值(0~65535);若已到达物理流终点,则返回 -1

  • int read()(char[] cbuf, int offset, int length)分块片段读取。从转换流中读取字符,并将其批量转储到目的字符数组 cbuf 中,从数组的 offset 偏移索引处开始填入,最大限制读取数量为 length。返回实际解出的字符总数。

  • String getEncoding()()获取当前有效编码名称。返回当前转换流底层的解码器所使用的字符集历史规范名称。若该流已被关闭,则可能返回 null

  • boolean ready()()检查流就绪状态。用于通知上层应用当前流缓冲区是否支持无阻塞读取。若转换流内部的字符预读缓冲区不为空,或者底层的字节输入流已经处于可读状态,则返回 true

  • void close()()关闭转换流。关闭该流并彻底释放与之相关联的全部系统底层资源。

注意事项

  1. 单字符读取方法 read() 的内部逻辑虽然会尝试利用内部缓冲,但若没有外层上层高级流(如 BufferedReader)进行包裹,在密集循环中高频触发读取仍会带来一定的性能衰减。
  2. 级联释放机制:当显式调用 InputStreamReader.close() 方法时,它的内部实现会去自动关闭其内部封装包裹的底层字节输入流节点(如 FileInputStream)。因此,切记不需要也不应该在外部重复关闭底层的字节流,防止触发某些特殊子流的二次关闭异常。
java
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class MethodGroupDemo {
  public static void main(String[] args) {
    byte[] source = "API_Method_Specification_Detail".getBytes(StandardCharsets.UTF_8);

    try (InputStreamReader isr = new InputStreamReader(new ByteArrayInputStream(source), StandardCharsets.UTF_8)) {

      // 1. 获取当前正在生效的编解码字符集标识
      System.out.println("底层正在服役的解码器名称: " + isr.getEncoding());

      // 2. 无阻塞就绪判定
      if (isr.ready()) {
        System.out.println("转换通道已就绪,可无阻塞自由读取。");
      }

      // 3. 读取单个字符探针
      int firstChar = isr.read();
      System.out.println("首字节解码所得字符: " + (char) firstChar);

      // 4. 分块批量读取余下字符段
      char[] targetContainer = new char[32];
      int readLen = isr.read(targetContainer, 0, targetContainer.length);

      System.out.println("本次分块批量读取到的字符长度: " + readLen);
      System.out.println("容器内缓存内容明文: " + new String(targetContainer, 0, readLen));

    } catch (IOException e) {
      e.printStackTrace();
    } // 5. try-with-resources 自动驱动 close() 方法级联关闭底层资源
  }
}

示例:指定编码读取文件

这是 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)标准默认字符集构造。创建一个使用系统默认字符集的转换输出流。

  • OutputStreamWriter OutputStreamWriter()(OutputStream out, String charsetName)字符集别名包装构造。创建使用指定名称字符集的转换输出流。常用于需要兼容特定别名编码的系统。

  • OutputStreamWriter OutputStreamWriter()(OutputStream out, Charset cs)推荐字符集对象包装构造。创建使用指定 Charset 对象的转换输出流。具备极高的编译期安全性,是工业开发推荐标准。

  • OutputStreamWriter OutputStreamWriter()(OutputStream out, CharsetEncoder enc)高阶编码器定制构造。创建使用指定字符编码器 CharsetEncoder 的转换输出流。

注意事项

  1. 构造方法传入的底层的字节流 OutputStream 对象绝对不能为 null,否则在初始化阶段将抛出 NullPointerException
  2. 使用以字符串形式指定别名的构造方法 OutputStreamWriter(OutputStream out, String charsetName) 时,必须强制显式捕获或声明抛出检查型异常 UnsupportedEncodingException
  3. 从 JDK 18 开始,Java 平台的默认编码已被统一调整为 UTF-8。但在高并发跨平台分布式部署环境中,应当禁止使用不带字符集参数的默认构造方法,以防异构宿主机环境(如旧版 Windows Server 系统的 GBK 编码)导致字符损坏。
java
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class ConstructorGroupDemo {
  public static void main(String[] args) {
    OutputStream outContainer = new ByteArrayOutputStream();

    // 1. 系统默认字符集构造(依赖平台默认编码环境)
    OutputStreamWriter oswDefault = new OutputStreamWriter(outContainer);

    // 2. 字符集字符串别名构造(需捕获检查型异常)
    try {
      OutputStreamWriter oswAlias = new OutputStreamWriter(outContainer, "GBK");
      oswAlias.close();
    } catch (UnsupportedEncodingException e) {
      System.err.println("当前系统无法识别该字符集命名别名!");
    } catch (Exception e) {
      e.printStackTrace();
    }

    // 3. 强类型字符集对象构造(推荐,规避拼写异常隐患)
    OutputStreamWriter oswStandard = new OutputStreamWriter(outContainer, StandardCharsets.UTF_8);

    // 4. 通过动态查找生成的 Charset 对象构造
    OutputStreamWriter oswDynamic = new OutputStreamWriter(outContainer, Charset.forName("Big5"));

    try {
      oswDefault.close();
      oswStandard.close();
      oswDynamic.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
    System.out.println("所有核心类型的桥梁构造函数配置完毕。");
  }
}

字符写入与刷新控制

  • void write()(int c)写入单字符。将单个字符写入流中。写入字符的 16 位码值由参数 c 的低 16 位提供,高 16 位被忽略。

  • void write()(char[] cbuf, int off, int len)片段写入字符数组。将字符数组 cbuf 中从偏移量 off 开始、长度为 len 的一部分字符写出。

  • void write()(String str, int off, int len)片段写入字符串。将字符串 str 中从偏移量 off 开始、长度为 len 的一部分字符写出。

  • String getEncoding()()获取当前有效编码名称。返回此流正在使用的字符编码的历史规范名称。若该流已经物理闭合,可能会返回 null

  • void flush()()刷新编码缓冲区。强制将留存在内部转换编码器缓冲区中的所有字节立即推入到下层的字节输出流中。

  • void close()()关闭转换流。关闭此流。系统在物理闭合前会自动调用一次 flush(),随后级联关闭底层的物理流并释放关联的物理句柄。

注意事项

  1. 核心缺陷防范(丢失数据风险)OutputStreamWriter 内部自带一个字符编码转换缓冲区。上层调用 write() 写入的字符实际上滞留在该内存缓冲区内,并未实时变成字节写入磁盘。必须显式调用 flush()close(),否则程序在非正常闭合时,缓冲区内的尾部数据将无法传递给底层的 FileOutputStream,导致最终生成的文本文件发生数据缺失或内容为空。
  2. 级联资源释放:当调用 OutputStreamWriter.close() 时,其内部的 finally 结构会无条件闭合下层被包装的物理字节输出流(如 FileOutputStream)。在实际工程中,只需确保外层转换流被安全包装在 try-with-resources 结构中即可,严禁在外部重复调用底层流的关闭方法。
java
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class MethodGroupDemo {
  public static void main(String[] args) {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();

    try (OutputStreamWriter osw = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {

      // 1. 获取当前正在服役的底层编码器名称
      System.out.println("有效编码器名称: " + osw.getEncoding());

      // 2. 单字符写入操作
      osw.write('A');

      // 3. 字符数组切片片段写入
      char[] token = {'J', 'a', 'v', 'a', 'I', 'O'};
      osw.write(token, 0, 4); // 仅写入 "Java"

      // 4. 字符串切片片段写入
      String message = "Cloud_Native_Platform_2026";
      osw.write(message, 13, 8); // 仅写入 "Platform"

      // 5. 强行触发表征,将编码后的字节压入 baos
      osw.flush();
      System.out.println("内存物理结果明文: " + baos.toString(StandardCharsets.UTF_8));

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

示例:指定编码写入文件

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

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 官方终于给 FileReaderFileWriter 的构造函数加上了 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 负责深度遍历和写入类元数据。完全手动。必须由程序员在代码中一行行写明怎么读写。
底层接口方法无(标记接口)。可通过私有方法定制。必须实现 writeExternalreadExternal
构造方法要求不需要无参构造方法。强制要求类必须提供一个 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(谷歌)、KryoHessian 等。

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(),将引发二进制结构错乱,导致抛出 OptionalDataExceptionStreamCorruptedException
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 会在反序列化完成前自动调用该方法,并用它的返回值替换掉刚刚重构出的新对象。

其他流【

数据流【

标准输入/输出流【

打印流【