S06-02 IO流-IO流
[TOC]
概述
在 Java 中,IO 流(Input/Output Stream) 是处理设备之间数据传输的核心机制。无论是读取本地文件、向网络发送数据,还是在控制台输入输出,都离不开 IO 流。
Java 将这些数据传输操作抽象为“流(Stream)”,就像水流一样,数据从一个地方源源不断地流向另一个地方。

IO 流分类
Java 的 IO 流类库非常庞大,但核心逻辑非常清晰。通常我们可以从三个维度对流进行分类:
按流向划分:
- 输入流(Input Stream): 把外部设备(文件、网络等)中的数据读取到内存中。
- 输出流(Output Stream): 把内存中的数据写入到外部设备中。
按处理的数据单位划分:
- 字节流(Byte Stream): 以 字节(8 个 bit) 为单位进行读写。适合处理所有类型的文件(如图片、视频、音频、可执行文件等二进制数据)。
- 字符流(Character Stream): 以 字符(16 个 bit,即两个字节) 为单位进行读写。专门用来处理纯文本文件(如
.txt、.java、.json等),会自动处理字符编码问题。
按流的角色划分:
- 节点流(Node Stream): 直接与具体的数据源(如文件、内存、网络)相连的流。例如
FileInputStream。 - 处理流(Processing Stream / 包装流): 建立在已存在的流之上,通过对其他流进行包装,来提供更强大的读写功能(如缓冲、序列化等)。例如
BufferedInputStream。
- 节点流(Node Stream): 直接与具体的数据源(如文件、内存、网络)相连的流。例如

抽象基类
所有的 Java IO 流类共涉及 40 多个类,最顶层都是由以下四个抽象类派生出来的:
| 数据单位 \ 流向 | 输入流 (Input) | 输出流 (Output) |
|---|---|---|
| 字节流 (Byte) | InputStream | OutputStream |
| 字符流 (Character) | Reader | Writer |

