数据流与对象序列化
本教程共 100 篇 · 第 89 篇 · 更新于 2026-08-05 · 约 5 分钟阅读
本节目标:学会用数据流读写基本类型,理解对象序列化的原理、坑点和安全边界。
为什么需要数据流
前面几节读写的要么是字节,要么是字符。但程序里的数据大多是有类型的:年龄是 int,价格是 double,是否会员是 boolean。
如果只用字节流,你得自己把 int 拆成 4 个字节写出去,读的时候再拼回来。这活儿又枯燥又容易出错。
DataOutputStream 和 DataInputStream 就是来干这件事的。它们属于处理流,必须包在别的流外面用。
数据流的读写方法
方法名很好记,write 或 read 后面跟类型名:
| 写出 | 读入 | 说明 |
|---|---|---|
writeInt(int) | readInt() | 固定 4 字节 |
writeLong(long) | readLong() | 固定 8 字节 |
writeDouble(double) | readDouble() | 固定 8 字节 |
writeBoolean(boolean) | readBoolean() | 固定 1 字节 |
writeUTF(String) | readUTF() | 变长,前 2 字节存长度 |
注意这些方法写出的是二进制,不是文本。用记事本打开生成的文件,看到的是一堆乱码,这很正常。
import java.io.*;
public class DataStreamDemo {
public static void main(String[] args) throws IOException {
// 写出:商品名、单价、库存
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream("goods.dat")))) {
out.writeUTF("机械键盘");
out.writeDouble(399.00);
out.writeInt(25);
out.writeUTF("显示器");
out.writeDouble(1299.50);
out.writeInt(8);
}
// 读入:顺序必须完全一致
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream("goods.dat")))) {
while (true) {
String name = in.readUTF();
double price = in.readDouble();
int stock = in.readInt();
System.out.printf("%s 单价 %.2f 库存 %d%n", name, price, stock);
}
} catch (EOFException e) {
System.out.println("读取完毕");
}
}
}
输出:
机械键盘 单价 399.00 库存 25
显示器 单价 1299.50 库存 8
读取完毕
顺序和 EOFException
上面这段代码有两个必须记住的点。
第一,写出的顺序就是读入的顺序。数据流写出去的是纯粹的二进制,文件里没有任何标记说明哪几个字节是什么类型。你先写 int 后写 double,读的时候就必须先 readInt() 再 readDouble()。顺序错一位,后面全乱。
第二,结束判断靠异常。readInt() 这类方法没法用 -1 表示结束——因为 -1 本身就是一个合法的 int 值。所以数据流选择在流末尾抛出 EOFException。
这是 Java I/O 里少见的「用异常表示正常结束」的设计。虽然不太优雅,但确实没有更好的办法。
Warning
catch (EOFException e)只能表示读到了末尾,别把它和真正的读取错误混在一起处理。更别写成catch (IOException e),那样会把磁盘故障也当成正常结束。
精度问题
有个坑要提前说:float 和 double 是二进制浮点数,无法精确表示大多数十进制小数。
写入 0.1 再读出来,值确实还是 0.1(因为二进制表示原样往返),但如果拿它做累加,误差就会显现。
涉及金额计算,正确做法是用 BigDecimal。而 BigDecimal 不是基本类型,数据流写不了,得用接下来的对象流。
对象序列化是什么
数据流只能处理基本类型。可实际开发中,我们操作的是对象。
把一个对象在内存中的状态转成字节序列,叫序列化;把字节序列还原成对象,叫反序列化。
有了这个能力,对象就能存进文件、写入数据库、通过网络发给另一台机器。
Java 内置了一套序列化机制,用起来只要三步。
第一步:实现 Serializable
要让一个类可以被序列化,它必须实现 java.io.Serializable 接口。
这个接口特殊——里面一个方法都没有。它只是一个标记,告诉 JVM「这个类允许被序列化」。
import java.io.Serializable;
public class Student implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password; // 不参与序列化
public Student(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
@Override
public String toString() {
return "Student{name=" + name + ", age=" + age + ", password=" + password + "}";
}
}
两个细节:
serialVersionUID 是版本号。反序列化时,JVM 会比对文件里记录的版本号和当前类的版本号,不一致就抛 InvalidClassException。手动声明一个固定值,可以避免类改动后旧数据读不出来。
transient 修饰的字段不会被写出去。密码、临时缓存、数据库连接这类不该持久化的内容,都应该加上它。
第二步:写出对象
import java.io.*;
import java.util.List;
public class SerializeDemo {
public static void main(String[] args) throws Exception {
List<Student> list = List.of(
new Student("张三", 20, "123456"),
new Student("李四", 22, "abcdef"));
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("students.ser"))) {
out.writeObject(list); // 集合本身也可序列化
}
System.out.println("已写出");
}
}
writeObject() 接收 Object,也就是说什么对象都能传。但如果对象没实现 Serializable,运行时会抛 NotSerializableException。
第三步:读回对象
import java.io.*;
import java.util.List;
public class DeserializeDemo {
@SuppressWarnings("unchecked")
public static void main(String[] args) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream("students.ser"))) {
List<Student> list = (List<Student>) in.readObject();
list.forEach(System.out::println);
}
}
}
输出:
Student{name=张三, age=20, password=null}
Student{name=李四, age=22, password=null}
看到 password 变成 null 了吗?这就是 transient 生效的证据。
readObject() 的返回类型是 Object,需要强制转型,所以会抛 ClassNotFoundException——万一 classpath 里没有那个类,就还原不出来。
对象图会被整体保存
序列化不只保存一个对象,而是保存整张对象图。
如果 Student 里有一个 Address 字段,那 Address 也会被一起写出去,因此 Address 也必须实现 Serializable。它引用的其他对象同理,一层层递归下去。
Java 还会自动处理循环引用。同一个对象在图里出现多次,只会真正写一份,其余位置写引用编号。读回来之后,这些位置指向的仍是同一个实例。
Tip这个特性说明序列化的开销可能远超预期。一个看似简单的对象,如果间接引用了一大堆数据,写出的文件会大得吓人。
序列化的安全风险
必须严肃提醒一句:永远不要反序列化来源不可信的数据。
readObject() 在还原对象的过程中会执行类中的某些代码。攻击者可以精心构造一段字节流,让反序列化过程触发任意命令执行。历史上多个知名框架的高危漏洞都出在这里。
因此现代 Java 项目中,跨系统传输数据基本不用 Java 原生序列化,而是改用 JSON、Protobuf 这类跨语言的文本或二进制格式。它们的解析过程不会执行目标类的代码,安全得多。
WarningJava 原生序列化适合的场景很窄:同一个程序内部的临时存储、可信环境下的进程通信。对外接口一律走 JSON。
本节小结
- 数据流按类型读写基本值,
writeXxx()与readXxx()顺序必须严格对应。 - 数据流用
EOFException表示读到末尾,不能用-1。 - 金额计算用
BigDecimal,数据流不支持,得走对象流。 - 序列化要求类实现
Serializable标记接口,字段用transient可排除。 - 显式声明
serialVersionUID,避免类改动后旧数据失效。 - 序列化保存整张对象图,引用到的类都必须可序列化。
- 不可信数据绝不反序列化,对外传输优先选 JSON。