Java IO 进阶:基本类型 IO、打印流与对象序列化实用指南

1 🎮 问题场景:从保存游戏分数说起

假设你正在开发一个本地小游戏,需要把玩家的最高分(int)、胜率(double)和昵称(String)保存到文件,下次启动时读回来。用前面学过的 FileWriter 写文本当然可以,但你需要手动处理类型转换——写的时候 intString,读的时候 Stringint,格式稍微不一致就解析失败。

有没有办法直接把 int 按固定 4 字节的二进制格式写入,读的时候也按 int 原样读出?这就是 基本类型 IO 要解决的问题。

更进一步,如果整个游戏状态是一个复杂的 Java 对象(玩家信息、关卡进度、道具列表),能不能 一键保存整个对象,一键读回 ?这就是 对象序列化 要解决的问题。

本篇覆盖 Java IO 的第四个进阶阶段,依次讲解三种机制:

机制用途一句话描述
DataInputStream / DataOutputStream读写基本类型和字符串按固定字节数的二进制格式读写,必须按序操作
PrintStream / PrintWriter格式化文本输出不抛异常,日常 System.out.println() 就在用它
ObjectInputStream / ObjectOutputStream对象序列化与反序列化一键将整个对象转为字节流保存或传输

2 💾 基本类型 IO:DataOutputStream / DataInputStream

2.1 ❓ 是什么

DataOutputStreamDataInputStream装饰器流 (包装已有的 OutputStream / InputStream),提供直接读写 Java 基本类型和 String 的能力。数据以 平台无关的二进制格式 写入——比如 writeInt(42) 始终写 4 个字节,无论在什么操作系统上,读出来的值都不变。

核心约束 :读的顺序必须和写的顺序完全一致。写的时候是 int → double → String,读的时候也必须是 int → double → String。因为 文件中没有类型标记——全是二进制字节,读的 API 只是机械地按指定的字节数解析。如果用 readDouble() 去读一个用 writeInt() 写入的 4 字节数据,会读到错误的数值。

2.2 🛠️ 怎么用

写入基本类型与字符串

try (DataOutputStream dos = new DataOutputStream(
        new FileOutputStream("data.bin"))) {
    dos.writeInt(100);           // int: 4 字节
    dos.writeDouble(3.14);       // double: 8 字节
    dos.writeBoolean(true);      // boolean: 1 字节
    dos.writeUTF("hello");       // String: 变长编码(前 2 字节为长度)
}

读回(必须按写入顺序)

try (DataInputStream dis = new DataInputStream(
        new FileInputStream("data.bin"))) {
    int value = dis.readInt();        // 先读 int
    double pi = dis.readDouble();     // 再读 double
    boolean flag = dis.readBoolean(); // 再读 boolean
    String text = dis.readUTF();      // 最后读 String
    // value=100, pi=3.14, flag=true, text="hello"
}

常见错误 :如果写入顺序是 writeInt → writeDouble → writeUTF,读取顺序却是 readUTF → readInt → readDouble,不会抛异常,但读出的数据是乱码——因为 readUTFint 的前 2 字节当作字符串长度来解析。

2.3 📋 常用方法一览

方法写入/读取类型占用字节数
writeInt() / readInt()int4 字节
writeLong() / readLong()long8 字节
writeDouble() / readDouble()double8 字节
writeFloat() / readFloat()float4 字节
writeShort() / readShort()short2 字节
writeByte() / readByte()byte1 字节
writeBoolean() / readBoolean()boolean1 字节
writeChar() / readChar()char2 字节
writeUTF() / readUTF()String变长(前 2 字节为 UTF-8 编码的字节长度)

2.4 🏗️ 实际开发中的应用场景

  • 网络协议 :自定义二进制协议时,协议头可以用 DataOutputStream 写入固定字节数的字段(消息长度、类型标识),接收端用 DataInputStream 按约定顺序解析
  • 游戏存档 :分数、坐标、生命值等数值直接以二进制形式存储,比纯文本更紧凑
  • 跨语言数据交换的底层:虽然上层常用 Protobuf、Thrift 等框架,但它们本质上也是把数据序列化为一种平台无关的二进制格式,理解 DataInputStream / DataOutputStream 有助于理解这些框架的底层原理

3 🖨️ 打印流:PrintStream / PrintWriter

3.1 ❓ 是什么

打印流是 Java 中最常用的输出流之一。它的核心特征是 不抛 IOException ——所有写操作在内部捕获异常,出错了静默处理,你可以通过 checkError() 方法检查是否发生过错误。

Java 提供两个版本:

面向典型实例
PrintStream字节流(OutputStream 的子类)System.outSystem.err
PrintWriter字符流(Writer 的子类)response.getWriter()(Servlet)、new PrintWriter(file)

System.out 就是一个全局的 PrintStream 实例,所以你在任何地方写 System.out.println() 都不需要处理异常——这正是打印流的设计目的:让日常输出变得简单。

3.2 🛠️ 怎么用

PrintStream(字节流版本)