API: InputStream
构造方法
InputStreamInputStream():(),构造方法。受保护的构造方法,供子类在初始化时显式或隐式调用。javaimport 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 intread():(),单字节读取。从输入流中读取下一个字节的数据,值以 0 至 255 的无符号 int 形式返回;若已达流末尾则返回 -1。方法在有数据到达、到达流末尾或抛出异常前保持阻塞。intread():(byte[] b),多字节缓冲读取。从输入流中读取一定数量的字节并存入缓冲区数组 b 中,返回实际读入的字节数;若已达流末尾则返回 -1。intread():(byte[] b, int off, int len),定长偏移读取。从输入流中最多读取 len 个字节存入缓冲区数组 b 中由 off 指定的起始偏移位置。byte[]readAllBytes():(),全量字节读取。读取当前流中剩余的全部字节并封装为 byte 数组返回。intreadNBytes():(byte[] b, int off, int len),定额字节读取。阻塞读取指定数量 len 的字节至缓冲区数组 b 的 off 偏移处,直至读满、遇到 EOF 或发生异常。byte[]readNBytes():(int len),指定长度读取。读取最多 len 个字节并返回实际包含读取内容的 byte 数组。
注意事项:
read()返回值类型为int而非byte。如果使用byte接收返回值,合法的字节值0xFF(即十进制 255、有符号 byte 的 -1)将无法与流终止标识符-1区分,导致数据被提前截断。read(byte[] b)与read(byte[] b, int off, int len)并不能保证一次性填满传入的数组容量,其返回值表示本次实际读入的字节量。业务处理中必须严格依据实际读入长度截取有效区间,严禁直接使用b.length遍历数据。readAllBytes()会在 JVM 堆内存中一次性分配连续数组。严禁在海量磁盘文件或持续流入的网络流上调用,否则会直接引发OutOfMemoryError。
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);
}
}
}流状态控制
intavailable():(),非阻塞可用字节估算。返回在不发生阻塞的情况下可从此输入流读取(或跳过)的估计字节数。longskip():(long n),字节跳过。跳过并丢弃输入流中的至多 n 个字节,返回实际跳过的字节数。voidskipNBytes():(long n),定额字节跳过。精确跳过输入流中的 n 个字节,若在跳过完毕前到达流末尾则抛出EOFException。booleanmarkSupported():(),标记支持检测。测试当前输入流是否支持mark()和reset()机制。synchronized voidmark():(int readlimit),流位置标记。在此输入流中标记当前位置,使后续调用reset()能回到该位置;readlimit 指定在标记失效前允许读取的最大字节数。synchronized voidreset():(),流位置重置。将此流的读取指针重置到最后一次调用mark()时的位置。
注意事项:
available()方法返回的是非阻塞可读取字节估算值。在网络套接字(SocketInputStream)等低速 I/O 场景中,该方法仅反映已到达操作系统本地套接字缓冲区的数据大小,切勿将其作为数据流的实际总长度用于判断是否读完。mark()与reset()并非所有流都支持。InputStream基类默认实现中markSupported()返回false,且reset()直接抛出IOException。调用前必须经由markSupported()校验,或使用BufferedInputStream、ByteArrayInputStream等具备内存缓存能力的子类。skip(long n)在发生底层分片或文件截断时可能返回小于 n 的实际跳过值;若需要严格跳过指定数量字节,应优先选用skipNBytes(long n)。
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());
}
}
}资源管控与传输
voidclose():(),资源释放。关闭此输入流并释放与该流关联的所有底层系统资源(如文件描述符、网络句柄)。longtransferTo():(OutputStream out),流数据直传。从当前流中读取全部字节并顺序写入指定的目标输出流,返回传输的字节总数。InputStreamInputStream.nullInputStream():(),空流创建。返回一个已打开且不包含任何字节的输入流实例,常用于占位或重定向丢弃场景。
注意事项:
InputStream实现了java.lang.AutoCloseable与java.io.Closeable接口,必须始终在try-with-resources语句块中进行声明管理,严禁依赖 GC 回收阶段释放底层文件描述符。transferTo(OutputStream out)内部采用默认 8192 字节的循环缓冲区进行阻塞式搬运。方法会持续读取直至当前流达到 EOF。若输入流为持续产生数据的长连接或无界流,该操作将发生永久阻塞。
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
构造方法
OutputStreamOutputStream():(),构造方法。受保护的构造方法,供子类在初始化时显式或隐式调用。javaimport 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 voidwrite():(int b),单字节写入。向输出流中写入指定的单个字节。参数接收 32 位整型值,但底层仅将其低 8 位写入目标介质,高 24 位被自动截断丢弃;该方法在数据完全写入或产生异常前保持阻塞。voidwrite():(byte[] b),多字节缓冲写入。将指定的字节数组 b 中的全部内容写入此输出流。其底层默认直接委托调用 write(b, 0, b.length)。voidwrite():(byte[] b, int off, int len),定长偏移写入。将指定的字节数组 b 中从偏移量 off 开始的连续 len 个字节写入此输出流。
注意事项:
write(int b)仅写入低 8 位:传入参数是int类型,如果传入超过0~255的整数(例如321,二进制0x0141),高 24 位会被静默截断,实际写入的仅是0x41(即字符'A')。write(byte[] b, int off, int len)存在严格的边界校验:当off < 0、len < 0或off + len > b.length时,会直接抛出IndexOutOfBoundsException。在处理网络传输或切片数据时,必须确保传入边界参数合法。- 阻塞性行为:
OutputStream的所有写入方法默认均为同步阻塞操作。若底层操作系统内核缓冲区已满或磁盘 I/O 拥塞,当前调用线程将被挂起等待,直至空间释放或写入超时。
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));
}
}
}资源管控
voidflush():(),缓冲区刷新。强制将此输出流中所有已缓冲的未写出字节同步刷新至目标底层物理介质或网络通道。voidclose():(),资源释放。关闭此输出流并释放与该流关联的所有底层系统资源(如文件句柄、网络套接字)。OutputStreamOutputStream.nullOutputStream():(),空流创建。返回一个已打开且丢弃所有写入字节的空输出流实例,常用于静默丢弃输出或重定向占位场景。
注意事项:
flush()在不同流实现中的语义差异:在无缓冲的节点流(如FileOutputStream)中,flush()继承自基类的空实现,并不保证将数据真正物理落盘(需依赖FileChannel.force()或FileDescriptor.sync());但在带缓冲的处理流(如BufferedOutputStream、PrintStream)中,必须显式调用flush()或等待缓冲区写满,否则暂存在内存中的数据可能丢失或无法及时被对端接收。- 关闭流的连带效应:在由多个装饰器流嵌套构成的管道链中,关闭最外层的流会自动沿链路自顶向下关闭内部所包装的所有底层流。在网络编程(如
Socket通信)中,关闭由socket.getOutputStream()获取的流将直接导致整个底层的Socket物理连接被关闭;若仅需关闭输出半通道,应调用socket.shutdownOutput()。- 必须使用
try-with-resources:OutputStream实现了AutoCloseable与Closeable接口,严禁依赖垃圾回收器在终结阶段释放底层操作系统分配的文件描述符或套接字。
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 ReaderReader():(),构造方法。受保护的默认构造方法,以当前 Reader 实例自身(this)作为关键操作的同步锁对象。protected ReaderReader():(Object lock),构造方法。受保护的重载构造方法,使用指定的独立对象作为关键临界区的同步锁。javaimport 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()); } }
基础数据读取
intread():(),单字符读取。从输入流中读取下一个字符,值以 0 至 65535 的无符号 int 整数返回;若已达流末尾则返回 -1。intread():(char[] cbuf),多字符缓冲读取。将字符读入目标字符数组 cbuf 中,返回实际读入的字符总数;若已达流末尾则返回 -1。abstract intread():(char[] cbuf, int off, int len),定长偏移读取。从输入流中最多读取 len 个字符存入目标字符数组 cbuf 中由 off 指定的起始偏移位置。这是子类必须实现的底层核心抽象读取方法。intread():(CharBuffer target),字符缓冲区读取。尝试将流中字符直接读入指定的 NIO 字符缓冲区 target 中,实现来自 java.lang.Readable 接口的统一契约。
注意事项:
read()返回值类型为 32 位int而非 16 位char。Java 中char为无符号 16 位类型(0x0000 ~ 0xFFFF),不存在负数表示。如果强制使用char承接返回值,流终止标识符-1会被截断并转换为 Unicode 码点0xFFFF(即65535),造成循环无法退出。- Unicode 增补字符(Supplementary Characters,如部分 Emoji 或生僻汉字)在 UTF-16 中是由两个 16 位的代理单元(Surrogate Pair:高代理与低代理)组成的。单次
read()仅会读取其中的一半(一个char),直接对其进行截断或单独处理会导致乱码,在处理全域 Unicode 文本时需结合Character.isHighSurrogate()进行代理对拼接。read(char[] cbuf, int off, int len)是所有高级读取操作的委托根基。子类实现时必须严格保证:当len == 0时必须立即返回0,不得发生阻塞或改变流游标。
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);
}
}
}流状态控制
booleanready():(),流就绪状态检测。判断此流是否已处于可进行无阻塞读取的状态。若底层缓冲区非空或下一次调用不会发生线程挂起,则返回 true。longskip():(long n),字符跳过。跳过流中的至多 n 个字符,返回实际跳过的有效字符数。booleanmarkSupported():(),标记支持检测。测试当前字符输入流是否支持 mark() 和 reset() 机制。Reader 抽象基类默认固定返回 false。voidmark():(int readAheadLimit),流位置标记。在此流中记录当前读取指针位置;readAheadLimit 指定在标记失效前允许向前读取的最大字符数。voidreset():(),流位置重置。将此字符流的读取游标重置回最后一次调用 mark() 时的位置。
注意事项:
ready()与InputStream.available()的语义差异:ready()返回的是布尔值,仅表示当前是否可以立即读取,不提供可读字符数量的估值。此外,ready()返回false并不代表流已结束或下一次读取一定会发生阻塞(在部分网络/管道流中可能仅代表底层数据包尚未抵达)。mark()与reset()默认不可用:Reader基类中mark()与reset()默认直接抛出IOException: mark() not supported。调用前必须经由markSupported()进行防御性校验,或显式选用BufferedReader、StringReader、CharArrayReader等具备内存缓存能力的实现类。skip(long n)的传参约束:参数n必须非负;若传入负数,方法将直接抛出IllegalArgumentException: skip value is negative。
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 voidclose():(),资源释放。关闭此字符流并释放关联的底层系统资源。一旦流被关闭,后续调用 read()、ready() 等操作将直接抛出 IOException。longtransferTo():(Writer out),字符流数据直传。从当前 Reader 中顺序读取所有剩余字符并直接写入指定的目标 Writer 中,返回传输的字符总数。ReaderReader.nullReader():(),空字符流创建。返回一个已打开且不包含任何字符数据的空 Reader 实例,常用于空对象模式占位或静默丢弃场景。
注意事项:
Reader实现了java.lang.AutoCloseable与java.io.Closeable接口,必须严格置于try-with-resources代码块中声明,杜绝因异常抛出导致底层操作系统文件句柄或套接字连接泄漏。transferTo(Writer out)内部采用默认 8192 字符大小的数组循环搬运,直到读至流末尾(返回-1)。若用于持续产出数据的长连接无界字符流,将导致调用线程永久挂起。此外,该方法传输完成后不会自动关闭输入流或输出流。close()方法的幂等性与传染性:关闭包装流(如BufferedReader)会自动沿着装饰链路关闭内部所包装的底层InputStreamReader及原始InputStream。若仅需要读取部分文本而保持底层网络连接打开,切勿直接调用最外层装饰流的close()。
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 WriterWriter():(),构造方法。受保护的默认构造方法,以当前 Writer 实例自身(this)作为关键操作的同步锁对象。protected WriterWriter():(Object lock),构造方法。受保护的重载构造方法,使用指定的独立对象作为关键临界区的同步锁。javaimport 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()); } }
基础字符写入
voidwrite():(int c),单字符写入。向输出流中写入单个字符。参数为 32 位整型,但底层仅写入低 16 位字符(0x0000 ~ 0xFFFF),高 16 位被自动忽略;在字符完全写出前保持阻塞。voidwrite():(char[] cbuf),多字符缓冲写入。将指定的字符数组 cbuf 中的全部字符写入此输出流。底层直接委托调用 write(cbuf, 0, cbuf.length)。abstract voidwrite():(char[] cbuf, int off, int len),定长偏移写入。将指定的字符数组 cbuf 中从偏移量 off 开始的连续 len 个字符写入此流。这是子类必须实现的底层核心抽象写方法。voidwrite():(String str),字符串写入。将指定的字符串完整写入此输出流。底层直接委托调用 write(str, 0, str.length())。voidwrite():(String str, int off, int len),定长字符串切片写入。将指定的字符串 str 中从偏移量 off 开始的连续 len 个字符写入此流。
注意事项:
write(int c)的低 16 位截断规则:传入的参数是 32 位int,若传入超出0x0000 ~ 0xFFFF(即 0 ~ 65535)范围的高位码点(例如部分包含辅助平面的 Emoji),高 16 位会被直接截断丢失,导致输出损坏。对于辅助平面字符,必须拆分为高低代理对(Surrogate Pair)依次写入两个char。write(char[] cbuf, int off, int len)与字符串切片写入的边界约束:当off < 0、len < 0或off + len > cbuf.length(或str.length())时,将抛出IndexOutOfBoundsException。在处理网络传输或切片数据时,必须确保传入边界参数合法。write(String str)的内部缓存优化:子类(如BufferedWriter)通常会为字符串写入重写优化路径,避免在写出前将String显式转换为额外的char[]堆内存分配,因此在长文本写入时应优先传递String或切片,而非手动调用str.toCharArray()。
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());
}
}
}字符序列追加
Writerappend():(CharSequence csq),字符序列全量追加。将指定的字符序列追加写入此输出流,返回当前 Writer 自身以支持链式调用;若 csq 为 null 则追加写入字符串 "null"。Writerappend():(CharSequence csq, int start, int end),字符序列子序列追加。将指定的字符序列 csq 从索引 start 到 end 的子序列追加到此流中;若 csq 为 null 则视为 "null" 进行切片。Writerappend():(char c),单个字符追加。将指定的 16 位字符 c 追加到此输出流中,返回当前流自身。
注意事项:
append()遵从java.lang.Appendable接口规约,在遇到入参为null时不会抛出NullPointerException,而是写入由 4 个字符构成的"null"文本。如果业务逻辑要求严格空值过滤,必须显式做前置判空。append(CharSequence csq, int start, int end)具有左闭右开区间语义[start, end)。当start < 0、start > end或end > csq.length()时,将直接抛出IndexOutOfBoundsException。- 返回值链式调用:
append()返回Writer引用本身,便于构建链式流式语法;但在异常处理或包含底层物理 I/O 刷新的链路中,链式调用掩盖了每个阶段的写入行为,调试排查需要格外注意。
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 voidflush():(),缓冲区刷新。强制将此流中所有已缓冲的字符数据立即写出到底层物理目标介质或管道中。abstract voidclose():(),资源释放。关闭此字符输出流并释放与该流关联的所有底层物理资源(如文件描述符)。关闭后再次调用写操作将抛出 IOException。WriterWriter.nullWriter():(),空写入器创建。返回一个已打开且丢弃所有写入字符的空 Writer 实例,常用于静默丢弃输出或空对象设计模式。
注意事项:
flush()在装饰器链中的传播:调用外层装饰流(如BufferedWriter)的flush()会逐层向下调用内层流的flush(),最终触发底层流将数据提交给操作系统。但对于文件流而言,flush()仅确保数据从 Java 用户态缓冲区进入操作系统页面缓存(Page Cache),并不保证瞬时完成物理磁盘落盘(完全落盘需依赖通道同步机制)。close()的先刷新语义与级联效应:规范实现中,close()在断开底层资源句柄之前必须先隐式调用一次flush()。同时,关闭最外层流会自动连锁关闭链条中的所有底层流;在网络通信(如 Socket 输出)中,关闭Writer会连带关闭整个 Socket 连接,若仅需半关闭连接应调用Socket.shutdownOutput()。- 必须在
try-with-resources语句中进行资源管控:Writer实现了AutoCloseable与Closeable,切勿在非防御性代码中手动依赖 GC 回收底层文件句柄。
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 接口的流,避免内存泄漏。
下面是一个使用缓冲字符流复制文本文件的经典示例:
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();
}
}
}最佳实践
用完必须关闭: IO 流属于系统资源,不属于 JVM 的垃圾回收范畴,不手动关闭会导致内存泄漏或文件被长期锁定。
记得
flush(): 缓冲输出流在缓冲区未满时不会自动写入磁盘。虽然close()触发时会自动刷出缓冲区,但在一些长连接或持续写入的场景中,手动调用flush()能保证数据及时落地。明确区分字节与字符: 只要是文本文件,优先用字符流/缓冲字符流;只要是文本之外的其他媒体文件(图片、MP3、视频等),必须用字节流。
节点流-文件流
在 Java 中,文件流(File Streams) 是 IO 流体系中最常用的分支,专门用于读写本地磁盘上的文件。
文件流直接与物理磁盘上的文件建立连接,属于节点流。根据处理数据单位的不同,文件流同样分为字节文件流和字符文件流。
四大核心类
Java 针对文件操作提供了四个核心的流类,它们分别继承自四大抽象流:
| 数据类型 \ 流向 | 输入流(读文件) | 输出流(写文件) |
|---|---|---|
| 字节文件流 | FileInputStream | FileOutputStream |
| 字符文件流 | FileReader | FileWriter |
字节文件流:以字节(Byte)为单位进行读写,由于计算机中所有文件(包括文本、图片、视频、压缩包等)底层都是字节,因此字节文件流可以处理任何类型的文件。
字符文件流:以字符(Character)为单位进行读写。它在由磁盘读取字节后,会根据指定的字符编码表(如 UTF-8、GBK)自动将其转换为字符。因此,它只能用来处理纯文本文件(如
.txt,.json,.java)。
字符文件流~
在 Java 的 IO 流世界中,如果说字节流是处理一切文件的“万能瑞士军刀”,那么字符文件流就是专门用来雕刻文本的“手术刀”。
计算机底层只认识字节(0 和 1),但人类使用的是文字(字符)。字符文件流的出现,就是为了在“字节”与“字符”之间搭建一座自动转换的桥梁,让我们在读写纯文本文件(如 .txt, .json, .xml, .java)时,再也不用手动去处理复杂的字符编码(如 UTF-8、GBK)和断字问题。
继承体系
字符文件流的核心类只有两个:(用于读文本)和 FileReader(用于写文本)。它们分别继承自 FileWriterReader 和 Writer 这两个抽象基类。
工作机制
FileReader 工作机制:
从继承关系可以看出,FileReader 实际上是 InputStreamReader 的子类。它的底层工作原理是:
先通过文件系统读取底层的 原始字节(Bytes)。
内部通过一个 解码器(Decoder),根据指定的字符集(如 UTF-8)将这些字节转换成 字符(Chars)。
最终让你以字符的形式读取出来。
FileWriter 工作机制:
由于 FileWriter 继承自 OutputStreamWriter,它的底层核心逻辑是:
接收程序传入的 字符(Chars) 或 字符串(Strings)。
内部通过一个 编码器(Encoder),根据指定的字符集(如 UTF-8)将字符转换为底层的 原始字节(Bytes)。
暂时将字节存放在内存的字节缓冲区中。
当缓冲区满了,或者手动刷新/关闭流时,将字节真正写入磁盘文件中。
API: FileReader
构造方法
FileReaderFileReader():(String fileName),构造方法。根据传入的文件名字符串构建字符输入流,使用平台默认字符集(JDK 18+ 默认 UTF-8)。FileReaderFileReader():(File file),构造方法。根据传入的File抽象路径构建字符输入流,使用平台默认字符集。适用于已持有File对象的场景。FileReaderFileReader():(FileDescriptor fd),构造方法。根据传入的已打开底层文件描述符构建字符输入流,使用平台默认字符集。适用于标准输入或原生句柄对接等特定底层场景。FileReaderFileReader():(String fileName, Charset charset),构造方法。根据指定文件名和显式字符集构建字符输入流(JDK 11+)。可避免跨操作系统部署时的乱码隐患。FileReaderFileReader():(File file, Charset charset),构造方法。根据File对象和显式字符集构建字符输入流(JDK 11+)。是生产环境中标准的文件字符流创建方式。
注意事项:
- 传入的文件路径若不存在或为目录,构造方法会直接抛出
java.io.FileNotFoundException(其为IOException的子类)。- 在 JDK 11 之前,若需要指定字符编码,禁止直接使用
FileReader,必须改用new InputStreamReader(new FileInputStream(file), charset)。FileDescriptor构造器创建的流不会关闭传入的外部文件描述符的所有权,需由调用方统一生命周期管理。
// 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
构造方法
FileWriterFileWriter():(String fileName),构造方法。根据传入的文件名字符串构建字符输出流,使用平台默认字符集(JDK 18+ 默认 UTF-8),默认覆写模式。FileWriterFileWriter():(String fileName, boolean append),构造方法。根据传入的文件名字符串构建字符输出流,可指定是否以追加(append)模式写入,使用平台默认字符集。FileWriterFileWriter():(File file),构造方法。根据传入的File对象构建字符输出流,使用平台默认字符集,默认覆写模式。FileWriterFileWriter():(File file, boolean append),构造方法。根据传入的File对象构建字符输出流,可指定是否以追加模式写入,使用平台默认字符集。FileWriterFileWriter():(FileDescriptor fd),构造方法。根据传入的已打开底层文件描述符构建字符输出流,使用平台默认字符集。适用于对接已有原生文件句柄或标准输出。FileWriterFileWriter():(String fileName, Charset charset),构造方法。根据指定文件名和显式字符集构建字符输出流(JDK 11+),默认覆写模式。规避跨平台默认编码不一致隐患。FileWriterFileWriter():(String fileName, Charset charset, boolean append),构造方法。根据指定文件名、显式字符集和追加模式标志构建字符输出流(JDK 11+)。FileWriterFileWriter():(File file, Charset charset),构造方法。根据File对象和显式字符集构建字符输出流(JDK 11+),默认覆写模式。FileWriterFileWriter():(File file, Charset charset, boolean append),构造方法。根据File对象、显式字符集和追加模式标志构建字符输出流(JDK 11+)。是生产环境中标准的文件字符输出方式。
注意事项:
- 目标文件的父级目录如果不存在,构造方法会直接抛出
java.io.FileNotFoundException,构造器本身不会自动创建缺失的父级目录。boolean append参数为false(或未指定)时,若目标文件已存在,底层将会截断(truncate)清空文件原始内容;若为true,则会将指针移动至文件末尾进行追加写入。- 在 JDK 11 之前,
FileWriter不支持显式指定字符编码;若需指定编码,必须回退至new OutputStreamWriter(new FileOutputStream(file, append), charset)。- 使用
FileDescriptor构建流时,该构造器不会获取外部文件描述符的唯一所有权,需注意外部资源的释放边界。
// 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") 时,数据只是被写到了内存里的缓冲区,并没有直接去碰磁盘。
数据落盘的三个触发时机:
缓冲区满了: 缓冲区装不下新数据时,会自动把老数据刷入磁盘。
调用了
flush(): 手动强行把当前缓冲区的所有数据“挤”进磁盘。调用了
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 标准示例:
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();
}最佳实践
- 严禁读写二进制文件: 绝不能用
FileWriter去写图片、视频或加密文件,否则编码器会强行破坏底层的二进制结构,导致文件损坏。 - 目录不存在问题:
- 如果文件不存在,
FileWriter会帮你创建文件; - 但如果文件所在的文件夹(目录)不存在,它会直接抛出
FileNotFoundException(系统找不到指定的路径)。 - 在写入前,应先确保父目录存在(可用
file.getParentFile().mkdirs()创建目录)。
- 如果文件不存在,
- 编码一致性: 写入文件时用了什么编码(如 UTF-8),后续用
FileReader或其他工具读取时,也必须指定相同的编码,否则会看到一堆乱码。
字节文件流~
在 Java 的 IO 流世界中,字节文件流(Byte File Streams) 是最底层、最通用、最不可或缺的基石。
无论我们在电脑上看到的是文本、图片、音频、视频,还是可执行程序,在计算机底层的存储形式统统都是 字节(Byte,即 8 位的二进制数 0 或 1)。因此,字节文件流可以说是文件 IO 中的“万能工具”,它能够处理磁盘上的任何文件。
继承体系
字节文件流的核心类只有两个:(字节输入流)和 FileInputStream(字节输出流)。它们分别继承自整个字节流体系的最高抽象基类:FileOutputStreamInputStream 和 OutputStream。
FileInputStream:把磁盘文件中的数据,以字节为单位读取到程序(内存)中。FileOutputStream:把程序(内存)中的字节数据,写入到磁盘文件中。
API: FileInputStream
构造方法
FileInputStreamFileInputStream():(String name),构造方法。通过指定的文件系统绝对或相对路径名字符串创建输入流。若文件不存在或为目录则抛出异常。FileInputStreamFileInputStream():(File file),构造方法。通过指定的物理文件抽象对象创建输入流。生产开发中最常用的标准构建形式。FileInputStreamFileInputStream():(FileDescriptor fdObj),构造方法。通过已打开的现存有效文件描述符创建输入流。适用于与现有原生句柄或标准输入对接。
注意事项:
- 若传入的物理文件不存在、目标路径指向一个已存在的目录、或者当前进程缺乏操作系统层面的读权限,构造函数均会抛出
java.io.FileNotFoundException。- 使用
FileDescriptor构造输入流时,如果该描述符无效,将直接抛出NullPointerException。且该输入流关闭时是否级联释放底层文件句柄需注意生命周期托管风险。
// 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();
}字节数据读取
intread():(),单字节读取。从输入流中读取单个字节的数据。返回0 ~ 255范围内的无符号整数;若到达流的末尾则返回-1。intread():(byte[] b),数组批量读取。从输入流中读取最多b.length个字节并存入目标数组中。返回实际读取的字节数;若到达流末尾返回-1。intread():(byte[] b, int off, int len),区间受限读取。从输入流中读取最多len个字节并存入数组指定偏移量off处。返回实际读取字节数,到达末尾返回-1。longskip():(long n),跳过字节读取。从输入流中跳过并丢弃n个字节的数据。返回实际跳过的字节数;若n为负数则不跳过任何字节并返回0。
注意事项:
read(byte[] b)与read(byte[] b, int off, int len)并不保证填满整个缓冲数组,只要底层内核有至少 1 字节数据即刻返回。必须以其实际返回的整数判断有效数据长度。- 严禁使用
while (fis.read(buffer) > 0)判断循环终止,当读到文件尾部时返回值是-1,该条件会导致无限死循环;正确条件必须是!= -1。
// 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);状态探测与底层资源
intavailable():(),可用字节预估。返回在不发生阻塞的情况下可从当前输入流读取(或跳过)的剩余字节数估算值。voidclose():(),关闭输入流。关闭当前流并释放与该流关联的所有系统资源与底层操作系统文件句柄。FileChannelgetChannel():(),获取文件通道。返回与当前文件输入流关联的唯一FileChannel对象,可用于 NIO 零拷贝传输或文件锁操作。final FileDescriptorgetFD():(),获取文件描述符。返回表示与当前输入流关联的打开文件的原生底层FileDescriptor对象。
注意事项:
close()方法具备幂等性,多次调用不会产生副作用。流一旦关闭,后续调用read()等方法将抛出IOException: Stream Closed。getChannel()返回的通道与当前FileInputStream共享同一个底层文件位置偏移指针(Position)。对通道执行位置变更会同步影响输入流的读取位置。
// 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
构造方法
FileOutputStreamFileOutputStream():(String name),构造方法。根据指定的文件路径字符串创建文件输出流,默认采用覆写模式。FileOutputStreamFileOutputStream():(String name, boolean append),构造方法。根据指定的文件路径字符串创建文件输出流,可指定是否为追加写入模式。FileOutputStreamFileOutputStream():(File file),构造方法。根据传入的File对象创建文件输出流,默认采用覆写模式。FileOutputStreamFileOutputStream():(File file, boolean append),构造方法。根据传入的File对象创建文件输出流,可指定是否为追加写入模式。FileOutputStreamFileOutputStream():(FileDescriptor fdObj),构造方法。通过已打开的现存有效文件描述符创建文件输出流。
注意事项:
- 若目标文件的父级目录不存在,构造函数会直接抛出
java.io.FileNotFoundException。FileOutputStream不会自动递归创建缺失的父级目录。boolean append为false时,若目标文件已存在,底层会在创建流的瞬间直接清空(截断为 0 字节)该文件。若后续发生异常提前退出,原文件数据也会完全丢失。- 使用
FileDescriptor构造流时,如果该描述符无效,会抛出NullPointerException。通过此构造器实例化的流不会获得底层文件描述符的独占所有权,必须谨慎管理其关闭生命周期。
// 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);
}字节数据输出
voidwrite():(int b),单字节输出。将指定的字节(保留传入整型的低 8 位)写入当前文件输出流。voidwrite():(byte[] b),数组批量输出。将指定字节数组中的所有字节顺序写入当前文件输出流。voidwrite():(byte[] b, int off, int len),区间受限输出。从指定字节数组的偏移量off开始,将连续len个字节写入当前文件输出流。
注意事项:
write(int b)仅输出参数b的最低 8 位(b & 0xFF),高 24 位被自动舍弃,无法直接传入大于 255 的整型数据并期望完整输出。write(byte[] b, int off, int len)会进行边界检查,若off < 0、len < 0或off + len > b.length,将抛出IndexOutOfBoundsException。write系列方法均为阻塞调用,未完成内核拷贝前当前线程不会返回;在挂载网络文件系统(NFS)或磁盘 I/O 饱和时,可能导致线程阻塞停顿。
// 1. 批量写入完整缓冲区
fos.write(buffer);
// 2. 单字节输出(仅取低 8 位)
fos.write(0xFF);
// 3. 从偏移量开始输出受限长度数据
fos.write(buffer, 4, 128);底层资源管理
voidclose():(),关闭输出流。关闭当前文件输出流并释放与该流关联的系统资源及底层文件描述符。FileChannelgetChannel():(),获取文件通道。返回与此文件输出流关联的唯一FileChannel对象。final FileDescriptorgetFD():(),获取文件描述符。返回表示与此文件输出流连接的底层文件描述符对象。
注意事项:
close()方法具备幂等性,多次调用不会产生异常。流一旦关闭,后续对write()等方法的调用将直接抛出IOException: Stream Closed。getChannel()返回的FileChannel与当前FileOutputStream共享同一个底层系统文件游标(Position),通道位置变化会同步影响后续流式写入的目标物理位置。- 关闭
FileOutputStream时会级联关闭其派生的FileChannel;反之,关闭FileChannel亦会导致底层流失效。
// 1. 显式释放所有底层系统句柄
fos.close();
// 2. 获取与该流绑定的唯一 NIO 文件通道
FileChannel channel = fos.getChannel();
// 3. 获取底层物理文件描述符以进行状态校验或硬件刷盘
FileDescriptor fd = fos.getFD();继承:OutputStream
flush, nullOutputStream
中文乱码问题
虽然字节流被称为“万能流”,但在单独使用字节流去读取包含中文的纯文本文件时,会有一个巨大的硬伤——乱码。
为什么会乱码:
在现代常用的 UTF-8 编码中,一个英文字符占 1 个字节,而一个中文字符通常占 3 个字节。
如果你使用
read()单字节读取,每次只读 1/3 个汉字,强行转换成字符就会变成乱码。即使你用
read(byte[] buffer)成批读取,如果缓冲区的边界刚好切在了一个汉字的中间(例如一个汉字的 3 个字节中,前 2 个字节在本次 buffer 里,第 3 个字节在下次 buffer 里),就会发生断字,导致前后两个汉字全部变成乱码。
救兵是谁?
- 如果是专门操作纯文本,应该使用 字符文件流(FileReader/FileWriter)。
- 如果必须用字节流处理文本,应该在外层套上 转换流(InputStreamReader),或者一次性把文件的所有字节读入内存(如
Files.readAllBytes(),仅限小文件)。
实战:文件复制
在开发中,最能体现字节文件流价值的场景就是文件复制(如复制图片、音乐、视频、压缩包等二进制文件)。
下面这个模板是 Java IO 中最经典的文件复制标准写法:
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());
}
}
}最佳实践
务必使用缓冲区(
byte[] buffer):千万不要写出while(fis.read() != -1)这样一次只读一个字节的代码。如果不加缓冲区,每读一个字节程序就要向操作系统发起一次硬件磁盘 IO,这会导致程序运行速度慢上成百上千倍!安全关闭资源:流是极其消耗操作系统资源的。一旦打开,必须关闭。强烈建议在开发中全部使用
try-with-resources结构,让 Java 替你自动、安全地关闭流。write(b, 0, len)的金律:在复制或写入文件时,永远使用带有三个参数的write方法。如果只用write(b),最后一次读取不满缓冲区时,尾部会残留上一次读取的旧数据,导致复制出来的文件比源文件变大或损坏。字节流的升级版:如果要追求更高的性能,可以在
FileInputStream外面再包裹一层BufferedInputStream(字节缓冲流)。它在内存中搭建了二次缓冲区,能够进一步压榨磁盘的读写吞吐量。
处理流-缓冲流
在 Java 的 IO 流世界中,缓冲流(Buffered Streams) 属于高级的处理流(包装流)。它是整个 Java IO 体系中的“性能加速器”。
传统的节点流(如 FileInputStream、FileReader)就像是用汤匙一口一口地喝汤,每次都要向操作系统和磁盘发起请求,效率极低。而缓冲流则像是在汤匙和嘴之间加了一个大碗(缓冲区),先把碗装满,再从碗里高效地喝汤,极大地减少了硬件 I/O 的次数。
工作原理
缓冲流的核心思想就是空间换时间。
它的内部自带了一个字节或字符数组作为缓冲区(默认大小通常是 8192,即 8KB)。
- 输入缓冲流:当程序调用
read()时,缓冲流会一次性从文件中读取 8KB 的数据填满自己的缓冲区。接下来的读取操作都会直接在内存缓冲区中进行,直到这 8KB 数据被消费完,才会再次去读磁盘。 - 输出缓冲流:当程序调用
write()时,数据并不会立刻写入磁盘,而是先暂存在内存缓冲区中。只有当缓冲区满了、或者显式调用了flush()、或者流被关闭(close())时,流才会把这 8KB 的数据一次性写入磁盘。
四大核心类
缓冲流并不直接连接文件,它必须包裹在普通节点流的外面。根据处理数据的类型,缓冲流分为以下四种:
| 基础流分类 | 对应的输入缓冲流 | 对应的输出缓冲流 | 核心优势与特有功能 |
|---|---|---|---|
| 字节流 (Byte) | BufferedInputStream | BufferedOutputStream | 极大提升图片、视频、压缩包等二进制文件的复制和读写性能。 |
| 字符流 (Char) | BufferedReader | BufferedWriter | 极大提升文本文件的读写性能;支持按行读写。 |
字节缓冲流~
在 Java 的 IO 流体系中,字节缓冲流(Byte Buffered Streams) 是专门用来提升二进制文件(如图片、音频、视频、压缩包等)读写性能的高级处理流(也称包装流)。
如果直接使用 FileInputStream 和 FileOutputStream,程序每次读写字节都需要直接与操作系统和磁盘进行交互,这会产生大量的硬件系统调用,导致性能低下。而字节缓冲流在内部维护了一个内存缓冲区,通过减少磁盘物理读写次数,极大地压榨了 I/O 的吞吐量。
核心家族成员
字节缓冲流只有两个核心类,它们分别继承自 FilterInputStream 和 FilterOutputStream(间接继承自 InputStream 和 OutputStream):
BufferedInputStream(字节缓冲输入流):提升读取字节数据的效率。BufferedOutputStream(字节缓冲输出流):提升写入字节数据的效率。
它们属于处理流,无法直接连接具体的磁盘文件,必须“包裹”在普通的节点流(如 FileInputStream / FileOutputStream)之外才能工作。
API: BufferedInputStream
构造方法
BufferedInputStreamBufferedInputStream():(InputStream in),标准包装构造。创建一个带有默认 8192 字节内部缓冲区的缓冲输入流。BufferedInputStreamBufferedInputStream():(InputStream in, int size),自定义容量构造。根据指定的缓冲区容量size创建一个缓冲输入流。
注意事项:
- 缓冲区大小并不是越大越好。在一般机械磁盘或 SSD 上,8KB 到 16KB 是平衡吞吐量与堆内存开销的黄金分割点。如果面对数 GB 的超大文件读写流,可以手动将其调整为
64KB (65536)或128KB。
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();
}
}
}字节读取
intread():(),从缓冲区读取单字节。从此输入流中读取一个字节。如果缓冲区为空,则先触发底层填充。若到达流末尾返回-1。intread():(byte[] b, int off, int len),片段读取至目标数组。将缓冲区内的字节批量读入到目的字节数组b中,从偏移量off开始,最多读取len个字节。
注意事项:
- 虽然使用了
BufferedInputStream,但依然不推荐频繁在大循环内调用无参的read()。即使没有物理 I/O 损耗,频繁的方法栈帧压栈与指针越界校验依旧会消耗 CPU 性能。最佳实践是使用read(byte[] b, int off, int len)再次进行批量承接。
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();
}
}
}标记与流控制
voidmark():(int readlimit),流位置标记。在此输入流的当前位置放置一个标记。参数readlimit告知此流在标记位置失效之前允许读取的最大字节数。voidreset():(),流位置重置。将当前读取指针重新定位到最后一次对该流调用mark()方法时的位置。booleanmarkSupported():(),测试标记支持。测试此流是否支持mark和reset。对于BufferedInputStream实例而言,恒定返回true。longskip():(long n),跳过指定字节。跳过并丢弃此流中的n个字节,返回实际跳过的字节数。intavailable():(),获取立即可读字节数。返回不需要阻塞即可从此流中读取的剩余字节数的估计值。voidclose():(),关闭流资源。关闭此缓冲流,并无条件级联关闭其内部封装的底层节点流,物理释放系统描述符。
注意事项:
mark(int readlimit)的底层实现极其特殊。如果在mark()之后读取的字节数超出了readlimit,或者超出了BufferedInputStream内部缓冲区的大小,内部会触发buf数组的重新扩容。为防止内存突变或OutOfMemoryError,不要在标记期间读取超长的数据。- 关闭
BufferedInputStream会自动关闭它包裹的底层FileInputStream。在try-with-resources块中,只需声明包裹流即可。
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
构造方法
BufferedOutputStreamBufferedOutputStream():(OutputStream out),标准包装构造。创建一个带有默认 8192 字节内部缓冲区的缓冲输出流,用于包装指定的底层字节输出流。BufferedOutputStreamBufferedOutputStream():(OutputStream out, int size),自定义容量构造。根据指定的缓冲区容量size创建一个缓冲输出流。在超大文件串行写入或高吞吐量网络通信场景中,可手动调大该值以进一步优化 I/O 效率。
注意事项:
- 传入的自定义缓冲区大小
size必须大于 0,否则会抛出IllegalArgumentException。在实际工业级调优中,通常将其设置为底层操作系统文件块(Block Size)的整数倍(如 16384 或 65536 字节)。
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();
}
}
}字节写入与刷新
voidwrite():(int b),写入单字节至缓冲区。将指定的字节(传入参数b的低 8 位)写入内部字节缓冲数组中。数据此时仅滞留在内存中。voidwrite():(byte[] b, int off, int len),片段写入缓冲区。将指定字节数组b中从偏移量off开始的len个字节高速复制到内部缓冲数组中。voidflush():(),强制刷新流缓冲区。强行将内部缓冲区中当前留存的所有字节数据完全写入下层的物理输出流中,并清空缓冲区。voidclose():(),关闭流资源。关闭此缓冲输出流。在物理关闭之前,该方法会自动隐式触发一次flush()操作,随后级联关闭其封装的底层物理流并释放关联的物理句柄。
注意事项:
- 潜在的数据丢失风险:如果通过
write写入的数据总量没有填满 8KB 缓冲区(例如仅写入了 500 个字节),且程序在异常结束前没有显式调用close()或flush(),那么滞留在内存缓冲区中的尾部数据将无法写入磁盘,从而造成物理文件内容的永久残缺。- 级联关闭机制:关闭装饰器流
BufferedOutputStream时,其内部实现会通过finally块确保下层包裹的FileOutputStream被安全关闭。开发中严禁编写双重关闭逻辑,直接关闭最外层的流即可。
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 的标准企业级写法:
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?”
这两者的缓冲维度和优化层次是不同的:
你定义的
byte[] buffer(用户缓冲区):解决的是程序内部循环次数的问题。如果你一次读一个字节,大文件需要循环几百万次;定义了数组,循环次数减少到 1/1024。
缓冲流内部的
buf[](流缓冲区/系统级优化):解决的是减少用户态到内核态的物理 I/O 切换。
即使你在代码中用
read(new byte[1])一次只读一个字节,BufferedInputStream也会自动帮底层的物理磁盘发起 8KB 的批量预读。它保证了无论你的上层代码写得多么糟糕(例如单字节循环读取),底层的磁盘 I/O 依然保持高吞吐量的批量交互。
结论:在实际开发中,“缓冲流” 加上 “自定义 byte[] 数组” 双剑合璧,才能达到最佳的 I/O 性能。
最佳实践
资源关闭的顺序(先开后闭):
由于缓冲流包裹了文件流,关闭时只需要关闭最外层的缓冲流即可。缓冲流的
close()方法内部会自动调用内层节点流的close()。如果手动去挨个关闭,不仅代码冗余,还容易抛出Stream Closed异常。使用try-with-resources结构则无需担心此问题。切记输出流的
flush():由于
BufferedOutputStream是攒满 8KB 才写磁盘,如果数据只有 1KB 且程序没有正常关闭流,数据就会憋在内存里造成丢失。在长连接传输、或者关键数据写入后,应手动调用bos.flush()强制将数据刷入磁盘。不要用它处理中文文本:
虽然字节缓冲流也可以读写
.txt文件,但它毕竟是以字节为单位。如果中文字符在读取时被截断(例如 UTF-8 的 3 字节汉字,前 2 个字节在本次读取中,第 3 个字节在下次中),就会产生乱码。纯文本文件请右转使用字符缓冲流(BufferedReader/BufferedWriter)。
字符缓冲流~
在 Java 的 IO 流体系中,字符缓冲流(Character Buffered Streams) 是专门用来高效处理纯文本文件(如 .txt, .json, .xml, .csv, .java)的高级处理流(也称包装流)。
如果说普通的字符流(FileReader / FileWriter)是徒手一砖一瓦地搬运文本,那么字符缓冲流就是开着一辆带有自动化传送带的翻斗车。它不仅通过内部的内存缓冲区极大提升了读写吞吐量,还带来了诸如“按行读取”、“跨平台换行”等企业级开发中必不可少的“神级功能”。
核心家族成员
字符缓冲流并不直接连接具体的磁盘文件,它们必须依托于底层的普通字符流(节点流)才能工作。整个家族的核心成员只有两个:
BufferedReader(字符缓冲输入流):继承自Reader,用于高效读取文本。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
构造方法
BufferedReaderBufferedReader():(Reader in),标准包装构造。创建一个使用默认大小(8192 个字符)输入缓冲区的缓冲字符输入流,用于包装指定的字符输入流。BufferedReaderBufferedReader():(Reader in, int sz),自定义容量构造。根据指定的缓冲区容量sz创建一个缓冲字符输入流。
注意事项:
- 传入的底层字符流
Reader对象不能为null,否则会引发NullPointerException。- 自定义容量
sz必须大于 0。在日常业务中默认的 8192 字符大小已足够应对绝大多数文本解析场景;但在面对超长文本行(如巨型单行 JSON 报文或混淆后的前端代码文件)时,可适当将容量调大以防止行边界计算引发频繁扩容。
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();
}
}
}核心读取与流控制方法
intread():(),从缓冲区读取单字符。从内部字符缓冲区中读取并返回下一个字符。若缓冲区无数据,则触发底层 Reader 填充。若到达文本末尾(EOF),则返回-1。intread():(char[] cbuf, int off, int len),片段读取至目标数组。将缓冲区内的字符批量复制到目的字符数组cbuf中,从偏移量off开始,最多读取len个字符。StringreadLine():(),高阶文本行读取。读取并返回一个完整的文本行。当读取到换行符(\n)、回车符(\r)或回车换行符(\r\n)时判定当前行结束。若已到达文件流末尾,则返回null。booleanready():(),检查流就绪状态。告知此流是否已准备好被读取。若缓冲区不为空,或者底层字符流已就绪,则返回true。voidclose():(),关闭字符流。关闭此缓冲流,并无条件级联关闭其内部包裹的底层Reader物理流,释放系统文件描述符。
注意事项:
- 核心避坑点:
readLine()方法返回的字符串中绝不包含行终止符(即丢弃了尾部的\n、\r或\r\n)。如果在文本文件复制或网络文本重组的业务中直接使用write(line),会导致最终写入的数据变成无换行的单行文本,必须在写入时手动补充跨平台的换行符。- 使用
try-with-resources结构管理BufferedReader时,只需要显式闭合最外层的包装流。外层流的close()内部具有完善的finally级联释放机制,能够安全地连同底层的物理节点流一并释放。
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 引入)
注意事项:
lines()方法返回的是一个惰性求值(Lazy Evaluation)的Stream。在其上的管道操作(如filter、map)只有在触发终止操作(如forEach、collect)时才会真正驱动BufferedReader进行磁盘字符读取。- 如果在利用
lines()进行流式管道处理的过程中发生IOException,该异常会被自动包装在运行时异常UncheckedIOException中抛出。
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
构造方法
BufferedWriterBufferedWriter():(Writer out),标准包装构造。创建一个使用默认大小(8192 个字符)输出缓冲区的缓冲字符输出流,用于包装指定的字符输出流。BufferedWriterBufferedWriter():(Writer out, int sz),自定义容量构造。根据指定的缓冲区容量sz创建一个缓冲字符输出流。
注意事项:
- 传入的底层字符流
Writer实例不允许为null,否则构造时将直接引发NullPointerException。- 自定义缓冲区大小
sz必须大于 0,否则会抛出IllegalArgumentException。在常规业务中默认的 8192 字符大小已足够高效;但在超大规模批量文本数据生成(如服务端离线导出百万级报文文件)时,可手动调大该值(如 32768 或 65536)以进一步压榨物理磁盘的顺序写入性能。
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();
}
}
}字符与字符串写入
voidwrite():(int c),写入单字符至缓冲区。向内部字符缓冲区写入单个字符。参数c的低 16 位被写入,高 16 位被忽略。voidwrite():(char[] cbuf, int off, int len),片段写入字符数组至缓冲区。从字符数组cbuf的偏移量off开始,连续将长度为len的字符批量复制到内部缓冲区中。voidwrite():(String s, int off, int len),片段写入字符串至缓冲区。从字符串s的偏移量off开始,连续将长度为len的字符批量复制到内部缓冲区中。javaimport 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(); } } }
换行与刷新控制
voidnewLine():(),跨平台换行符写入。向流中写入一个行分隔符。该换行符由系统属性line.separator动态决定,在 Windows 环境下会自动写入\r\n,在 Linux/Unix 环境下会自动写入\n。voidflush():(),冲刷字符流缓冲区。强制将当前滞留在内部字符缓冲区中的所有数据刷出到底层流中,从而触发物理持久化。voidclose():()silent。关闭字符流。关闭此流。在物理关闭前系统会自动隐式先调用一次flush(),随后释放与之关联的操作系统资源,并级联关闭底层物理节点流。
注意事项:
- 核心缺陷防范(丢失数据风险):由于
BufferedWriter存在内部用户态缓冲区,如果向流中写入字符后既没有填满 8192 字符空间,又没有显式执行flush()或close()闭合操作,这部分滞留在堆内存中的字符将永远不会真正落盘,进而导致生成的文件内容残缺、丢失。- 换行标准规范:在处理纯文本文件时,强烈建议放弃使用固定的
\n或\r\n字符串,统一改用newLine()方法。这可以完全消除由于跨平台部署(如 Windows 平台研发,Linux 生产环境运行)因底层换行标识符不一致而导致的文本解析错误、错行问题。
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(),安全闭合流通道
}
}实战:文本复制与过滤
在实际企业级开发中,我们通常将两者结合使用。以下是一个经典的文本备份与数据清洗(过滤)的标准模板:
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 在底层其实是它们的子类。

