一个空接口,凭什么让对象起死回生?——Java 序列化深度拆解
上周你请了两天假,满心欢喜地打开自己写的 RPG 游戏,加载存档——
结果屏幕一黑,控制台打印了一串让你头皮发麻的错误:
text
复制
下载
java.io.InvalidClassException: com.xxx.Hero; local class incompatible: stream classdesc serialVersionUID = 123456789, local class serialVersionUID = 987654321存了一个星期的极品装备全没了,就因为给 Hero 类加了一个 int speed 属性。
你仰天长啸:这不就是多加了一个字段吗?凭什么整个对象就废了?!
还有更离谱的:Serializable 接口里面明明一个方法都没有,它就是画了个饼,JVM 到底对对象做了什么手脚,才能把它变成一串字节、存到磁盘,又在三个月后原封不动地“复活”?
今天,我们就来撕开 Java 序列化的内裤,看看这个看不见摸不着的魔法到底是怎么回事。
一、冷冻休眠舱:序列化到底是啥?
简单说,序列化就是把内存里的对象,转成一串可以存储或传输的字节序列。
反序列化就是把这串字节重新“变回”一个活生生的对象。
打个比方:
你快递一个乐高城堡,不能直接把拼好的整个城堡塞进包裹,得拆成零件,记录下拼装说明书。
收到的人拿到零件和说明书,重新拼出来一个一模一样的。
序列化就是把对象拆成字节,反序列化就是照着图纸重新拼装对象,连属性值都一模一样。
这招有什么用呢?
持久化:把对象存到文件、数据库里,下次再读回来。
网络传输:一个对象从这台机器飞到那台机器,靠的就是字节流。
缓存:Memcached、Redis 里存 Java 对象时,底层也经常依赖序列化。
Java 给了你一个开箱即用的“冷冻剂”:实现 Serializable 接口。
二、Serializable 明明啥也没干,凭什么?
我们来看一眼 Serializable 的真身:
java
复制
下载
publicinterfaceSerializable{// 空,啥都没有}一个方法都没定义,你实现它,和给类贴个标签没有区别。
可一旦你给类贴上这个标签,ObjectOutputStream 就能用 writeObject(hero) 把你对象冻成文件,ObjectInputStream 再用 readObject() 从文件里孵出来。
这背后根本不是接口在做工,而是 JVM 开了后门。
当你调用 ObjectOutputStream.writeObject() 时,它:
先看你的对象是不是实现了
Serializable。不是?直接抛NotSerializableException。是的话,它就用反射把你的类结构扒个精光:包括类名、字段名、字段类型、字段的值……
把这些元数据和实际数据按照特定协议写入输出流。
反序列化的时候更离谱:
ObjectInputStream读取到类名后,直接通过底层 native 方法强行创建对象,不走构造器!它把字节里的字段值一个一个塞回给这个“空壳”对象。
所以 Serializable 接口的作用,仅仅是一个通行证,告诉 JVM:“我这个类的对象,你可以用黑科技对它进行冷冻和解冻,我同意了。”
就像你签了一份器官捐献卡,什么动作都没做,但医生看到卡片就可以合法地摘了又装回去。
三、血案元凶 serialVersionUID:对象身份证号
回到开头的惨案。
你加了个 speed 属性,存档就读不回来了,原因就藏在这个 serialVersionUID 里。
序列化时,JVM 会为每个可序列化的类偷偷生成一个版本号 serialVersionUID,写进字节流里。
反序列化时,JVM 会比较字节流里的版本号和当前类计算出来的版本号。
不一样?立刻抛 InvalidClassException,绝不勉强。
这个版本号不是随机值,而是根据类的“形状”用一套算法算出来的:
类名、字段、方法签名、实现的接口等等……
你哪怕只是动了一个字段的名字,整个 UID 瞬间翻脸不认人。
解决办法也简单:
不要用 JVM 自动算的,你直接手动写死一个 serialVersionUID:
java
复制
下载
publicclassHeroimplementsSerializable{privatestaticfinallong serialVersionUID =1L;// 其他字段}写死以后,你加个字段、删个字段,版本号都不变,JVM 就会尽力去兼容——新字段读不到就赋默认值,旧字段多了就扔掉。
一句话:UID 写死,数据不猝死。
四、遗忘咒 transient:有些秘密不该被冷冻
密码、银行卡号、验证码这些敏感信息,你敢让它们随随便便被序列化到文件里吗?
Java 给你一个关键字:transient。
java
复制
下载
privatetransientString password;被 transient 标记的字段,序列化时就像被施了“遗忘咒”——JVM 假装它不存在,直接跳过。
反序列化的时候,这个字段会被置为类型的默认值(对象就是 null,int 就是 0)。
多么优雅的安全措施:
你只要轻轻加上一个单词,就不怕敏感数据跟着字节流一起裸奔。
五、那只看不见的手:反序列化漏洞
当你以为序列化就是存个盘、传个参的时候,黑客已经开始搓魔法了。
2015 年,Apache Commons Collections 爆出一个惊天漏洞,影响了无数 Java Web 服务器。
攻击原理大概是:
黑客构造一串精心设计的恶意字节流,里面嵌入了可以执行系统命令的对象链。
反序列化的时候,这些对象被依次复活,某个看似无害的 readObject 方法内部,悄悄执行了 Runtime.getRuntime().exec("rm -rf /")。
你只是反序列化了一串数据,服务器就被人提权拿下了。
根本原因是:
反序列化在重建对象的过程中,会触发对象的读取方法,而攻击者可以拼出一个执行链(POP 链),像多米诺骨牌一样,利用正常逻辑完成恶意操作。
从那以后,安全圈给 Java 原生序列化贴上了“高危”标签。
如果你一定要用,务必记住:
绝不对不受信任的数据进行反序列化
如果必须反序列化外部数据,用白名单过滤类名,或者直接投向更安全的序列化方案。
六、既然原生序列化又慢又危险,还有什么选项?
Java 自带的序列化确实缺点不少:
生成的字节流体积大(带了太多元数据)
速度慢
只限于 Java 平台,不能跨语言
于是各种序列化框架应运而生:
JSON/XML:人可读,跨语言,但体积和速度一般。
Hessian:二进制,跨语言,比原生快,Dubbo 曾长期使用。
Protobuf / Thrift:谷歌和 Facebook 的明星产品,速度飞快,体积极小,强依赖 IDL 定义结构。
Kryo:Java 原生生态里的性能王者,很多大数据框架在用。
但不管再怎么切换,理解原生 Serializable 是你做技术选型的底气。
否则你连为什么 Dubbo 序列化会丢对象、Redis 序列化会爆栈,都找不到调试入口。
七、最后掏心窝子
Java 序列化机制,就像一把老菜刀:
你用 Serializable 贴个标,以为在切菜,其实 JVM 拿着反射和 native 构造在后台给你表演了一场傀儡复活术。
回头再看那个空空的 Serializable 接口——
它哪里是什么“接口”,它明明就是一个 “准拆标签”,告诉虚拟机:
“哥,我这个类可以拆,放心拆,出事我自己兜着。”
记住这三句保命符:
类想持久化,加上
implements Serializable,顺手把serialVersionUID写死。不想冻住的字段,丢个
transient下去。除非你确定对面那串字节是亲儿子,否则绝对不要反序列化陌生数据。
下次你的存档再爆炸,就别怪 SerialVersionUID 了,怪自己没看这篇。
你被序列化坑过最惨的一次是什么?评论区晒出来,让大家看看你的伤疤。
更多推荐
所有评论(0)