// System.out 就是 PrintStream,日常最常用的输出方式
System.out.println("Hello");          // 输出并换行
System.out.print("no newline");       // 只输出,不换行
System.out.printf("value=%d\n", 42);  // 格式化输出(类似 C 的 printf)

// 也可以自己创建,包装文件
try (PrintStream ps = new PrintStream("log.txt")) {
    ps.println("第一条日志");
    ps.printf("用户ID: %d, 姓名: %s\n", 1001, "张三");
    // 不需要 try-catch IOException
}

PrintWriter(字符流版本)

// 包装文件输出
try (PrintWriter pw = new PrintWriter("output.txt")) {
    pw.println("Hello World");
    pw.printf("pi = %.2f\n", 3.14159);
}

// Servlet 场景
// PrintWriter writer = response.getWriter();
// writer.println("<html>...</html>");

3.3 🔄 关键行为:自动刷新(autoFlush)

构造 PrintStreamPrintWriter 时,可以传入第二个参数 autoFlush 设为 true

PrintWriter pw = new PrintWriter(
    new FileWriter("log.txt"), true  // autoFlush=true
);

autoFlushtrue 时,每次调用 println()printf()format() 后自动执行 flush(),确保数据立即写入底层设备。这在实时日志场景中很重要——如果缓冲区的数据因程序崩溃而没来得及 flush,日志就丢了。

3.4 ⚠️ 错误处理:checkError()

因为 print() / println() 等方法不抛异常,你无法用 try-catch 知道到底有没有写成功。这时用 checkError() 查询状态:

PrintWriter pw = new PrintWriter("maybe_fail.txt");
pw.println("some data");
if (pw.checkError()) {
    // 之前某次写操作失败了(比如磁盘已满)
    System.err.println("写入失败!");
}

checkError() 返回 true 的条件:内部 IOException 被捕获后,流会设置一个内部错误标志,checkError() 就是读这个标志。 注意 :一旦出错,这个标志不会被清除,后续的写入操作也无法恢复。

3.5 📊 PrintStream vs PrintWriter 对比

特性PrintStreamPrintWriter
继承体系继承 OutputStream(字节流)继承 Writer(字符流)
编码处理使用平台默认编码,可能跨平台不一致可指定字符编码
println(String) 内部实现将字符串转为字节数组后写入直接写入字符
国际化的场景需要额外处理编码推荐使用,编码可控
典型实例System.outnew PrintWriter(response.getWriter())

选型建议 :写文本文件或涉及字符编码的场景,优先用 PrintWriter,因为可以显式指定编码。控制台输出直接用 System.out 即可。

4 📦 对象序列化:ObjectOutputStream / ObjectInputStream

4.1 ❓ 是什么

序列化 (Serialization)是把一个 Java 对象的状态(即它的所有字段值)转换为字节序列的过程。反序列化 (Deserialization)则是把这个字节序列重新还原为内存中的 Java 对象。

一句话理解:序列化就是 “把对象存到硬盘或通过网络发出去” ,反序列化就是 “从硬盘或网络把对象读回来”

这有什么用?

  • 持久化 :把对象保存到文件,程序重启后读回,恢复状态
  • 网络传输 :在 RMI(远程方法调用)、早期的 EJB 中,对象通过网络传来传去,底层就是序列化
  • 深拷贝 :把一个对象序列化再立刻反序列化,得到的是一个内容相同但引用独立的全新对象
  • Session 持久化 :Tomcat 等容器在关闭时会序列化 Session 中的对象,启动时反序列化恢复

4.2 🛠️ 怎么用

第一步:让类实现 Serializable 接口

Serializable 是一个 标记接口 (没有任何方法)。它只是告诉 JVM:“这个类的对象可以被序列化”。不实现这个接口就调用 writeObject(),JVM 直接抛 NotSerializableException

class Player implements java.io.Serializable {
    // serialVersionUID 强烈建议显式定义(原因见下节)
    private static final long serialVersionUID = 1L;

    private String name;
    private int score;
    private transient String password;  // transient 跳过序列化

    public Player(String name, int score, String password) {
        this.name = name;
        this.score = score;
        this.password = password;
    }

    @Override
    public String toString() {
        return "Player{name='" + name + "', score=" + score
                + ", password='" + password + "'}";
    }
}

第二步:用 ObjectOutputStream.writeObject() 序列化

Player player = new Player("张三", 9999, "secret123");

try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("player.ser"))) {
    oos.writeObject(player);  // 一键保存整个对象
}

第三步:用 ObjectInputStream.readObject() 反序列化

try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("player.ser"))) {
    Player restored = (Player) ois.readObject();  // 返回值是 Object,需要强转
    System.out.println(restored);
    // Player{name='张三', score=9999, password='null'}
    // password 是 null,因为被 transient 跳过了
}

4.3 🔑 serialVersionUID:版本控制的关键

serialVersionUID 是序列化机制中的 版本号。JVM 在反序列化时,会比较字节流中的 serialVersionUID 和当前类的 serialVersionUID 是否一致:

  • 一致 → 正常反序列化
  • 不一致 → 抛 InvalidClassException