整个转换流家族只有两个核心类:
InputStreamReader方向:把输入的字节流转为字符流。
本质:解码 (decoding)。它把磁盘或网络传过来的、计算机听得懂的二进制“字节”,翻译成人类看得懂的“文字字符”。
OutputStreamWriter方向:把输出的字符流转为字节流。
本质:编码 (encoding)。它把程序在内存中处理的“文字字符”,打包、翻译成计算机底层用来存储或传输的二进制“字节”。
转换流的作用
在旧版本的 Java 中(Java 11 之前),传统的文本节点流 FileReader 和 FileWriter 有一个巨大的硬伤:它们无法在构造函数中指定字符编码,只能死板地使用操作系统的默认编码。
这就导致了一个灾难性的场景:
一个在 Linux 下用
UTF-8编码保存的文本文件,如果拿到默认编码为GBK的 Windows 电脑上用FileReader直接读取,屏幕上立刻就会喷出满屏的乱码。
转换流就是解决这个问题的终极武器。它在包裹住字节流(如 FileInputStream)的同时,允许你强行指定一个编码格式。
API: InputStreamReader
构造方法
InputStreamReaderInputStreamReader():(InputStream in),构造方法。根据传入的底层字节输入流创建对象,使用平台默认字符集(JDK 18+ 默认统一为 UTF-8)。InputStreamReaderInputStreamReader():(InputStream in, String charsetName),构造方法。根据传入的字节输入流及指定的字符集名称创建对象;若传入不受支持的编码名称则抛出UnsupportedEncodingException。InputStreamReaderInputStreamReader():(InputStream in, Charset cs),构造方法。根据传入的字节输入流及标准Charset对象创建对象。强类型安全,推荐的生产标准构造形式。InputStreamReaderInputStreamReader():(InputStream in, CharsetDecoder dec),构造方法。根据传入的字节输入流及自定义CharsetDecoder解码器创建对象。适用于需要定制畸变或无法映射字节处理策略的场景。
注意事项:
- 传入的底层
InputStream参数不能为null,否则会直接抛出NullPointerException。charsetName字符串参数必须有效;若名称非法会抛出UnsupportedEncodingException,相比强类型的Charset对象更易引发运行时错误。- 无参字符集构造方法在 JDK 18 之前受各平台运行环境默认编码影响,跨操作系统部署易出现解析不一致。
// 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();
}字符数据读取
intread():(),单字符读取。从输入流中读取单个字符。返回解码后的字符对应无符号整数(0 ~ 65535),若到达流的末尾则返回-1。intread():(char[] cbuf, int offset, int length),区间受限字符读取。从输入流中读取最多length个字符并解码存入目标字符数组cbuf的offset偏移位置。返回实际读取的字符数;若已达流末尾则返回-1。
注意事项:
read(char[] cbuf, int offset, int length)返回的是实际解码并存储的字符个数,并非底层读取的字节数。read()返回的是int类型,接收单字符时必须先与-1进行判断,确认非末尾后再强转为(char),否则-1强转为char会变成\uFFFF导致死循环。- 读取操作是同步阻塞的,当底层流数据未就绪且未到 EOF 时,当前线程将被挂起。
// 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);
}状态查询与生命周期管理
StringgetEncoding():(),获取字符编码名称。返回当前流正在使用的字符编码规范历史名称(如UTF8);若流已被关闭则可能返回null。booleanready():(),读取就绪检测。判断当前流是否已准备好进行无阻塞读取。若内部缓冲区非空或底层字节流已就绪则返回true。voidclose():(),关闭输入流。关闭当前字符转换读取流,并级联关闭其包装的底层InputStream字节流,释放底层句柄。
注意事项:
close()方法具备幂等性,重复调用不会抛出异常。流关闭后再次调用read()或ready()将抛出IOException: Stream closed。close()会级联触发底层InputStream的close()。如果不希望包装流关闭底层字节通道(例如复用网络连接),严禁直接调用包装类的close()。ready()返回false并不能保证下一次调用read()一定会阻塞,仅表示当前无法保证无阻塞读取。
// 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(按行缓冲读取)。
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
构造方法
OutputStreamWriterOutputStreamWriter():(OutputStream out),构造方法。使用系统默认字符集(JDK 18+ 默认统一为 UTF-8)创建将字符转换为字节的输出流。OutputStreamWriterOutputStreamWriter():(OutputStream out, String charsetName),构造方法。使用指定的字符集名称创建转换输出流;若传入不受支持的名称则抛出UnsupportedEncodingException。OutputStreamWriterOutputStreamWriter():(OutputStream out, Charset cs),构造方法。使用指定的强类型Charset对象创建转换输出流。类型安全,生产环境推荐使用。OutputStreamWriterOutputStreamWriter():(OutputStream out, CharsetEncoder enc),构造方法。使用自定义配置的CharsetEncoder编码器创建转换输出流。适用于需要精准控制不可映射字符或畸变字符策略的高级场景。
注意事项:
- 传入的底层
OutputStream参数不能为null,否则构造器会直接抛出NullPointerException。charsetName字符串参数必须有效且被 JVM 支持;若名称非法或拼写错误将抛出UnsupportedEncodingException(Checked Exception),推荐优先使用强类型的Charset重载版本。- 无参字符集构造方法在 JDK 18 之前强依赖运行平台的本地默认编码,在跨系统部署时极易产生乱码,应尽量显式声明字符编码。
// 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");
}字符数据写入
voidwrite():(int c),单字符写入。将单个字符写入输出流。传入的整型数值仅低 16 位被写入(强转为char),高 16 位被自动舍弃。voidwrite():(char[] cbuf, int off, int len),字符数组切片写入。将字符数组cbuf中从偏移量off开始的连续len个字符编码并写入底层输出流。voidwrite():(String str, int off, int len),字符串切片写入。将字符串str中从偏移量off开始的连续len个字符编码并写入底层输出流。
注意事项:
write(int c)写入的是 16 位 Unicode 字符而不是单个字节。若该字符在目标编码下由多个字节组成(如中文在 UTF-8 下占 3 字节),底层会自动将其展开并生成多个字节。write(char[] cbuf, int off, int len)与write(String str, int off, int len)会进行严格的数组/字符串边界校验,若off < 0、len < 0或off + len超出源数据边界,将抛出IndexOutOfBoundsException。- 所有
write操作在执行期间均会持有内部lock互斥锁,多线程并发调用同一实例的写入方法具备线程安全性,但会引发线程排队等待。
// 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);缓冲区刷新与生命周期管理
voidflush():(),刷新输出缓冲区。强制将内部编码转换缓冲区(StreamEncoder)中的所有待输出字节刷入挂载的底层OutputStream中。voidclose():(),关闭输出流。首先刷新内部编码缓冲区,随后关闭底层的OutputStream字节流并释放关联的系统句柄。StringgetEncoding():(),获取字符编码名称。返回当前流正在使用的字符编码的历史规范名称;若流已关闭可能返回null。
注意事项:
close()方法具备幂等性,重复调用不会抛出异常。流关闭后再次调用write()或flush()将抛出IOException: Stream closed。close()会级联触发底层OutputStream的关闭。若底层字节流需要复用(例如常驻的网络 Socket 连接或共享的ByteArrayOutputStream),严禁直接关闭外层的OutputStreamWriter,应改用显式flush()。flush()仅能清空StreamEncoder的内部缓冲并提交到底层OutputStream,并不等同于物理介质的强制落盘(fsync)。
// 1. 强制推送编码缓冲区至底层字节流
writer.flush();
// 2. 获取当前流的实际生效编码规范名称
String activeEncoding = writer.getEncoding();
// 3. 关闭外层转换流并级联释放底层字节流句柄
writer.close();继承:Writer
append, append, append, nullWriter, write, write
示例:指定编码写入文件
同样地,写文件时我们也可以强制指定编码,确保不管在什么系统上运行,生下来的文件都是指定的格式。
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)或系统标准流时,转换流依然是唯一的王牌。
例如,获取键盘输入:
// System.in 是一个底层的字节输入流(InputStream)
// 如果想把它升级成可以按行读取的字符流,必须通过转换流连接
BufferedReader keyboardReader = new BufferedReader(new InputStreamReader(System.in));在网络编程中,从服务器获取到的网络流 socket.getInputStream() 也是纯字节流。想要把它当成文本高效处理,同样必须先用 InputStreamReader 翻译成字符流。
总结:转换流避坑口诀
见字如面:看到
Reader就是要把字节解码成文字,看到Writer就是要把文字编码成字节。两端对齐:读取文件时指定的编码,必须与文件原本保存时的编码一模一样,否则必出乱码。
级联关闭:和缓冲流一样,关闭时只需要关闭最外层的
BufferedReader / BufferedWriter即可,内层的转换流和底层文件流会被连带安全关闭。
处理流-对象流
在 Java 的 IO 流世界中,对象流(Object Streams) 是实现对象持久化存储和跨网络传输的终极武器。
如果你想保存一个游戏的复杂地图、玩家的所有状态,或者通过网络把一个完整的内存对象发送给另一台服务器,单纯用数据流(DataInputStream)去一个个读写基本类型就会极其痛苦。对象流允许我们直接将一个完整的 Java 对象当作一个整体进行读写。
实现这一过程的底层技术,就是我们熟知的 序列化(Serialization) 与 反序列化(Deserialization)。
核心概念与工作流
对象流家族主要由两个高级处理流组成:
ObjectOutputStream:负责序列化。将 JVM 内存中的 Java 对象,转换为与平台无关的二进制字节流。ObjectInputStream:负责反序列化。将二进制字节流重新装配、还原为内存中的 Java 对象。

