本期是Java开发教程系列的第二十一期,本期介绍序列化:把内存里的对象整体保存成文件或传给网络,下次运行原样还原.第 12 期我们存的是文本文件,今天存的是"整个对象".若你已经熟悉 Serializable、ObjectOutputStream 和 serialVersionUID,可以跳过本期内容.
每期叠甲:
时间仓促,水平有限,文中难免存在缺点和错误,恳请广大开发者和从业者指正.
一、序列化是什么:给对象拍张"照片"
第 11 期我们学会了把文本存进文件;第 20 期学会了存键值对配置.但存的对象呢?比如第 12 期那个 Student 对象(有 name、score 两个字段),我们存的是"拼成字符串再读回来":
writer.write(s.getName() + "," + s.getScore()); // 手动拼格式
// 读的时候再 split(",") 拆回来
对象字段一多(十几个字段、还嵌套别的对象),手动拼串就活不下去了.有没有办法"把整个对象直接存进去,再原样恢复"?有! 序!列!化! :
- 序列化:对象 → 字节流(可以存文件、传网络)
- 反序列化:字节流 → 新的对象(字段值原样还原)
打个比方:序列化是给对象拍一张"高清照片",反序列化是照着照片把对象重新搭出来.照片存在文件里,程序重启后还能"复活"它.
二、三步写出对象:Serializable + 两个流
第一步:让类实现 Serializable 接口:它是个"标记接口",一个方法都没有,只用来告诉 JVM:“这个类的对象可以序列化”:
import java.io.Serializable;
public class Student implements Serializable {
public String name;
public double score;
public Student(String name, double score) {
this.name = name;
this.score = score;
}
}
第二步:用 ObjectOutputStream 写出:它是"对象专用"的输出流,和第 11 期的 FileOutputStream 一脉相承,只是包了一层:
import java.io.ObjectOutputStream;
import java.io.FileOutputStream;
Student s = new Student("小明", 92.0);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("student.dat"))) {
out.writeObject(s); // 整个对象写出去
}
第三步:用 ObjectInputStream 读回:
import java.io.ObjectInputStream;
import java.io.FileInputStream;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("student.dat"))) {
Student s = (Student) in.readObject(); // 读回来,强转成 Student
System.out.println(s.name + " " + s.score); // 小明 92.0
}
注意三点:
readObject() 返回 Object,要强转成目标类型
- 读的字段顺序、类型和写的时候一致,对象才能还原
List<Student> 同样可以直接序列化——集合对象本身就是对象,writeObject(list) 一次全存,不用写循环
一个对象的所有字段都会写进字节流,包括嵌套的对象(嵌套对象也会自动序列化,只要它的类也实现了 Serializable).这就是"整个对象"的含义.
思考一下:我们前面说尽量少强转,这里怎么强转啦?
三、serialVersionUID:对象的"版本号"
上一节代码里我们没写版本号,能跑.但有个隐患:哪天 Student 类加了一个字段(比如加了 phone),再读以前存的文件:
Exception in thread "main" java.io.InvalidClassException:
Student; local class incompatible:
stream classdesc serialVersionUID = 5838960950294633260,
local class serialVersionUID = -1234567890
报错原因:序列化时,对象会带一个 serialVersionUID(序列化版本号)一起存进文件;反序列化时,JVM 拿这个号对比当前类的版本号,对不上就拒绝还原
因为类变了,旧数据还原出来可能驴唇不对马嘴.
如果代码里没写这个号,JVM 会根据类的结构自动算一个:结构一改,自动算出的号就变了,于是报错.这很烦:加一个字段,旧存档全废.
解决:在类里手动声明一个固定的版本号,并随类一起维护:
public class Student implements Serializable {
private static final long serialVersionUID = 1L; // 固定版本号
public String name;
public double score;
public String phone; // 新加的字段
}
这样以后加字段、删字段,只要更新这个号(或保持兼容就不动它),JVM 就知道"这是同一版本的类",旧存档照样能读.规则一句话:实现 Serializable 的类,都写上 private static final long serialVersionUID,加字段时手动决定是保持还是升级.IDE 有快捷键自动生成,用上它.
为什么是 static final?static 字段不参与序列化(它属于类),final 常量在编译期固定.它天生就是"给 JVM 看的元信息",不进字节流,反而永远可用.
四、transient:有些字段不想存
对象里不是所有字段都该进文件:密码、临时缓存、依赖网络连接的对象……用 transient 标记,序列化时自动跳过:
public class Player implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public transient String password; // 不落盘,不传网络
public transient Session session; // 运行时才有的东西,存了也没意义
}
transient 字段反序列化回来是默认值(null、0).规则:"该保存的"才让它存,"不该存的"用 transient 挡在门外.密码之类的敏感数据,绝对不能明文或者未经专门加密存在硬盘里.
顺带说清一个易混点:static 字段也不参与序列化.但那是另一回事,static 字段属于类、不属于某个对象,序列化对象时自然不管它.transient 是"对象里有,但我不想存";static 是"根本不在对象里".
五、安全提醒:反序列化不调用构造器
有一个细节值得现在记住,面试也常被问到:反序列化创建对象,不经过构造器:readObject 是 JVM 按字节流"拼"出对象,构造器、字段初始化的代码(比如 private List<String> cache = new ArrayList<>())都不执行,字段直接按文件里的值填充.
这带来两点后果:
- transient 字段回来是默认值.前面说的 null/0 就因此而来:构造器里的初始化没跑,存的时候又没存它
- 安全风险:如果把不可信的文件或网络数据拿去反序列化,攻击者可以精心构造字节流,让
readObject 触发危险逻辑(构造特殊对象、执行恶意代码).这就是"反序列化攻击".所以规则是:只反序列化自己信任的数据(自己的存档),绝不对来路不明的输入调 readObject(安全攻防阶段还会展开)
第 12 期的存档是自己写的,放心用;但"从网上下载的存档文件直接读进内存"这种操作,就要警惕了.
六、小项目:学生列表存档
把第 12 期的学生列表整体存成 .dat,再读回,全程没有一行手动拼字符串:
文件 Student.java:
import java.io.Serializable;
public class Student implements Serializable {
private static final long serialVersionUID = 1L;
public String name;
public double score;
public Student(String name, double score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return name + " " + score;
}
}
文件 Main.java:
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
// 造一个学生列表
List<Student> students = new ArrayList<>();
students.add(new Student("小明", 92));
students.add(new Student("小红", 58));
students.add(new Student("小刚", 76));
// 整个列表序列化到文件
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("students.dat"))) {
out.writeObject(students);
}
System.out.println("已保存 " + students.size() + " 名学生到 students.dat");
// 反序列化读回
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("students.dat"))) {
@SuppressWarnings("unchecked")
List<Student> loaded = (List<Student>) in.readObject();
System.out.println("读回 " + loaded.size() + " 名学生:");
for (Student s : loaded) {
System.out.println(s);
}
}
}
}
运行结果:
已保存 3 名学生到 students.dat
读回 3 名学生:
小明 92.0
小红 58.0
小刚 76.0
动手实验(两步都值得做)^-^
- 用记事本打开
students.dat,开头能看到 AC ED 两个字节,这是 Java 序列化文件的"魔数",就像文件的签名
- 把
Student 加一个字段(比如 String phone),删掉 serialVersionUID 那行,再读旧存档.复现 InvalidClassException;然后把 serialVersionUID 加回来,又能读了
朝花夕拾:从"拼字符串"到"存整个对象"
第 12 期我们存存档,写的是 writer.write(s.getName() + "," + s.getScore());读回时 split(",") 拆。当时学的是"手动格式"。今天回头看,那套手动的本质就是自定义序列化格式,只是我们自己写的格式。今天学的 ObjectOutputStream 是 Java 官方的格式:通用、自动、支持嵌套对象,代价是文件不是人读的文本。两条路没有谁更好:要给人看的(配置文件、日志)用文本;要程序用的(存档、缓存)用对象序列化.第 12 期的学生成绩管理系统,今天升级成了"一键存档"版。
总结
本期学会了把对象"整体搬走"再"原样搬回":
- 序列化:对象 ↔ 字节流,
Serializable 标记接口 + ObjectOutputStream/ObjectInputStream
- serialVersionUID:类的版本号,手动固定,类结构变化才可控;不写会自动生成,改类就报错
- transient:标记不想序列化的字段(密码、临时数据);static 本来就不参与
- 学生列表整体存档
.dat 小项目,顺带认识 AC ED 魔数.
下一期我们学习网络基础:程序第一次和别的程序(甚至另一台电脑)说话.