前言
本文为 java 序列化系列 · 下篇(进阶生产篇) 承接上篇基础内容,专注底层原理、对象缓存机制、版本兼容高阶api、序列化代理模式、反序列化安全漏洞、jdk版本变化、数据库存储实战、线上踩坑复盘、企业选型最佳实践。
适合已经掌握serializable基础,想要深挖底层、规避线上bug、应对高阶面试的开发者。
👉 基础概念、transient、serialversionuid、继承规则请阅读上篇: 【面试必背】java序列化:serializable、transient、serialversionuid完整梳理
一、底层核心:对象引用缓存与循环引用
1.1 缓存实现原理
objectoutputstream 内部持有一个对象‑输出句柄映射表。 每序列化一个对象,就把对象存入缓存,后续流中遇到同一个对象,不会重复序列化对象数据,只会输出一个引用编号。
带来两个特性:
- 天然支持对象循环引用,不会无限递归栈溢出
- 同一流多次写出同一个对象,读取得到的是同一个对象引用
1.2 高频生产bug:修改对象后再次序列化不生效
user user = new user("张三",18);
oos.writeobject(user);
// 修改对象属性
user.setname("李四");
oos.writeobject(user);反序列化出来,第二个对象名字依旧是张三。
原因:流缓存已经记录该对象,直接输出引用,不会重新扫描对象最新状态。
1.3 两种解决方案
oos.reset():清空整个流的全部对象缓存,适合简单场景;缺点会重置全部历史对象记录。oos.writeunshared(user):本次不使用缓存,强制把当前对象完整序列化一遍;不会改动历史缓存。
面试考点:区分 writeobject 和 writeunshared 的区别。
二、高阶版本兼容api
2.1 readobjectnodata()
当反序列化时,流里面完全没有当前子类的任何字段数据时,才会回调这个方法。 典型场景:父类子类继承关系发生改动,旧版本字节流缺少子类信息。
注意:仅仅新增几个字段,不会触发 readobjectnodata,很多面试题在这里挖坑。 在方法内可以给字段设置业务默认值,避免字段为null。
2.2 objectinputfilter 输入过滤
jdk9 正式引入,jdk8u121之后回移植到jdk8。 用来做反序列化黑白名单,限制允许反序列化的类,拦截恶意 gadget 类,是jdk官方提供的防御手段。
三、readresolve / writereplace 深度剖析(面试高频)
上篇简单介绍,这里补充生产坑。
writereplace:序列化发生之前,替换要写入流的对象。readresolve:反序列化完成,对象构造出来之后,替换最终返回给程序的对象。
3.1 单例序列化漏洞
仅仅单例私有构造,没有 readresolve,反序列化会产生全新对象,破坏单例。
private object readresolve() {
return instance;
}
坑点:readresolve 只改变返回对象,流里面依旧保存着旧实例的完整字节数据,不会改变流内容。
3.2 和序列化代理模式的区别
readresolve 只是替换返回对象;序列化代理模式会把代理对象真正写入字节流,安全性更高。
四、externalizable 生产坑点回顾
externalizable 完全自己实现读写逻辑。
- 必须有无参构造器,反序列化会反射调用无参构造;没有直接抛异常。
- transient 修饰的字段依然需要手动读写,transient关键字失效。
- serialversionuid依然生效。
- 业务中极少使用,大部分场景用serializable即可。
五、序列化代理模式(effective java推荐最安全方案)
普通 readresolve 存在攻击绕过风险,effective java 重点推荐序列化代理模式。
核心思路:
- 为业务实体编写一个私有静态代理类,代理类保存业务对象全部核心字段。
- 原类实现 writereplace,返回代理实例写入流。
- 代理类实现 readresolve,重新构建原始业务对象返回。
// 业务实体
public class user implements serializable {
private string name;
private integer age;
public user(string name, integer age) {
this.name = name;
this.age = age;
}
// 序列化代理,写入流的实际是这个代理对象
private static class serializationproxy implements serializable {
private final string name;
private final integer age;
public serializationproxy(user user) {
this.name = user.name;
this.age = user.age;
}
// 反序列化代理,重建原始对象
private object readresolve() {
return new user(name, age);
}
}
// 序列化时替换为代理
private object writereplace() {
return new serializationproxy(this);
}
// 禁止直接反序列化本类,防止攻击者构造恶意字节
private void readobject(objectinputstream in) throws invalidobjectexception {
throw new invalidobjectexception("请使用序列化代理");
}
}优势:
- 攻击者无法构造恶意子类进行攻击;
- 反序列化强制调用业务构造器,可以做参数校验;
- 可以无视原对象所有字段访问权限。
缺点:会额外多一层对象,对超大对象会带来少量性能开销。
六、容器类自定义序列化原理:arraylist / hashmap
jdk集合类并没有简单把全部成员变量序列化。 以arraylist举例:
- 底层elementdata数组会预留大量空位置,如果直接序列化会把大量null空间写入字节流,体积膨胀。
- 重写 writeobject,只序列化数组中有效size个元素;
- readobject读取时,再重建elementdata数组。
hashmap同理,会重新构建哈希表,不会直接序列化table数组。
面试考点:为什么集合类要重写writeobject/readobject?减少序列化字节体积。
七、反序列化高危安全漏洞
7.1 漏洞原理
java原生反序列化不需要调用业务构造器,读取字节即可还原对象。 攻击者构造恶意序列化字节,程序反序列化时,会自动执行类内部readobject等逻辑,配合第三方库的恶意调用链(gadget),实现远程代码执行。
受影响:jdk原生序列化,只要反序列化不受信任的字节流就有风险。 历史中招组件:commons‑collections、commons‑beanutils 等。
7.2 分层防御策略
- 最高原则:永远不要反序列化外部不可信来源的数据,这是根本。
- 使用 objectinputfilter 设置类黑白名单,拦截危险类。
- 在自定义 readobject 内部做严格字段校验,拒绝非法参数。
- 业务系统,尽量彻底放弃java原生序列化,使用json、protobuf替代。
八、其他容易被忽略的序列化坑
- final修饰的字段,可以通过序列化赋值,绕过编译期final限制。
- 不同类加载器场景:类全限定名一样,类加载器不同,反序列化抛出类型不匹配异常。
- 引用类型:softreference、weakreference序列化之后引用会丢失。
- 枚举:枚举序列化依靠枚举名字,修改枚举常量名字,旧字节流反序列化直接报错。
- 静态内部类可以序列化;非静态内部类自带外部类隐式引用,极易序列化失败,业务禁止序列化非静态内部类。
九、jdk版本演进:原生序列化现状
- jdk8:序列化完整可用,没有标记废弃;objectinputfilter需要升级小版本。
- jdk9:原生序列化被标记为 legacy(遗留机制),官方明确不推荐继续使用。
- java17/21:提供开关可以禁用原生序列化;未来版本会逐步移除。
重点:面试经常问,java官方对原生序列化的态度。
十、数据库存储场景序列化实战规范
很多项目会直接把 java 序列化后的二进制字节存入数据库blob字段,这里有大量线上踩坑点。
10.1 重要概念区分
数据库事务隔离级别 serializable 和 java 对象序列化完全无关,不要混淆。
10.2 几种存储方案对比
- java原生序列化二进制(blob) 不建议业务使用。 缺点:版本升级极易反序列化失败;可读性为0;数据库无法做条件查询;跨语言完全不兼容;存在反序列化安全风险。
- json字符串(varchar / text) 业务最常用方案。 优点:可读性好,数据库支持json函数查询;跨语言兼容;版本向前向后兼容好。 缺点:体积相比二进制偏大。
- protobuf / hessian 二进制 适合高性能内部系统。 优点:体积小,序列化性能高,版本兼容性强。 缺点:数据库不能直接解析查询,调试不方便。
- xml 基本淘汰,体积冗余大。
10.3 什么场景可以把对象序列化存入数据库
适合:对象整体保存、整体读取,几乎不会做where条件查询、不需要join关联的数据。 例如:流程快照、历史备份快照、缓存备份。
10.4 什么场景绝对不要序列化存储
需要按对象内部字段做查询、过滤、统计、关联查询,必须拆成数据库普通表字段,不要塞二进制大字段。
10.5 历史遗留系统注意事项
老项目已经使用java序列化blob存入数据库:
- 类升级务必维护好serialversionuid;
- 禁止随意修改类继承、字段类型;
- 尽量做数据迁移,逐步转为json存储。
十一、生产环境完整最佳实践总结
- 如果业务迫不得已要实现serializable:
- 手写固定 serialversionuid,不要依赖自动生成。
- 敏感字段标记transient,业务层做加密,不要依靠序列化做安全。
- 同一个objectoutputstream多次写对象,修改对象后记得 reset 或者 writeunshared。
- 单例、不可变类优先考虑序列化代理模式,规避单例被破坏风险。
- 网络接口、存储到数据库,不要使用java原生序列化字节;优先json/protobuf。
- 禁止把java序列化字节对外暴露给外部接口。
- 不要序列化非静态内部类、lambda。
写在最后
java原生序列化看着api简单,但是缓存机制、继承、安全漏洞坑点极多。面试是高频考点,但是新项目尽量避免直接使用。
本系列上篇讲解基础考点,本篇讲解底层原理与生产踩坑,两篇结合完整覆盖java序列化全部核心内容。
到此这篇关于java 序列化进阶实战:循环引用、反序列化漏洞与生产避坑指南的文章就介绍到这了,更多相关java 序列化和反序列化内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论