序列化/反序列化~
底层工作原理
当我们在 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:
private static final long serialVersionUID = 1L; // 或者是任意生成的 64 位 Long 值- 必须声明为
static final long,权限则没有要求。 - 要保证值是唯一的。
为什么它如此重要?
当你反序列化时,JVM 会对比文件里保存的 serialVersionUID 与你当前类中的 serialVersionUID 是否一致。
- 如果你没有显式声明:Java 编译器会根据当前类的属性、方法等自动算出一个哈希值。这就意味着,只要你对类进行了一丁点微调(比如改了个变量名,或者加了个空格),自动算出的版本号就会改变。 此时去反序列化旧数据,就会直接抛出
InvalidClassException导致程序崩溃。 - 如果你声明了:即使类结构后续发生了轻微改变(如新增了非关键字段),旧的数据依然可以安全地反序列化回来,极大地提高了兼容性。
善用 transient 关键字
在序列化对象时,有些数据我们不希望被保存。例如:
- 敏感信息:用户的密码、银行卡号等。
- 无关状态/临时对象:数据库连接、线程对象、一些临时的计算缓存。
只要在字段前加上 transient 关键字(意思是“瞬时的/短暂的”),该字段就会被序列化机制自动忽略。反序列化回来时,这些字段会被初始化为其对应类型的默认值(对象为 null,数值为 0,布尔值为 false)。
示例:实现对象的保存与恢复
下面是一个展示如何将一个包含敏感字段和版本控制的 User 对象进行序列化与反序列化。
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
如果你想在序列化前后对某些字段进行加密/解密,或者执行一些特殊的格式转换,你可以重写这两个私有方法:
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 接口
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();
}
}继承关系中的序列化
当类与类之间存在继承关系时,序列化的行为会变得有些微妙:
父类可序列化,子类自动可序列化:
如果父类实现了
Serializable,那么子类无需任何声明,自动获得序列化通行证。父类未序列化,子类强行序列化:
如果父类没有实现
Serializable,而子类实现了。- 序列化时:子类可以正常序列化,但父类拥有的字段不会被写入流中。
- 反序列化时:为了重建父类的状态,JVM 会强制调用父类的无参构造方法来初始化父类字段。如果父类没有无参构造方法,运行时直接抛出
InvalidClassException崩溃。
反序列化的注意事项
作为一名优秀的研发人员,你需要知道 Java 原生反序列化机制的三个冷知识和安全痛点:
反序列化不走构造方法:
当
ObjectInputStream重构一个对象时,它完全不会调用该类的任何构造方法(Constructor)。JVM 是直接通过底层的反射机制,按照字节流里的数据,强行抹平并填满对象的成员变量。这意味着你在构造方法里写的任何安全校验、初始化逻辑,在反序列化时通通失效。反序列化安全漏洞:
Java 原生的反序列化机制在安全界名声极其狼狈。
- 危险原理:在调用
readObject()时,如果传入的二进制字节流是被恶意篡改过的,攻击者可以构造一种特殊的“小工具链(Gadget Chains)”,在 JVM 还没有完全重建完对象之前,就强行触发某些恶意代码的执行。 - 业界共识:绝对不要反序列化任何来自不可信来源(如公网用户上传、未加密的网络包)的二进制对象数据。
- 危险原理:在调用
原生序列化的性能缺陷:
Java 原生的序列化机制虽然简单好用,但它有着难以调和的劣势:
- 体积臃肿:为了保证跨平台和类安全,保存的文件里包含了大量的 Java 类元数据,冗余度高。
- 速度慢:底层重度依赖反射,CPU 消耗大,不适合高并发的网络微服务。
现代替代方案:
在现在的企业级架构(如 Spring Cloud、Dubbo、网络传输)中,我们几乎全面淘汰了 Java 原生的对象流。取而代之的是性能更好、体积更小、更安全的序列化框架:
- 文本流向:
JSON(Jackson / Gson / Fastjson2)- 高性能二进制:
Protobuf(谷歌)、Kryo、Hessian等。
API: ObjectOutputStream
构造方法
ObjectOutputStreamObjectOutputStream():(OutputStream out),构造方法。使用指定的底层字节输出流创建一个 ObjectOutputStream。常用于将序列化元数据绑定到文件、内存数组或网络 Socket 通道。
注意事项:
- 实例化
ObjectOutputStream(OutputStream out)时,构造方法内部会立即向底层流写出 4 字节的 Stream Header(Magic 和 Version)。因此,在网络通信(Socket)场景中,必须先实例化对端的ObjectInputStream或是确保输出流及时flush(),否则如果双端都同步阻塞在未接收到 Header 的状态,将导致分布式死锁。
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();
}
}
}对象与基本类型写入
voidwriteObject():(Object obj),对象图序列化写入。将指定的对象完整状态写入流中。包括该对象的类元数据、非静态与非 transient 属性,以及级联引用的其他对象图。voidwriteInt():(int val),基本类型 int 写入。向流中写入一个 4 字节的 32 位 int 数值。voidwriteBoolean():(boolean val),基本类型 boolean 写入。向流中写入一个 1 字节的 boolean 布尔状态。voidwriteUTF():(String str),修改版 UTF-8 字符串写入。以符合 Java DataOutput 规范的修改版 UTF-8 编码格式写出字符串,前 2 字节用于存储字符串的物理字节长度。voidflush():(),刷新流缓冲区。强行刷新当前输出流,将内部可能滞留的所有缓冲字节强制推入到底层关联的物理流中。voidclose():(),释放并关闭流资源。关闭此对象输出流,并自动隐式级联关闭下层绑定的物理流句柄。
注意事项:
- 传入
writeObject()的对象如果未实现Serializable接口,运行时将无条件抛出NotSerializableException异常。- 被
transient关键字修饰的成员变量属于敏感或临时变量,其状态在序列化时会被彻底忽略(数值型变量反序列化后恢复默认值0,对象型恢复为null)。- 同一引用多次写入机制:
ObjectOutputStream内部维护着一个引用哈希表。如果将同一个对象实例连续写出多次,它在第二次及以后写出时只会写出该对象的句柄编号(Handle)而非真实属性图。因此,如果在写出对象后修改了其属性再次调用writeObject(),修改后的属性不会被重新写出。
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();
}
}
}高级序列化定制与自定义
voiddefaultWriteObject():(),执行默认序列化行为。将当前类的非静态和非 transient 字段写入流。该方法只能在类内部重写的自定义writeObject(ObjectOutputStream out)方法中被显式调用。ObjectOutputStream.PutFieldputFields():(),获取自定制字段缓冲对象。获取一个用于专门缓冲要写入流中的持久字段的对象,允许开发人员完全脱离原有属性结构的物理限制。voidwriteFields():(),物理写出自定制字段。将通过putFields()方法积攒在PutField缓冲器中的字段值物理写出到对象输出流中。voidreset():(),重置流缓存表。清空流内部维护的所有写出对象的引用哈希缓存表。重置后再次写出的对象将被视作全新的实体重新全量输出。
注意事项:
- 如果在大批次、长连接的流或密集循环中频繁通过同一包装流写出数据(如网络长连接),由于内部哈希表对所有写出的对象强引用,会导致这些对象无法被 JVM GC 垃圾回收,进而诱发灾难性的
OutOfMemoryError内存溢出。在此种场景下,必须在合适的业务周期内定期调用reset()方法释放流缓存。
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
构造方法
ObjectInputStreamObjectInputStream():(InputStream in),构造方法。创建一个向指定底层字节输入流读取数据的 ObjectInputStream。用于从物理文件、内存数组或 Socket 接收的数据中恢复 Java 对象。
注意事项:
ObjectInputStream的构造方法在执行时会阻塞并立即读取底层流中的前 4 字节协议头。如果配对的ObjectOutputStream尚未创建或尚未刷写(flush())物理数据,此构造方法将无条件阻塞,并在网络 Socket 通信中极易诱发分布式死锁。
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();
}
}
}对象与基本类型读取
ObjectreadObject():(),读取对象图。从流中读取并重建一个完整的 Java 对象实体。intreadInt():(),读取基本类型 int。从流中连续读取 4 个字节,恢复为一个 32 位的 int 数值。booleanreadBoolean():(),读取基本类型 boolean。从流中读取 1 个字节,恢复为一个 boolean 值。StringreadUTF():(),读取修改版 UTF-8 字符串。读取符合 Java DataInput 规范的修改版 UTF-8 编码字符串。voidclose():(),关闭输入流。释放此对象流关联的所有系统资源,并隐式级联关闭下层绑定的物理节点流。
注意事项:
readObject()返回的类型是统一的Object,上层业务必须进行显式的向下类型转换(Downcasting)。若本地类路径中缺少该类的类定义文件,会抛出检查型异常ClassNotFoundException。- 反序列化时,成员变量恢复的顺序和写入时的调用顺序必须严格对齐。如果写入时先写
int再写String,读取时若先调用readUTF()再调用readInt(),将引发二进制结构错乱,导致抛出OptionalDataException或StreamCorruptedException。
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());
}
}
}反序列化控制与深度定制
voiddefaultReadObject():(),执行默认反序列化。从流中读取当前类的非静态和非 transient 字段。该方法只能在可序列化类内部重写的私有readObject(ObjectInputStream in)方法中被级联调用。ObjectInputStream.GetFieldreadFields():(),读取持久化字段组。从流中提取所有命名的持久字段,并将其封装进一个GetField对象中。用于脱离类物理属性结构的限制,实现高阶的手动定制化解码恢复。Class<?>resolveClass():(ObjectStreamClass desc),解析类元数据对象。子类通过重写此方法,可以自定义本地类文件的寻找和加载逻辑。常用于跨类加载器或网络动态加载类的场景。
注意事项:
- 反序列化绕过构造方法风险:反序列化在恢复实现了
Serializable接口的类实例时,不会执行该类的任何实例构造方法(包括无参构造方法)和实例初始化块。它仅通过底层反射直接还原内存状态。但如果该类的父类没有实现Serializable接口,则反序列化时无条件会触发该非序列化父类的无参构造方法。- 被
transient关键字修饰的属性在反序列化后,其值会自动初始化为对应类型的默认初始值(数字型变量恢复为0/0.0,布尔型恢复为false,引用对象类型统一恢复为null)。
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()方法:javaprivate Object readResolve() { return INSTANCE; // 直接返回已有的单例实例,抛弃反序列化新生成的那个 }
JVM 会在反序列化完成前自动调用该方法,并用它的返回值替换掉刚刚重构出的新对象。