做后端这些年,序列化这个问题几乎每天都在打交道。Redis缓存要序列化、Dubbo调用要序列化、消息队列要序列化,连存个Session都绕不开它。我一直觉得理解序列化最好的方式是把它类比成"给对象办托运"——内存里的对象就像你家里的家具,又大又重还不方便搬运;序列化就是把它拆解打包成标准尺寸的箱子,到了目的地再按清单原样组装回来。整个过程看似简单,但一旦出问题就是大事:接口突然报类型转换异常、缓存里的数据读出来全是乱码、甚至被攻击者利用构造恶意请求执行系统命令,这些线上事故的根源,十有八九都落在序列化和反序列化这两个环节上。
这篇文章会把序列化和反序列化从原理到实战完整梳理一遍,包括它到底解决了什么问题、底层是怎么工作的、主流方案怎么选、不同语言里有哪些坑,以及最近讨论度很高的反序列化攻击的原理和防护。适合刚入门不久想弄懂底层机制的开发者,也适合写过不少业务代码但一直没系统整理过这块知识的同学。我会尽量用大白话讲清原理,同时也给出可以直接抄作业的实操方案。
1. 序列化到底解决了什么问题
1.1 为什么内存里的对象不能直接存下来
先想一个问题:你在Java里new了一个User对象,里面有name字段是"张三",有age字段是25,怎么把这个对象写进文件?直接写内存地址?当然不行。内存地址是操作系统给当前进程划分的一块空间,进程一退出,这块地址就失效了,换个进程甚至换台机器,这个地址完全没有意义。
更深一层的问题是,对象在内存里不只是简单的数据,它还有类型信息、继承关系、引用指向,一个List对象内部维护着数组扩容、modCount这些运行时状态。这些信息对"另一个地方要读取这个对象"的场景没有价值,甚至是有害的——因为它们强依赖当前进程的运行时环境。
序列化的本质,就是把对象里那些真正有业务含义的字段值提取出来,按照一套约定好的规则编码成字节序列,这个过程叫序列化(Serialization)。反过来,拿到字节序列后,再按照同一套规则把对象恢复出来,就叫反序列化(Deserialization)。字节序列可以写到磁盘、通过网络传输、存进Redis,它唯一依赖的就是那套约定规则,而不依赖具体的进程和内存。
1.2 三个最核心的应用场景
第一个场景是缓存。我们用Redis缓存用户信息,不能直接把Java对象怼进去,先序列化成JSON或者二进制,存进去;读取的时候反序列化回来。这个过程对业务代码是透明的,但底层一直在发生。
第二个场景是网络传输和RPC。你在A服务调用B服务的接口,参数是一个Order对象,这个对象必须被序列化成字节流通过网络传过去,B服务再反序列化成它自己的Order对象。Dubbo、gRPC、Spring Cloud OpenFeign,无一例外。
第三个场景是持久化存储。把对象状态保存到文件、数据库或者消息队列,比如Kafka里传递的消息体,本质上就是一系列序列化后的字节。你写业务的时候可能感受不到,但框架层面早就帮你做完了。
另外还有一个很容易被忽略的场景:深拷贝。把对象序列化后再反序列化回来,能得到一个全新的对象,所有嵌套引用都重新创建,这比手动逐个字段复制要省事得多,我第一次看到有人这么写的时候还觉得挺惊艳的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 序列化的底层原理拆解
2.1 序列化协议里到底存了什么
不管哪种序列化方案,底层都要解决三件事:存类型信息、存字段数据、存恢复规则。
类型信息告诉你拿到的这堆字节应该还原成什么类。字段数据就是对象各个属性的实际值。恢复规则则决定了字节的排列方式,反序列化的时候必须完全照着这个规则来读。
举个例子,Java原生的序列化会先写一个魔数(AC ED 00 05),再写类描述信息,包括类名、serialVersionUID、字段数量、字段名和类型,最后才写具体的字段值。反序列化的时候,先读类描述,找到对应类,再按字段逐个赋值。这就是为什么Java原生序列化的体积偏大——它把很多元信息都写进去了。
JSON序列化则简单得多,它只保存字段名和值,不保存类型信息,这也是为什么JSON反序列化往往需要显式指定目标类型。在用Jackson或者fastjson把JSON字符串转成对象时,底层都是通过反射创建实例,再按字段名匹配赋值。如果你传的类型不对,比如把int类型的字段填了个字符串"abc",就会在反序列化阶段报类型转换异常。
2.2 serialVersionUID和类的演化
这是Java序列化里最容易踩的坑,也是最值得先讲清楚的内容。JVM在做反序列化时会校验类的serialVersionUID是否跟序列化数据里的一致,不一致直接抛InvalidClassException。如果你没有显式声明,JVM会根据类名、字段、方法等自动计算一个,只要类结构一改,自动算出来的值就变了。
我之前在一个老项目里见过线上故障就是这么来的:A服务先序列化了一批对象到Redis,后来代码改了,给对象加了个字段,没声明serialVersionUID,结果自动生成的UID变了,B服务再去读缓存的时候直接报错,整个链路就断了。
所以显式声明serialVersionUID是一个从第一行代码就该养成的习惯。规则很简单:如果类的修改是兼容的,比如加字段、加方法,UID保持不变,老数据可以正常反序列化,新增的字段用默认值填充;如果修改是不兼容的,比如删了字段、改了类型,就应该手动改UID,让旧数据明确失效。
2.3 transient、static这些特殊字段怎么处理
Java里用transient关键字标记的字段不会参与序列化。典型的场景是密码、密钥、或者一些可以重新计算的派生字段,它们不应该被写进文件或者网络流。static字段也默认不参与序列化,因为它属于类而不属于对象实例。
反序列化标记为transient的字段时会得到默认值,比如引用类型是null,int是0。所以如果序列化后这些字段变成null,不用慌,先检查一下是不是被transient修饰了。
PHP里对应的机制是__sleep和__wakeup魔术方法。__sleep指定序列化时要保留哪些属性,__wakeup在反序列化时自动调用,通常用来做资源重建,比如重新打开数据库连接、重新加载配置文件。Python的pickle也类似,可以通过__getstate__和__setstate__控制序列化行为。
3. 主流序列化方案横向对比与选型
3.1 文本格式:为什么JSON成了事实标准
JSON可以说是现在最普及的序列化格式,几乎没有之一。它可读性好、跨语言能力强,任何一个语言都能轻松解析。它的缺点是体积相对大,没有二进制格式那么紧凑,解析性能也不算顶级,但对于绝大多数业务系统来说完全够用了。
Java生态里最常用的JSON库就是fastjson、Jackson和Gson。Jackson是Spring Boot默认的,Gson在Android和轻量级场景用得比较多,fastjson因为性能好曾经非常流行,但这些年因为漏洞问题,用它的团队越来越谨慎。
XML也可以算文本序列化方案,但它太啰嗦了,现在主要在一些老系统接口、配置文件、以及需要严格文档结构约束的场景里存活。日常业务开发里,JSON已经基本取代了XML。
3.2 二进制格式:Protobuf、Kryo与Hessian的区别
需要更高性能、更小体积的场景,会选二进制序列化方案。Protobuf是Google推出的,需要先写.proto文件定义结构,然后生成各语言的代码。它的优点是体积小、解析速度快、跨语言能力强,缺点是需要维护单独的proto定义,改结构要重新生成代码,稍微有点重。
Kryo是Java语言专属的序列化框架,性能非常高,因为它是基于ASM字节码生成技术直接读写字段,不需要反射。但它有个比较大的坑是:Kryo的序列化结果和类的结构强绑定,类一改就很容易出现反序列化失败,所以缓存场景里用Kryo要特别小心版本变化。
Hessian是Dubbo默认使用的序列化协议之一,属于二进制RPC协议,跨语言能力比Kryo强,Java之外的语言也有实现。它不需要定义proto文件,直接对对象操作,用起来比较轻量。不过它的体积压缩率不如Protobuf。
3.3 选型速查
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| HTTP接口、日志、Redis缓存 | JSON(Jackson/fastjson) | 可读性好,调试方便,生态成熟 |
| 高并发RPC内部调用 | Protobuf、Hessian | 体积小、性能高 |
| Java应用内缓存序列化 | Kryo | 性能最强 |
| 跨语言、跨平台通信 | JSON、Protobuf | 都有多语言支持 |
| 跟浏览器打交道 | JSON | JavaScript原生支持 |
选型的时候别只看性能,还要考虑团队维护成本和兼容性。如果不是性能瓶颈明确指向序列化,大多数情况下JSON是性价比最高的选择。
4. 各语言中的序列化实战与避坑
4.1 Java:Serializable规范与fastjson使用注意
Java里实现序列化的标准姿势是实现Serializable接口,它是一个标记接口,不需要实现任何方法。实现之后,Java原生序列化机制就生效了,ObjectOutputStream可以把这个对象写出去。
但Java原生序列化有个明显的缺点:序列化结果里包含大量类元数据,体积很大,而且只有Java自己能用。所以实际项目中大部分场景都会绕过它,改用JSON或者其他框架。真正落到代码里,需要注意的事情就多了。
fastjson的序列化默认不会转义中文,直接输出汉字;而Jackson默认会把非ASCII字符转成\uXXXX。同样一个对象,两种库输出的字符串是不一样的,前端拿到之后需要做对应处理。凡是涉及到跨系统、跨语言的数据交换,最好统一序列化库和配置,否则两边比对结果不一致,排查起来非常痛苦。
泛型反序列化是另一个高频坑。把一个ListJSON.parseObject(json, List.class)拿到的其实是一个ListJSON.parseObject(json, new TypeReference<List<User>>(){})。Jackson那边同样是这个问题,对应的写法是TypeReference或者TypeFactory。
4.2 PHP:serialize/unserialize与Phar的坑
PHP里的serialize()和unserialize()是语言内置的序列化函数。序列化出来的格式是PHP自己的一套文本协议,包含类型标记和属性名,比如O:8:"stdClass":1:{s:4:"name";s:3:"Tom";}。这套格式在PHP内部很好用,但一旦用于对外接口,不同语言解析起来就很麻烦。
PHP反序列化的一个经典坑是:信任不可信输入。如果你把用户传进来的数据直接丢给unserialize(),攻击者可以通过精心构造的序列化字符串,在反序列化过程中触发某些魔术方法,比如__wakeup()、__destruct(),进而实现任意代码执行。这是PHP反序列化漏洞的根源。
另一个容易被忽略的入口是phar文件。phar是PHP的打包格式,phar文件里有一段存储元数据的地方,这段元数据本身就是一个序列化字符串。当代码里用file_exists()、is_dir()这些文件操作函数去处理一个phar://开头的路径时,PHP会自动反序列化phar文件里的元数据,从而触发反序列化漏洞。这就是广为流传的phar反序列化攻击。
4.3 Python:pickle与json的取舍
Python的pickle模块是用来做对象序列化的,用法很简单,pickle.dumps(obj)完事。pickle的麻烦在于它和Java原生序列化一样,反序列化的时候会执行对象里的构造逻辑,结果就是pickle非常不安全,绝对不要对不可信数据调用pickle.loads()。
有一个很讽刺的事实:大家常常拿pickle来演示反序列化攻击,因为构造一个恶意的pickle数据非常容易。安全最佳实践是:如果只是存配置、存普通数据,一律用json或者yaml,不要用pickle。
Python的json模块在处理日期、Decimal、自定义类对象时不能直接序列化,需要定义default函数把对象转换成可序列化的字典,反序列化时再写object_hook函数把字典还原成对象。这是Python里非常常规的套路,写的时候顺手把转换逻辑封装成一个工具类,能省去很多重复代码。
4.4 Spring Boot 4.0.x 消息序列化处理的变化
最近Spring Boot 4.0.x在社区里讨论很多,尤其是升级之后的消息序列化处理变化,让不少人踩了坑。4.0版本基于Spring Framework 7,底层默认的JSON序列化库从Jackson 2迁移到了Jackson 3,包名从com.fasterxml.jackson改成了tools.jackson。
这个变化的影响面比想象中大得多。很多老项目里配置了com.fasterxml.jackson包下的ObjectMapper,或者直接在代码里import com.fasterxml.jackson.databind.ObjectMapper,升级到Spring Boot 4.0.x之后直接编译不过。如果你在接口上用了@RequestBody、@ResponseBody,或者在Kafka、RedisTemplate里配了Jackson序列化器,都要检查一遍是否适配了新的包路径。
另外Spring Boot 4.0.x对消息转换器的自动配置也做了调整,以前通过spring.jackson.*配置的项,部分可能需要迁移到新的spring.codec.*或者Jackson 3对应的前缀。如果你是在定时任务里自己new了一个ObjectMapper,那也要确认依赖引入的是哪个版本,避免混用旧包和新包导致序列化行为不一致。
我的建议是:升级前先全局搜索一下代码里的Jackson包名引用,把依赖锁定和排除关系理清楚,再动手。别等到部署上线后才发现内网调用方和新版本序列化出来的结构对不上。
5. 反序列化攻击:原理、案例与防护
5.1 攻击到底是怎么发生的
反序列化攻击的原理,一句话概括就是:程序对不可信数据进行反序列化,攻击者构造特殊的数据,在反序列化过程中触发危险代码。
为什么这个攻击面这么严重?因为反序列化本身就是"把外部字节流变成内部对象"的过程,这个过程天然涉及类型判断、反射创建实例、字段赋值,甚至调用某些魔术方法。如果这个过程里混入了攻击者指定的类,而类里面又存在危险逻辑,整个系统就可能被攻破。
最常见的攻击目标是Java和PHP,因为这两门语言的反序列化机制功能强大,包含的类库丰富,很容易找到可以利用的"链条"。一个看似无害的序列化字符串,里面可以隐含地触发某个类的readObject()、__destruct()、__wakeup()等方法,一步步滑向代码执行。
5.2 从fastjson到Phar:典型案例分析
fastjson的AutoType功能曾经是重灾区。AutoType允许在JSON字符串里用@type字段指定要反序列化的类,比如{"@type":"com.example.Hacker","cmd":"calc"}。如果后端代码直接把这段JSON交给fastjson解析,fastjson就会按照@type指定的类创建对象并执行字段赋值。
早期版本的fastjson在解析过程中,某些类的setter方法里藏着危险行为,或者能够通过JNDI、RMI等机制加载远程类,最终实现命令执行。这就是当年fastjson 1.2.x系列漏洞频出的原因。官方后续推出了安全模式,关闭AutoType或者设置白名单,但很多旧系统升级不及时,依然暴露在风险中。
Phar反序列化的思路类似。前面提到,phar文件里的元数据会触发反序列化。攻击者可以构造一个包含恶意序列化数据的phar文件,然后诱导程序去执行file_exists("phar://恶意文件")这类操作。问题在于,代码里本来处理的是普通文件路径,没想到传入一个phar协议后,除了原本的文件操作,还额外触发了一场反序列化。
还有靶场平台Pikachu,里面专门做了反序列化漏洞模块,把PHP反序列化漏洞从原理到利用演示得清清楚楚。想系统学习这块知识的同学,直接在本地跑一个靶场练手,远比单纯读文章管用。
5.3 防护实践清单
防护反序列化漏洞,有几个非常落地的原则:
第一,永远不要反序列化不可信数据。用户输入、外部接口传过来的内容,默认都不可信。能用JSON、标准文本格式就别用原生序列化协议。
第二,如果必须使用原生反序列化,做严格的白名单校验。Java可以用ObjectInputFilter限制允许反序列化的类,PHP可以用unserialize()的第二个参数指定允许的类白名单,Python的pickle则尽量换成其他方案。
第三,保持组件版本最新。fastjson、log4j、Spring这些组件的历史漏洞大多在新版本中得到修复,老版本带着漏洞跑在生产环境,等于给攻击者留了一扇门。
第四,做好代码审计和漏洞扫描。反序列化漏洞往往在代码评审阶段就能发现,重点排查所有接收外部数据后直接反序列化的地方,以及项目里引用的组件是否在已知漏洞列表里。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| InvalidClassException | serialVersionUID不一致 | 对比序列化和反序列化两侧的类版本,显式声明UID |
| 反序列化后字段为null | transient关键字修饰,或JSON字段名不匹配 | 检查代码,确认序列化配置和命名策略 |
| List里取出来是嵌套Map | 泛型类型被擦除 | 使用TypeReference或TypeFactory指定泛型 |
| 中文变\uXXXX | Jackson默认转义非ASCII | 统一序列化库,或配置关闭转义 |
| Redis key/value乱码 | RedisTemplate用默认JDK序列化 | 配置Jackson或GenericJackson2JsonRedisSerializer |
| 日期格式不一致 | 不同库默认日期格式不同 | 全局配置日期格式,传输统一时间戳 |
| 循环引用导致栈溢出 | 对象互相引用 | 配置循环引用处理策略,如fastjson的循环引用检测 |
| 反序列化后类型错误 | JSON里没存类型信息 | 反序列化时显式指定目标类型 |
6.2 两个排查实例
第一个是fastjson序列化中文输出问题。有次对接一个老系统,对方接口返回的JSON里中文直接显示为明文,而我们这边用Jackson解析后一切正常。但两个系统日志做对比时发现,同一个对象在写入数据库前,fastjson输出的是{"name":"张三"},而Jackson输出的是{"name":"\u5f20\u4e09"}。两边前端拿到数据都能正常渲染,但很多字符串比对、签名校验的逻辑就会出问题。排查到最后就是序列化库的差异,直接在配置里关掉Jackson的非ASCII转义,两边格式就统一了。
第二个是Redis缓存反序列化报错。新接手的一个项目,Redis里缓存的是用户Session,用JDK原生序列化写的。后来升级到Spring Boot,默认的RedisTemplate换成了JDK序列化,但由于某个类加了一个字段,且没声明serialVersionUID,导致老缓存全部反序列化失败。修复方案是两步:第一步把RedisTemplate的序列化方式统一改成Jackson JSON序列化;第二步上线前清理掉存量缓存,避免老数据和新序列化方式冲突。
7. 最后分享几个压箱底的经验
写序列化代码这么多年来,我个人的体会是:先把序列化方案当成接口契约的一部分来对待,而不是随手选一个。跨服务传递的对象,字段的增删改都要有版本意识,不要想着"反正都是JSON,加个字段不是很容易吗"。加字段容易,但消费方老版本还没升级完的时候,新字段处理不当就会导致反序列化失败。
再分享一个小技巧:在日志里打印对象时,重写toString()方法,用JSON格式输出。这一招在排查序列化问题时特别管用,对比输入输出格式一目了然。别小看这个习惯,它能帮你节约大量排查时间。
最后,不管用哪种序列化技术,把安全放在第一位。你以为只是给内部系统写个接口,结果暴露到公网上被人扫了一遍反序列化漏洞,这个代价谁都兜不住。该加的防护别省,该升级的版本别拖。序列化这根弦绷紧了,线上能少很多幺蛾子。
