9.Java文件与IO流

Java 文件与 IO 流:路径、字节、字符、缓冲与对象序列化
本篇目标:区分文件路径与数据流,按数据类型选择字节或字符 API,明确字符集,安全关闭资源,并了解对象序列化的边界。
版本约定:使用 JDK 17 / 21 的常用 API。
Files.readString()、Files.writeString()自 Java 11 起可用。注明文件名的程序可独立运行,其他 Java 片段放入main方法;涉及 I/O 的片段需让main声明throws IOException或捕获该异常,导入语句放在类声明之前。文件示例在临时目录中创建和清理自己的数据。
1. 输入、输出、字节与字符
站在程序角度:输入是程序读取数据,输出是程序写出数据。来源和去向可以是文件、内存、网络或其他程序,不局限于磁盘文件。
| 维度 | 主要类型 | 选择依据 |
|---|---|---|
| 文件或路径 | Path、File |
表示位置和文件系统操作,不负责传输文件内容 |
| 字节输入、输出 | InputStream、OutputStream |
图像、压缩包、PDF、任意二进制格式及原始字节 |
| 字符输入、输出 | Reader、Writer |
已明确字符集的文本 |
| 缓冲与编码转换 | BufferedInputStream、BufferedReader、InputStreamReader 等 |
为字节流或字符流添加功能 |
字节流按照字节读写,字符流按照 UTF-16 代码单元读写;存储文本时仍须把字符编码成字节,例如 UTF-8。读写文件必须明确双方使用的字符集。
这里的流通常是顺序处理数据的通道,不能把它直接理解为用于排队的 FIFO 容器。java.io 的数据流也不同于集合处理中的 java.util.stream.Stream,二者有不同的用途与生命周期。
“节点流与处理流”是理解装饰器式包装的一种传统分类,不是所有流类型唯一且互斥的分类。选择 API 时,优先根据数据语义、编码和资源所有权来判断。
2. Path 与 File:表示路径,不等于打开文件
File 是旧 API 中的抽象路径名,可以表示尚不存在的路径;创建 File 对象不会创建磁盘文件。新代码通常优先使用 Path 与 Files,仍可能需要与接受 File 的旧 API 互操作。
File / Path 操作 |
说明与限制 |
|---|---|
new File(path) / Path.of(path) |
创建路径表示,不检查也不创建对应实体 |
exists() / Files.exists(path) |
检查当时能否观察到路径,结果可能因并发或权限变化而过期 |
isFile()、isDirectory() |
查询类型;并不是所有错误或权限情况都能由简单布尔结果区分 |
length() / Files.size(path) |
查询文件大小;目录长度没有通用的文件内容含义 |
mkdir() / Files.createDirectory(path) |
创建单级目录 |
mkdirs() / Files.createDirectories(path) |
创建尚不存在的父目录 |
createNewFile() / Files.createFile(path) |
原子地尝试新建文件;已存在时失败或返回 false |
delete() / Files.delete(path) |
删除操作会改变文件系统,需处理失败和并发情况 |
renameTo(dest) |
File 的旧重命名接口,结果受平台和文件系统限制 |
Files.move(source, dest, ...) |
新 API 的移动操作,可选择合适选项;跨文件系统行为需留意 |
File.getName()、getParent()、getPath() 分别返回名称、父路径文本和路径文本;getParentFile() 返回父路径对应的 File,没有父路径部分时可能为 null。Path 提供 getFileName()、getParent() 和 resolve() 等操作。可用 file.toPath()、path.toFile() 与旧 API 对接,后者仅适用于受支持的文件系统 provider。
对于 File,方法是 mkdir()、mkdirs(),不存在 mkdit()。创建 Path 也不等于创建目录;创建前需要决定目录存在时的行为和错误策略。
2.1 使用临时目录运行文件示例
保存为 FilePathDemo.java:
java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FilePathDemo {
public static void main(String[] args) throws IOException {
Path directory = Files.createTempDirectory("java-io-demo-");
Path file = directory.resolve("note.txt");
try {
System.out.println(Files.exists(file)); // false:resolve 不会创建文件。
Files.createFile(file);
System.out.println(Files.isRegularFile(file)); // true
System.out.println(file.getFileName()); // note.txt
System.out.println(Files.size(file)); // 0
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(directory);
}
}
}
Files.createTempDirectory() 在系统临时目录下创建目录。示例只清理由自己创建的文件和目录,并用 finally 覆盖读写失败的路径;清理操作本身仍可能失败,业务项目应按异常篇的原则保留主异常和清理失败的信息。File.delete() 失败时只返回 false,不能把调用执行过理解为删除成功。
相对路径从进程的当前工作目录解析,通常会受 IDEA、命令行或测试运行配置影响。不要硬编码某位开发者机器上的 D:\... 绝对路径。
3. 字节流:按实际读取的字节处理
InputStream.read() 每次返回 0~255 的下一个字节值;到达流末尾返回 -1。它不会用 0 表示文件结束,零是有效字节。
缓冲区读取 read(byte[], offset, length) 返回本次实际读取的字节数,不足指定数量也可能正常返回;返回 -1 才表示已经到达末尾。未写入的缓冲区剩余位置保留旧值或默认值,不应全部处理。
java
import java.io.ByteArrayInputStream;
import java.io.IOException;
byte[] source = {0, 65, (byte) 255};
try (ByteArrayInputStream input = new ByteArrayInputStream(source)) {
int value;
while ((value = input.read()) != -1) {
System.out.println(value);
}
}
// 输出 0、65、255。
3.1 批量读取字节
java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
byte[] source = {0, 1, 2, (byte) 255};
byte[] buffer = new byte[2];
try (ByteArrayInputStream input = new ByteArrayInputStream(source);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
System.out.println(java.util.Arrays.equals(source, output.toByteArray())); // true
}
处理每次返回的 count 个字节。缓冲区大小影响性能,但不改变流结束条件。
对于长度大于 0 的标准阻塞输入流读取,未结束时应返回至少一个字节;请求长度为 0 时可以返回 0。不要假设每次 read(buffer) 都填满数组,尤其是网络输入或包装过的流。
3.2 指定偏移:只读取被写入的区间
java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Arrays;
byte[] buffer = new byte[8];
try (ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{65, 66, 67})) {
int count = input.read(buffer, 2, 5); // 请求最多 5 个字节,实际只有 3 个。
System.out.println(count); // 3
System.out.println(Arrays.toString(Arrays.copyOfRange(buffer, 2, 2 + count)));
// [65, 66, 67]
}
有效数据位于 buffer[2] 到 buffer[2 + count - 1]。如果返回 -1,就没有可处理的数据;不能继续用这个值创建长度为负的字符串或数组区间。普通循环读取可参考上一节,单次调用不代表读完全部内容。
InputStream.available() 只估计“不阻塞即可读取”的字节数量,不等于文件剩余总字节数,也不能用 while (available() > 0) 作为读取到流末尾的通用方式。
调用读取方法可能抛出 IOException;文件可能比内存大,实际程序应分块处理而不是默认一次全部加载到数组。
4. 字节输出流:写入的是字节值
OutputStream.write(int value) 写入参数的低 8 位,也就是 value & 0xff 所表示的字节;它不是写入一个 Java int 的四个字节。
java
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
output.write(new byte[]{0, 65, (byte) 255});
byte[] result = output.toByteArray();
System.out.println(Arrays.toString(result)); // [0, 65, -1]
}
flush() 让流按 API 语义把已缓冲数据传递到下一层;不等于保证数据已经物理写入磁盘。关闭输出流通常会先完成其必要刷新,并释放资源。
不要对文本简单地逐字节强转 char。多字节编码要由字符解码器正确处理。
5. 文件复制:循环到 -1,覆盖策略要明确
使用 read() > 0 判断结束是错误的:值为 0 的字节是有效数据,会让复制提前停止。应判断 != -1,或使用 Files.copy() 等合适 API。
5.1 缓冲流复制二进制文件
保存为 CopyFileDemo.java。示例明确覆盖已存在的目标文件,输入与输出不同路径:
java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
public class CopyFileDemo {
static void copy(Path source, Path target) throws IOException {
if (source.toAbsolutePath().normalize().equals(target.toAbsolutePath().normalize())
|| (Files.exists(target) && Files.isSameFile(source, target))) {
throw new IllegalArgumentException("源文件和目标文件不能相同");
}
Path parent = target.toAbsolutePath().normalize().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try (BufferedInputStream input = new BufferedInputStream(Files.newInputStream(source));
BufferedOutputStream output = new BufferedOutputStream(
Files.newOutputStream(target, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE))) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
}
}
public static void main(String[] args) throws IOException {
Path directory = Files.createTempDirectory("java-copy-demo-");
Path source = directory.resolve("source.bin");
Path target = directory.resolve("nested/target.bin");
byte[] data = {0, 1, 127, (byte) 128, (byte) 255};
try {
Files.write(source, data);
copy(source, target);
System.out.println(Arrays.equals(data, Files.readAllBytes(target))); // true
} finally {
Files.deleteIfExists(target);
Files.deleteIfExists(target.getParent());
Files.deleteIfExists(source);
Files.deleteIfExists(directory);
}
}
}
更简短的复制可以使用 Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING)。它适合常见复制任务;需要自定义流处理、校验、进度或变换时再写循环。
打开普通文件输出流时,目标可能立即被截断。不同路径也可能通过硬链接、符号链接等指向同一个文件,因此示例额外使用 Files.isSameFile() 检查已有目标。检查与打开之间仍可能被其他程序改变文件,示例不承诺对并发文件系统替换提供原子保护。
真实程序还须定义遇到部分失败时是否删除不完整目标,以及如何处理符号链接、权限和并发替换。
6. 字符流与字符集:字节转换成字符的明确边界
Reader.read() 返回一个 UTF-16 代码单元的数值,范围为 0~65535,到达流结束返回 -1。它不是“每个 Unicode 字符都对应一个 char”:补充平面字符可能使用一对代理代码单元。
英文、中文等字符需要多少字节取决于编码;例如 UTF-8 中字符所需字节数可不同。不能笼统地说“中文固定三个字节”。
FileReader 和 FileWriter 默认使用 Java 运行环境的默认字符集,这会造成不同机器之间文本损坏风险。需要协议或稳定文件格式时,指定 StandardCharsets.UTF_8。
保存为 TextEncodingDemo.java,以 UTF-8 写入和读取:
java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class TextEncodingDemo {
public static void main(String[] args) throws IOException {
Path directory = Files.createTempDirectory("java-text-demo-");
Path file = directory.resolve("message.txt");
String text = "Hello,世界😀";
try {
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
writer.write(text);
writer.newLine();
writer.write("第二行");
}
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(directory);
}
}
}
输出为 Hello,世界😀 和 第二行。readLine() 返回的行不包含换行终止符,文件到末尾时返回 null。读取字节后用 new String(bytes) 也应显式指定字符集。
6.1 常用 Reader / Writer 操作
| 操作 | 含义 |
|---|---|
reader.read() |
返回一个代码单元或 -1 |
reader.read(char[], off, len) |
返回本次实际读取的代码单元数量,只处理对应区间 |
writer.write(int) |
写入低 16 位表示的代码单元,不是自动写出数字的十进制文本 |
writer.write(char[], off, len) |
写入字符数组中的指定长度 |
writer.write(String) |
写入字符串内容;普通 Writer 已有此方法,并非缓冲流独有 |
writer.write(String, off, len) |
第三个参数是长度,不是结束下标 |
writer.flush() / close() |
刷新 / 关闭,按具体实现的约定处理底层资源 |
以下内存字符流示例不涉及文件编码,展示小缓冲区多次读取:
java
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
try (StringReader reader = new StringReader("你好😀");
StringWriter writer = new StringWriter()) {
char[] buffer = new char[2];
int count;
while ((count = reader.read(buffer)) != -1) {
writer.write(buffer, 0, count);
}
System.out.println(writer.toString()); // 你好😀
System.out.println(writer.toString().length()); // 4 个 UTF-16 代码单元。
}
字符块不一定在完整 Unicode 代码点或字形簇边界结束;若对每块独立分析字符,需要保留跨块状态。单纯按序写回这些代码单元则不需要把每一块当成独立字符串去解释。
7. 转换流:在字节流和字符流之间指定编码
InputStreamReader 将输入字节解码为字符,OutputStreamWriter 将输出字符编码为字节。两个方向不要弄反。下面保存为 CharsetBridgeDemo.java:
java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class CharsetBridgeDemo {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("java-conversion-", ".txt");
try {
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(Files.newOutputStream(file), StandardCharsets.UTF_8))) {
writer.write("字节经 UTF-8 编码后保存");
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8))) {
System.out.println(reader.readLine()); // 字节经 UTF-8 编码后保存
}
} finally {
Files.deleteIfExists(file);
}
}
}
外层缓冲包装器拥有并关闭其底层流;只关闭最外层即可。若底层流还有其他使用者,不要提前关闭它。
遇到格式要求严格的外部数据时,可进一步配置 CharsetDecoder 的错误处理规则,避免错误字节静默替换后再进入业务。
7.1 非法编码:明确拒绝还是替换
使用字符集构造的 InputStreamReader 遇到错误输入时可能进行替换;需要拒绝时,可以传入设置为 CodingErrorAction.REPORT 的解码器。保存为 StrictDecodingDemo.java:
java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
public class StrictDecodingDemo {
public static void main(String[] args) throws IOException {
byte[] invalidUtf8 = {(byte) 0xc3, 0x28};
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try (InputStreamReader reader = new InputStreamReader(
new ByteArrayInputStream(invalidUtf8), decoder)) {
while (reader.read() != -1) {
// 读取过程中会拒绝非法 UTF-8 序列。
}
} catch (CharacterCodingException e) {
System.out.println("输入不是有效 UTF-8");
}
}
}
解码失败属于 I/O 处理中的一种失败。字符编码错误不应靠“强转为 char”修复,也不应无条件丢弃错误字节继续当作合法业务数据。
8. 缓冲流:提高访问效率,不改变结束条件
缓冲包装器暂存一批数据,减少小块 I/O 调用。它不改变文件内容的字节或字符语义,也不保证每一种场景都自动更快。
| 流 | 用途 | 常见方法 |
|---|---|---|
BufferedInputStream |
缓冲字节输入 | read()、read(byte[]) |
BufferedOutputStream |
缓冲字节输出 | write(...)、flush() |
BufferedReader |
缓冲字符输入 | read()、read(char[])、readLine() |
BufferedWriter |
缓冲字符输出 | write(...)、newLine()、flush() |
新建带缓冲文件读写,优先考虑 Files.newBufferedReader(path, charset)、Files.newBufferedWriter(path, charset)。自定义转换流时也可以在 InputStreamReader / OutputStreamWriter 外层再包装缓冲流。
只关闭最外层资源:关闭 BufferedInputStream 会关闭它包装的输入流,反复关闭底层和外层通常没有必要。使用 try-with-resources 使资源在成功、异常和提前返回等路径上都能按约定关闭。
如果只需要对不大的文件一次性读写,可使用 Files.readString()、Files.writeString();这些便捷方法不适合无界读取大型文件,因为内容会进入内存。
9. 文本文件复制与追加模式
复制文本时,可用字符流解码再编码,前提是明确文本字符集;若只想原样复制文件字节,则使用字节流或 Files.copy(),避免不必要的解码再编码。
Files.newBufferedWriter() 未指定追加选项时,常用行为是创建或截断目标文件;追加应明确指定 StandardOpenOption.CREATE 与 APPEND。覆盖、创建、追加和“必须新建”是不同策略,先选好再打开输出流。
保存为 AppendTextDemo.java,初次写入后再追加:
java
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class AppendTextDemo {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("java-append-", ".txt");
try {
Files.writeString(file, "第一条\n", StandardCharsets.UTF_8);
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
writer.write("第二条\n");
}
System.out.print(Files.readString(file, StandardCharsets.UTF_8));
// 两行依次为第一条、第二条。
} finally {
Files.deleteIfExists(file);
}
}
}
本例明确使用 \n 作为文件格式的换行符;BufferedWriter.newLine() 则使用平台的行分隔符。通过 readLine() 再 newLine() 复制文本,会丢失原始换行风格以及末尾是否有换行的信息;要求字节完全一致时用字节复制。
FileOutputStream(file, true)、FileWriter(file, charset, true) 也可以追加;默认构造的普通文件输出流通常会截断原内容。追加并不保证多个线程或进程的整条日志记录自动以原子方式写入,应按并发需求设计。
当写入失败时,目标文件可能已经是部分内容。重要文件可先写入同一文件系统上的临时文件,完成并校验后再尝试原子移动替换;原子移动能力由文件系统与 provider 决定,不能对所有平台一概保证。
10. 序列化:对象格式、兼容性与信任边界
实现 Serializable 的类可使用 ObjectOutputStream / ObjectInputStream 执行 Java 原生对象序列化。Serializable 是标记接口,不提供 write() 方法。
对象图中的可达对象也必须可序列化;否则写入时可能抛出 NotSerializableException。默认序列化会持久化非 static、非 transient 实例字段;transient 字段不会按默认机制保存,反序列化后通常取默认值,敏感数据需要专门设计处理方式。
serialVersionUID 用于序列化版本兼容检查。显式声明可避免编译器从类结构生成的值意外变化,但字段或对象格式演进仍需测试并设计。
对于普通 Serializable 类,反序列化通常不调用该类自己的构造器,而会涉及第一个不可序列化父类的可访问无参构造器。构造器中的校验不会因此自动重做,不能仅凭构造器校验就信任恢复后的状态;外部格式也并非 JSON 等跨语言数据格式。
保存为 SerializationDemo.java:
java
import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
public class SerializationDemo {
static final class User implements Serializable {
private static final long serialVersionUID = 1L;
private final int id;
private final String name;
private final transient String token;
User(int id, String name, String token) {
this.id = id;
this.name = name;
this.token = token;
}
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "', hasToken=" + (token != null) + "}";
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
Path file = Files.createTempFile("java-serialization-", ".bin");
User original = new User(1, "Alice", "do-not-serialize");
try {
try (ObjectOutputStream output = new ObjectOutputStream(Files.newOutputStream(file))) {
output.writeObject(original);
}
User restored;
try (ObjectInputStream input = new ObjectInputStream(Files.newInputStream(file))) {
input.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"maxdepth=10;maxrefs=100;maxbytes=4096;maxarray=100;SerializationDemo$User;!*"));
Object value = input.readObject();
if (!(value instanceof User)) {
throw new IOException("序列化数据不是 User");
}
restored = (User) value;
if (restored.id <= 0 || restored.name == null || restored.name.isBlank()
|| restored.name.length() > 100) {
throw new IOException("User 字段不符合约定");
}
}
System.out.println(original); // User{id=1, name='Alice', hasToken=true}
System.out.println(restored); // User{id=1, name='Alice', hasToken=false}
} finally {
Files.deleteIfExists(file);
}
}
}
输出不包含 token 的具体内容,只展示恢复前后是否存在。默认序列化跳过了 transient 字段,恢复后它是 null;这不是加密,也不能替代整个敏感数据处理方案。
过滤规则只放行本例的 User 类,并设置资源限制;Java 原生序列化中的具体字符串值不通过该类型过滤器逐个检查,所以示例在恢复后还检查字段。流过滤器不是输入文件大小的全面保证,也不能代替业务验证或限制传入数据总量。
真实应用应按对象图精确设计类型允许列表,不能简单放行整个 JDK 模块或任意类。即便加过滤器,反序列化不可信数据仍具有风险,应优先避免 Java 原生反序列化,使用有明确模式并经过校验的数据协议。
反序列化接收的文件格式是二进制数据,即使扩展名是 .txt,也不能当普通文本打开阅读。若类或字段结构版本变化,应有明确的兼容性策略。
11. 复习要点
File与Path表示路径;仅创建路径对象不会新建文件。- 字节读取到达末尾返回
-1,零字节是有效数据;缓冲区只处理实际读到的长度。 available()不是流结束判断,不能用read() > 0作为结束条件。- 文本读取与写入应明确字符集;UTF-8 中一个字符所占字节数可以不同。
- 字节流适合原样处理二进制;字符流需要正确解码和编码文本。
InputStreamReader负责字节到字符,OutputStreamWriter负责字符到字节。- 缓冲流减少小块 I/O 的开销,但不会改变输入流语义;只关闭最外层资源。
- 复制时定义目标覆盖策略,检查源目标是否相同,并考虑部分失败后的清理。
- 原生序列化依赖
Serializable、对象图兼容和类型过滤,不可信输入不应直接反序列化。

评论