
你有没有遇到过这种场景同一套代码换个环境Redis 里明明存了数据读出来却直接抛ClassNotFoundException或者线上更新了一个实体类老数据全部读取失败最后发现是serialVersionUID惹的祸。这两个问题根子都在 Java 序列化。序列化听起来是个老生常谈的基础概念但真正在工作中能用明白的人并不多。大部分人的认知停留在“实现Serializable接口就能存 Redis 了”至于底层协议怎么组织的、字段增删改之后老数据还能不能读、反序列化为什么有漏洞、Redis 默认序列化器为什么吃内存这些细节往往要到线上翻车了才去查。今天这篇就把 Java 序列化这套机制从头到尾扒一遍。会覆盖默认协议的底层流程、serialVersionUID的兼容性规则、transient与自定义序列化的实战姿势、反序列化安全漏洞的根源和防护最后再聊聊框架里的序列化选型以及面试里那些高频问题的标准答法。适合做 Java 后端、搞微服务、维护缓存和 RPC 场景的朋友准备面试的也可以重点看第 6 章。1. 序列化到底干了什么从字节流反推对象图1.1 为什么偏偏要自定义一套二进制协议序列化的本质是把对象转换成可以传输或存储的字节流。Java 的对象是活在 JVM 堆里的进程一结束就没了。想让对象跨越时间持久化到文件、Redis和空间通过网络发给另一台 JVM就必须把对象“拍扁”成字节过去之后再“立起来”。但这里有个问题为什么 Java 不直接用 JSON 或 XML 来干这件事而是非要自己搞一套二进制协议因为 JSON 只是记录了对象的“数据形状”字段名加字段值但 Java 对象图里还有更微妙的东西——类型信息、继承关系、字段的可访问性以及最重要的对象之间的引用关系。举个例子A 和 B 两个对象互相引用形成了一个环。用 JSON 序列化不小心就会无限递归到最后只能自己处理循环引用。而 Java 原生序列化协议在写对象时会为每个对象分配一个句柄handle第一次遇到某个对象时写入完整数据之后再有引用指向它只写一句“复用之前的句柄号”天然解决了循环引用问题。协议的魔数也很有意思。任何一个 JDK 原生序列化产生的字节流开头固定是AC ED 00 05AC ED是流标识00 05是版本号。看到这个开头基本就能判断这是 Java 序列化的产物。1.2 writeObject 和 readObject 的执行细节很多文章讲序列化只讲“实现了接口就能用”但底层是怎么跑的没几个人能说清楚。我简单梳理一下ObjectOutputStream.writeObject()的完整链路。第一步检查对象是否实现了Serializable或Externalizable没有就抛NotSerializableException。第二步向流里写入类描述包括类名、serialVersionUID、字段列表和字段类型。第三步递归遍历对象的所有实例字段遇到非transient字段就写入值。这里要注意遍历不仅包括当前类自己的字段还包括所有可序列化父类的字段。如果对象里有自定义的writeObject方法框架会优先调用它让开发者自己控制写入逻辑。如果没有就使用默认机制逐字段写。整个过程中每遇到一个对象就分配一个句柄并记录到句柄表循环引用就靠这个表来维持。反序列化是反过来的过程ObjectInputStream.readObject()读入类描述然后根据类描述加载 Class 对象再分配内存并填充字段。这里有一个面试必问的点反序列化不调用构造函数也不执行类初始化块而是通过 native 方式直接在内存里把对象“拼”出来。所以哪怕类的构造函数里有复杂的初始化逻辑反序列化也不会走直接拿字节流里的字段值覆盖到新对象上。这也解释了为什么有些类字段被声明成final反序列化仍然能“绕过去”给它赋值——因为分配内存和赋值字段的机制完全绕开了 Java 常规语法层的限制。还需要注意一点如果父类不可序列化但子类可以那反序列化子类时父类部分的字段不会从字节流恢复而是通过父类的无参构造器重新初始化。如果父类连无参构造器都没有直接报错。这个坑在多层继承的实体类里非常常见。1.3 序列化对象图里的边界情况对象图深了以后问题就来了。默认序列化是递归遍历字段的如果一个对象链条特别深比如链表结构有几万个节点序列化时会递归调用writeObject深度过大可能触发StackOverflowError。类似地字段里包含大批量集合对象时先估算一下规模别把几百万条数据的 List 直接扔给 JDK 序列化内存和 CPU 都可能扛不住。另一个容易被忽略的点是枚举类型。枚举类天然可序列化而且 JVM 对枚举的序列化做了特殊处理只序列化枚举常量的 name反序列化时通过valueOf找对应常量。这就是为什么推荐用枚举而不是int常量来表示固定状态一方面代码可读性好另一方面序列化也稳定——即使枚举类后面加了新常量老数据也能正常读。2. serialVersionUID版本身份证能不省就不省2.1 不写 serialVersionUID 会怎样实体类实现Serializable后如果没显式声明serialVersionUID在序列化时 JVM 会根据类的结构包括类名、接口、字段名、字段类型、方法签名、修饰符等自动计算出一个 64 位的哈希值作为版本号。问题在于这个哈希值太敏感了。你往实体类里加一个字段哈希值就变了删一个方法哈希值又变了甚至改了某个字段的修饰符也可能变。而反序列化的时候字节流里记录的是序列化当时算出来的 UID如果读的时候类结构已经变化、UID 对不上就会抛InvalidClassException提示本地类 UID 和流中的 UID 不一致。我之前有个项目就吃过这个亏。某次给订单实体加了个备注字段顺手就上线了。结果 Redis 里的缓存全部读取失败查了半天日志看到的就是一句冷冰冰的InvalidClassException。原因很简单没写serialVersionUIDDSL 自动生成的 UID 因为字段变更而改变新代码拒绝老数据。所以我的建议非常直接所有实现Serializable的类第一行就写死serialVersionUID。用 IDE 自动生成也行用一个固定的常量也行但必须显式声明不要把版本号交给 JVM 去算。2.2 类字段变动与兼容性速查表既然显式声明了 UID类结构改动时到底哪些兼容、哪些不兼容我把平时总结的规则列成一张表方便直接参考变更类型是否兼容说明增加字段兼容旧数据反序列化后新字段取默认值0、null、false 等删除字段兼容旧数据里的对应值会被忽略class 里已经没有这个字段了修改字段类型不兼容字节流里的数据和类定义对不上直接异常增加方法可能不兼容如果是默认 UID方法签名会影响哈希显式 UID 则无影响修改方法体兼容序列化只关心字段方法逻辑不参与字节流类名或包名改变不兼容类描述对不上等同于全新的类字段改为 transient兼容序列化时该字段不再写入老数据里如果有值反序列化后被忽略修改 serialVersionUID不兼容两边 UID 必须严格一致这是硬性校验增加自定义 writeObject/readObject兼容但要保证自定义逻辑能正确处理老格式的流这张表要注意的是兼容不代表一切安好。加字段后老数据读出来的新字段值是默认值如果业务对它有依赖就必须做空值兜底。删字段倒相对安全因为字节流里多余的数据会被跳过。2.3 线上版本升级的最佳实践实体类一旦暴露给序列化场景比如进 Redis、进 MQ、写入文件改动前就要有评估的习惯。我现在的基本流程是改类结构前先看一眼这个类有没有进过线上缓存如果进过先分析老数据的 UID 和新类的 UID再决定是加默认值兼容还是直接清理缓存。JDK 自带了一个小工具叫serialver可以快速查看一个类的 UID。命令行直接serialver -show会弹出一个图形界面输入类全名点回车就能看到。在写代码的时候我不会依赖它通常是 IDE 里生成好 UID版本管理时留意一下差异就行。还有一种非常隐蔽的情况实体类没有显式声明 UID但某个用了反射工具包比如 Commons Lang 的SerializationUtils的组件会自动序列化并缓存对象。这时候你对实体类做了看似无害的改动但 UID 变了老的缓存数据全部无效。排查起来会更难因为报错的入口不在你的业务代码里。3. transient、static 与自定义序列化的坑3.1 transient 标记的字段transient关键字的作用是告诉序列化机制这个字段不要给我写进字节流。默认的序列化逻辑遍历字段时会跳过它反序列化时它保持默认值比如对象引用是 null、int 是 0。什么时候该用transient最典型的是敏感信息。假如一个用户对象要落盘或进 Redis里面有密码字段直接序列化就是明文暴露这时候一定要加transient让密码不参与持久化。另一个场景是缓存字段有些对象里存着一份拉取耗时的大数据本地缓存完全可以从主数据重新算出来没必要跟着序列化一起存。但要注意transient只是说默认序列化不处理它如果你自己写了writeObject想在流里手动加一点东西线程既往不咎——你完全可以把一个transient字段加密后写进流里读的时候再解密恢复。这是自定义控制的灵活性所在。3.2 static 字段不参与序列化static字段不属于任何一个对象实例它属于类本身。默认的序列化机制只保存实例状态所以静态字段永远不会进字节流。反序列化之后静态字段保持当前 JVM 里已有的值不会因为序列化而改变。有些人会误以为序列化能“保存全局状态”把系统配置放在一个静态字段里打算存完再读出来。实际效果是存的时候静态字段一点没写进字节流读的时候拿到的还是进程当前内存里的值完全不是在另外一台机器上存的那个值。不过跟transient一样如果你在自定义的writeObject里手动去读静态字段Java 也允许你把它写进去。反过来在readObject里手动设置静态字段也是合法的。但这已经是把一个“类级状态”强行塞进“实例字节流”里的脏操作常规业务没必要这么干。3.3 为什么 ArrayList 要重写 writeObjectArrayList的源码里有一个非常经典的例子可以说明自定义序列化的价值。ArrayList内部用一个Object[] elementData数组存元素用一个int size记录实际元素个数。数组是申请了容量的可能容量是 16 但实际只有 3 个元素后面 13 个位置全是 null。如果用默认序列化把整个数组写进流里等于把 13 个 null 也一起存了数组越大浪费越严重。所以ArrayList自己实现了writeObject先写size然后循环把前size个元素逐个写入。readObject则先读size再按实际大小创建数组并填充。elementData数组本身被标成了transient默认机制不会写整个数组一切由自定义逻辑控制。这个例子告诉我们自定义序列化的核心动机有两个一是精简字节流体积二是对敏感或临时字段做特殊处理。任何内部结构存在“冗余容量”的集合类都值得考虑类似的重写比如 HashMap 的 bucket 数组、StringBuilder 的 char 数组其实都有对应的序列化定制。3.4 Externalizable 与 readResolveSerializable接口本身是个标记接口所有序列化细节让 JVM 自动处理。如果你想完全掌控序列化格式可以实现Externalizable接口它要求实现两个方法writeExternal和readExternal所有字段的读写都由你手工完成默认机制不参与。用Externalizable有几个硬性要求必须提供一个 public 无参构造器否则反序列化时会因为无法实例化对象而报错。这个约束跟Serializable不同Serializable反序列化不调用构造器而Externalizable会调用无参构造器创建对象再调用readExternal填充字段。readResolve是另一个可以玩的机制。反序列化完成后JVM 会检查有没有readResolve方法如果有就用它的返回值替换掉刚反序列化出来的对象。典型场景是单例类和枚举类它们通过readResolve保证反序列化结果还是单例那个实例而不是新建了一个对象。4. 反序列化漏洞字节流里的暗门4.1 反序列化的攻击面在哪反序列化漏洞在安全圈已经被讲烂了但很多开发者知其然不知其所以然。根源在于ObjectInputStream.readObject()的工作方式它从字节流里读出一个类描述然后加载这个类、创建实例、并调用它可能存在的readObject方法。问题在于这个流程是完全“由数据驱动”的。攻击者可以构造一段恶意字节流里面写的是某个特定类的描述和数据一旦应用调用了readObject就会加载并触发这个类的某些方法。如果这个类的readObject或它调用的hashCode、equals、toString等方法里存在危险逻辑就能形成一条利用链。这也是为什么很多工具类库历史上频频爆出反序列化漏洞——比如一些老版本的 Apache Commons Collections、Spring 相关的 jar或者某些 JSON 库的自动类型开关。它们的内部类在反序列化时可能被劫持触发执行恶意逻辑。靶场里常见的pikachu反序列化漏洞实验本质上就是让学习者自己构造恶意对象观察服务端被触发后的异常和回显。对这个原理有了直观认知防护起来才会自觉。4.2 实际防护手段有哪些第一原则绝对不要反序列化来源不可信的字节流。很多系统在 MQ 消息、HTTP body、文件上传、第三方接口回调里接了反序列化这属于高危入口。真有这种需求优先换成 JSON 这类结构化文本协议因为 JSON 解析器不会自动实例化任意类。如果确实绕不开 JDK 序列化可以使用ObjectInputFilter设置反序列化过滤条件。Java 9 之后可以用objectInputStream.setObjectInputFilter(...)或者通过 JVM 参数-Djdk.serialFilter...配置全局过滤。简单的白名单模式是只允许指定包名下的类其余全部拒绝。另外JSON 库的自动类型识别也是一个敏感开关。以前有几个版本的 fastjson 默认开启了autotype反序列化时会根据传入的type字段直接实例化指定类安全隐患极大。现在正确做法是关掉自动类型至少要对类型做白名单校验而且最好直接升级到 fastjson2 或者使用 Jackson 并配置安全的DefaultTyping策略。4.3 排查反序列化异常的思路生产环境如果突然出现大量ClassNotFoundException或InvalidClassException不要只盯着“缺类”或者“版本不一致”看。先检查反序列化入口的数据来源是不是可控的再看异常里出现的类名是不是项目里根本没有的第三方类。如果出现这种情况很可能就是有人在拿构造好的恶意序列化字节流探测你的系统。日志里出现莫名其妙的类名第一反应应该是封禁来源、排查入口而不是急着补一个依赖进去。5. 框架里的序列化选型从 Redis 到 JSON5.1 Redis 默认的 JDK 序列化问题很多人第一次用RedisTemplate的时候会发现一个现象用 redis-cli 连上去key 是一串带前缀的乱码value 是一大段二进制完全没法看。这是因为 Spring Data Redis 默认用的是JdkSerializationRedisSerializervalue 会生成AC ED开头的 JDK 序列化字节。这种默认方案最大的问题是体积。JDK 序列化会把类描述、字段表、句柄表全部写进字节流一个小对象可能膨胀好几倍。缓存一多Redis 内存相当吃紧。其次是没有跨语言能力Java 写进去的数据其他语言根本读不了。再者如果实体类改了字段老缓存直接报废这类问题在前面章节已经说过了。所以我的建议是Redis 里存的数据除非有特殊原因否则不要用 JDK 序列化器。优先考虑 JSON 序列化至少可读、可调、跨语言。5.2 配置 JSON 序列化的完整代码在 Spring Boot 项目里可以把RedisTemplate的 value 序列化器换成 JSON 方案。GenericJackson2JsonRedisSerializer是比较好用的选择它会在 JSON 里额外写一个class字段记录真实类型反序列化时能直接还原成原来的类。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 用 String 序列化器避免乱码 StringRedisSerializer keySerializer new StringRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); // value 用带类型信息的 JSON 序列化器 GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这个配置在实际项目里非常常见但有几个坑要提前说。第一泛型丢失问题。如果某个接口的返回值是ListOrder这种泛型GenericJackson2JsonRedisSerializer反序列化回来往往是ListLinkedHashMap强转回ListOrder会直接ClassCastException。解决办法是业务上单独封装类型信息或者干脆在缓存存字符串、取出时再手动用 ObjectMapper 转换。第二时间类型问题。Jackson 默认序列化LocalDateTime时会输出数组形式比如[2025, 1, 10, 15, 30, 0]非常难读。需要在 ObjectMapper 里注册 JavaTimeModule 并设置日期格式但GenericJackson2JsonRedisSerializer内部有自己的 ObjectMapper要想完全自定义格式得继承这个类重写构造方法。第三对象类型问题。JDK 的 record 类、私有内部类等JSON 反序列化时可能因为缺少无参构造器或字段不可访问而报错。这也是为什么推荐“多存 DTO少直接操作实体类”的原因。5.3 各序列化方案的对比序列化方案选型本质是在体积、速度、跨语言、可读性之间做取舍。我整理了一个常用方案的对比方案跨语言体积速度可读性适用场景JDK 原生差大慢差短生命周期、小对象图Jackson JSON好中中好Redis 缓存、HTTP 接口fastjson2好中较快好国内项目常见注意配置安全Protobuf好小快差高并发 RPC、跨语言服务Kryo差小快差Java 内部高吞吐需注册类Hessian2中较小快差Java 服务间 RPC老牌方案这里顺带提一个 fastjson 使用中的小细节默认情况下JSON.toJSONString会对字符串值里的双引号、反斜杠、换行符做转义因为要生成合法 JSON。如果业务要求序列化后的字符串里不包含转义字符需要自定义序列化器或调整相关特性但我不推荐为了“输出好看”就关掉转义和安全检测合规格式比看顺眼重要得多。6. 面试题速查与一次线上故障复盘6.1 高频面试题清单与参考答案序列化是 Java 面试里的常客我把高频问题整理成了一份速查表每个问题给一个核心回答方向方便查漏补缺。面试题核心回答要点serialVersionUID有什么用不写行不行用于版本兼容校验不写会按类结构自动生成结构一变 UID 就变反序列化可能失败反序列化为什么不走构造函数通过 native 方式直接在内存分配对象再填充字段绕开构造逻辑父类不可序列化时才会调父类无参构造器transient关键字的作用默认序列化时跳过该字段反序列化后保持默认值常用于敏感字段和临时缓存哪些字段不会参与序列化static和transient修饰的字段默认情况下都不参与ArrayList 为什么自己实现 writeObject内部数组容量可能大于实际元素数直接序列化整个数组会浪费大量空间自定义后只写实际元素如何实现深拷贝序列化重写对象图可以做到但要求所有字段可序列化、static/transient字段丢失更推荐手工 clone 或工具库Java 原生序列化和 JSON 的区别原生序列化保留类型信息、支持循环引用、体积更大JSON 直观、跨语言、体积相对可控反序列化漏洞的根源和防护根源是readObject可自动实例化任意类并触发方法防护核心是不反序列化不可信数据配合白名单过滤、升级依赖回答这些题目时最好能结合一两句实际工作场景比如“我之前就因为没写 UIDRedis 缓存全部读不出来”比背定义有用得多。6.2 反序列化失败常见异常速查线上出了问题先看异常类型能快速定位方向。异常类型常见原因排查方向InvalidClassExceptionUID 不一致类结构变更对比新旧类 UID检查缓存是否需要清理ClassNotFoundException字节流里的类在本地不存在检查依赖缺失或数据来源是否可信StreamCorruptedException字节流被破坏或协议不对确认数据是否被截断、是否用了别的序列化器OptionalDataException流里写入的数据类型和类定义不匹配检查自定义 writeObject 与 readObject 是否对称NotSerializableException对象或其某个字段未实现 Serializable检查字段类型必要时加 transient 或实现接口这里有个排查心得日志里如果频繁出现ClassNotFoundException而且类名看起来不像自己项目里的先别急着加依赖优先怀疑是不是有人在向你的反序列化入口发送恶意数据。判断方法很简单看一下这些异常是不是都集中来自同一个来源 IP 或同一个消息队列。6.3 线上故障复盘缓存读不出来的那半小时最后讲一个我印象很深的故障。某个订单服务实体类实现了Serializable但没写serialVersionUID。因为懒当时所有人都觉得“反正 IDE 会自动生成没事的”。某天业务要加一个couponAmount字段改完代码测试环境一切正常因为缓存很快被新数据覆盖了。但上了生产Redis 里还躺着大量前一天的老缓存。请求进来新代码做反序列化发现流的 UID 和当前类的 UID 不一致所有命中缓存的请求全部抛InvalidClassException。当时的处理很狼狈先定位到异常堆栈确认是反序列化失败用serialver对比新老 UID确认是字段变更引发的临时方案是让 Redis 缓存 key 加一个版本号一夜之间全部自然过期长期方案是给所有Serializable类补上显式serialVersionUID。好在当时缓存里的数据不是核心强依赖只是性能优化用的。如果是会话数据或者未落库的交易中间态后果会严重很多。这个事之后我的实体类规范里多了一条第一行必须是private static final long serialVersionUID 1L;没有例外。序列化看起来是 Java 最基础的能力但越基础的东西翻车影响越大。我现在的习惯是进 Redis、MQ、RPC 传输的对象能不用 JDK 序列化就不用接口之间用 DTO不直接裸传实体每建一个可序列化类先补 UIDJSON 库的版本和安全开关盯紧一点。希望这篇能帮你把 Java 序列化这块的坑提前填起来别再让线上告警教你做人了。