如果你不显式定义 serialVersionUID,JVM 会在编译时根据类的结构(类名、字段、方法签名等)自动计算一个哈希值。这意味着:

只要类发生任何改动(新增字段、修改方法签名、甚至仅仅是重新编译),自动计算的值就可能变化,导致之前序列化的文件全部无法读取。

// 显式定义:类改了字段,但只要 version 不变,旧数据仍然可读(新字段取默认值)
private static final long serialVersionUID = 1L;

// 不定义:依赖 JVM 自动计算,类一改版本号就变,旧数据全废

最佳实践 :任何实现了 Serializable 的类, 必须显式定义 serialVersionUID 。可以用 IDE 自动生成(IntelliJ IDEA 中按 Alt + Enter 选择 “Add ‘serialVersionUID’ field”)。

4.4 🔒 transient:敏感字段的安全阀

transient 关键字用于标记 不需要被序列化的字段。被标记的字段在序列化时被跳过,反序列化后恢复为该类型的默认值(引用类型为 null,数值为 0booleanfalse)。

典型场景

  • 密码、密钥:明文密码不应该被写进文件或通过网络传输
  • 连接、流、线程SocketConnectionThread 这些对象依赖于运行时资源,序列化了也毫无意义
  • 缓存/临时计算结果:反序列化后可以重新计算,不需要保存
  • Spring 中注入的 Bean:Spring 容器管理的依赖注入,序列化后无法恢复依赖引用
class User implements Serializable {
    private static final long serialVersionUID = 1L;

    private String username;
    private transient String password;      // 密码不入库
    private transient Thread workerThread;  // 线程无法序列化
    private int loginCount;                 // 正常序列化
}

4.5 🔍 序列化机制的三个关键细节

(1)static 字段不会被序列化

序列化只保存 对象的状态,而 static 字段属于类(Class 对象),不属于某个具体实例。因此 static 字段的值不会写入字节流。反序列化后,static 字段的值是当前 JVM 中该类加载时的初始值,而非序列化时的值。

(2)引用传递与对象图

如果你序列化的对象内部引用了其他对象,被引用的对象也会被级联序列化——前提是这些被引用的类也实现了 Serializable。这形成了完整的 对象图 序列化。同一个对象被多次引用时,序列化机制会记录引用关系,反序列化后不会变成两个独立对象。

class SaveData implements Serializable {
    private Player player;       // Player 也必须 implements Serializable
    private List<Item> items;    // List 中的 Item 也必须 implements Serializable
}

(3)readObject() 不调用构造器

反序列化时,JVM 直接从字节流中恢复对象的状态,不会调用该类的构造器。这意味着如果在构造器中做了初始化逻辑(如建立数据库连接、验证参数合法性),反序列化得到的对象会跳过这些逻辑。如果你需要在反序列化时执行额外的初始化,可以实现 readObject() 私有方法(不是重写,而是一个回调):

private void readObject(ObjectInputStream in)
        throws IOException, ClassNotFoundException {
    in.defaultReadObject();  // 先执行默认反序列化
    // 然后可以在此做额外初始化,比如重新建立连接
}

4.6 🚨 序列化与反序列化常见异常速查

异常原因解决方法
NotSerializableException类没有实现 Serializable 接口让类实现 Serializable,或用 transient 跳过该字段
InvalidClassExceptionserialVersionUID 不匹配显式定义 serialVersionUID,或确保新旧类结构兼容
EOFException流已读完,或者文件被截断检查文件完整性,确保读和写的对象数量一致
StreamCorruptedException流数据格式损坏(可能混入了其他数据)确保读取顺序正确,文件没有被其他程序修改

5 🎯 总结

本篇覆盖了 Java IO 的三种进阶机制,从精确控制二进制字节的 DataInputStream / DataOutputStream,到日常输出最常用的 PrintStream / PrintWriter,再到一键保存/恢复对象的序列化机制。以下是这三种机制的核心对比:

维度Data流打印流对象序列化
目标数据基本类型 + String任意文本完整 Java 对象
数据格式二进制(固定字节数)文本(可读)二进制(私有格式)
读回方式必须按写入顺序读取不涉及(单向输出)readObject() 一键恢复
异常处理可能抛 IOException不抛异常,用 checkError()可能抛多种异常(见上表)
跨平台是(平台无关二进制格式)取决于编码是(JVM 私有二进制格式)
版本兼容无版本机制,顺序变了就乱不涉及serialVersionUID 控制
典型场景二进制协议、游戏存档日志、控制台、HTTP 响应对象持久化、RMI、Session 存储

选择建议

  • 读写基本类型数据的二进制文件 → DataInputStream / DataOutputStream
  • 写日志、打印调试信息、输出格式化文本 → PrintStream(控制台)/ PrintWriter(文件、网络)
  • 保存或传输完整 Java 对象 → ObjectInputStream / ObjectOutputStream,记得实现 Serializable、定义 serialVersionUID、敏感字段用 transient