🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介:28届校招生,后端开发/Agent 方向在学
📚 学习内容:Java、Python、计算机视觉、大语言模型、Agent开发
📝 专栏内容:从零开始的Claude Code零代码生活(持续更新中)
不只背八股,更想搞懂为什么这样设计

前言

前段时间刷到一篇讲 Java 序列化的文章,评论区有一句话让我印象挺深:

对象在内存里本来就是二进制数据,为什么不能直接把这块内存写到磁盘里?

这个问题其实比“什么是序列化”更接近本质。

因为很多人在解释序列化时,都会说一句:序列化就是把对象转换成字节流,方便存储和传输。

这句话当然没错,但它只回答了“序列化做了什么”,没有回答“为什么非得这么做”。

如果只是为了变成二进制,那对象在内存里本来就是二进制;如果只是为了写文件,操作系统也确实可以把一段内存写入磁盘。那为什么我们还需要专门设计一套序列化机制?

我一开始也被这个问题卡住过。后来才慢慢意识到,序列化真正解决的并不是“如何把对象变成字节”,而是:

如何让一份数据脱离当前进程、当前机器、当前语言运行环境之后,仍然能够被正确理解和还原。

一、先把误区说清楚:对象本来就是二进制,为什么还要序列化?

先承认一点:这个疑问是合理的

对象在内存里确实是二进制数据,文件最终保存的也是二进制数据。从这个角度看,“把对象转成字节流再保存”好像多此一举。

但问题在于:内存里的二进制,不等于一份稳定、可长期保存、可跨环境理解的数据。

对象在内存中的样子,是为“当前程序运行”服务的;而序列化后的数据,是为“以后还能读回来,别人也能读懂”服务的。这两者目标不一样。

最典型的问题就是对象引用

比如一个 User 对象里有一个 Address 对象:

class User {
    String name;
    Address address;
}

在 Java 代码里,我们看到的是 user.address,感觉它们是一个整体。但在内存里,User 对象里保存的并不是完整的 Address 内容,而是一个指向 Address 对象的引用。

这个引用本质上和“地址”有关。

如果我们直接把 User 那块内存原封不动写到磁盘里,也就把这个引用值一起写进去了。问题是,下次程序启动时,原来的内存地址大概率已经没有意义了。它可能被别的数据占用,也可能根本不是一个合法地址。

所以直接保存内存数据,看似保存了对象,实际上保存的是一堆只在当时有效的运行时信息。

这就像你记住了一个人昨天住的酒店房间号,但第二天酒店已经重新安排入住了。房间号还在,可它已经不能代表昨天那个人了。

序列化要做的事情,就是把这种“依赖内存地址的关系”转换成“稳定的数据结构”。

它不会保存 address 在内存中的地址,而是保存:

{
  "name": "张三",
  "address": {
    "city": "杭州",
    "street": "xxx路"
  }
}

这样一来,数据就脱离了那一次程序运行时的内存布局。下次读取时,程序可以重新创建 UserAddress 对象,再把它们之间的关系恢复出来。

除了引用问题,直接保存内存数据还有平台差异问题

不同 CPU 架构可能有不同的字节序。比如一个多字节整数,在某些机器上低位字节放前面,在另一些机器上高位字节放前面。如果直接保存内存中的原始排列,换一台机器读取时,就可能解析出完全不同的值。

还有结构体填充和内存对齐问题。

为了提高访问效率,编译器或运行时可能会在字段之间插入一些空白字节。不同平台、不同编译器、不同运行时环境,对这些细节的处理不一定一样。你直接保存内存布局,就等于默认未来读取它的环境和现在完全一致。

但现实里,数据经常要跨机器、跨版本、跨语言,甚至跨很多年。

类型大小也类似。某些语言或平台里,long 可能是 4 字节,也可能是 8 字节。如果没有明确的数据格式约定,只保存内存中的原始内容,读取方很难知道这几个字节到底应该怎么解释。

所以这里真正的分界点是:

内存数据是运行时表示,序列化数据是交换和存储表示。

前者追求的是程序运行时访问高效,后者追求的是稳定、明确、可还原。

这也是为什么“对象本来就是二进制”这句话没错,但它不能推出“可以直接保存对象内存”。

因为我们要保存的不是某一刻内存里的样子,而是对象所表达的数据和关系。序列化的价值就在于:把依赖运行环境的对象,转换成一份有明确规则的数据格式

二、那序列化到底做了什么?

理解了“为什么不能直接保存对象”之后,再看序列化就没那么玄乎了。

它不是简单地把对象复制一份,也不是随便把内存里的 0 和 1 倒出来,而是按照一套规则,把对象中真正有意义的信息整理出来。

大体上,序列化做了三件事。

第一件事,是把对象里的引用关系变成可描述的数据关系

还是前面的例子:

class User {
    String name;
    Address address;
}

在程序运行时,user.address 背后依赖的是对象引用。可一旦对象要离开当前进程,这个引用就不能直接用了。

序列化时,保存的不是 Address 对象在内存中的位置,而是它包含的数据,以及它和 User 之间的关系。

换句话说,序列化关心的是:

User 有一个 name
User 有一个 address
Address 里有 city、street 等字段

而不是:

User 在内存地址 A
Address 在内存地址 B
User.address 指向 B

前一种信息离开当前程序后仍然有意义,后一种信息只在这次运行时有效。

第二件事,是把数据转换成约定好的格式

比如同样是一个用户对象,可以被序列化成 JSON:

{
  "name": "张三",
  "age": 18
}

也可以被序列化成 Protobuf、Hessian,或者 Java 原生序列化格式。

这些格式长得不一样,但目标类似:让写入方和读取方都知道,哪些字节表示字段名,哪些字节表示字段值,哪些字节表示类型信息,哪些字节表示对象结构。

没有这层约定,字节本身是没有意义的。

比如磁盘里有一段内容:

00 00 00 12

它可以是整数 18,也可以是长度,也可以是某个字段的一部分。到底怎么解释,取决于背后的格式规则。

序列化格式的作用,就是给这些字节加上“说明书”。

第三件事,是让对象可以被重新构建

反序列化并不是把旧对象“搬回来”,而是根据保存下来的数据,重新创建一个新的对象,再把字段值和对象关系填进去。

所以准确一点说:

序列化保存的是对象的状态,反序列化恢复的是一个等价的新对象。

这个“等价”很重要。

反序列化后的对象,内存地址通常已经变了,运行时环境也可能变了,但只要它表达的数据和关系一致,就达到了目的。

这也是为什么不同序列化方案会有不同取舍。

JSON 更适合人阅读和调试,但体积相对大,类型信息也没那么强。Protobuf 依赖 .proto 文件定义结构,可读性差一些,但体积小、解析快,也更适合 RPC 场景。Java 原生序列化用起来省事,但可控性、安全性和兼容性都比较差。

三、实际工程中怎么选择序列化?

把原理讲清楚之后,问题就会落到工程里:既然序列化方案这么多,那实际项目里到底该怎么选?

先说一个结论:现在实际项目中,很少会直接使用 Java 原生序列化。

这里的“Java 原生序列化”,指的是实现 Serializable 接口,然后通过 ObjectOutputStreamObjectInputStream 这一套机制把对象写出去、读回来。

它的优点很明显:上手简单。

class User implements Serializable {
    private String name;
    private Integer age;
}

看起来只要加一个接口,对象就能被序列化了。早期很多 Java 项目、缓存组件、RPC 框架都用过它。但如果放到现在的工程环境里,它的问题也很突出。

1. Java 原生序列化为什么不太推荐?

第一个问题是强绑定 Java 语言

Java 原生序列化出来的数据,基本只能由 Java 自己理解。
如果你的数据只在一个 Java 程序内部短暂流转,这个问题可能还不明显;但现在大多数系统都不是单体应用了,一个后端服务可能要和 Go 服务通信,也可能要给前端、移动端、数据平台消费。

这时候如果你丢过去一坨 Java 原生序列化后的二进制数据,对方根本没法方便地解析。

也就是说,它不适合跨语言协作。

第二个问题是可读性差,排查问题不方便

JSON 序列化后的内容大概长这样:

{
  "name": "张三",
  "age": 18
}

哪怕程序出问题了,复制出来看一眼,大概也知道字段有没有传错。

但 Java 原生序列化后的内容是一段二进制流,人基本读不懂。线上接口、缓存、消息队列一旦出现数据异常,排查成本会高很多。

当然,不是所有二进制序列化都不好。Protobuf 也是二进制格式,但它有明确的 schema,有成熟的工具链,有跨语言支持。Java 原生序列化的问题在于:它更多是围绕 Java 对象本身设计的,而不是围绕系统之间的数据交换设计的。

第三个问题是版本兼容比较麻烦

Java 原生序列化会关心类名、字段、serialVersionUID 等信息。
如果类结构发生变化,比如新增字段、删除字段、修改字段类型,就可能出现反序列化失败。

很多人应该见过这个异常:

java.io.InvalidClassException

它经常和 serialVersionUID 有关。

你当然可以手动指定 serialVersionUID,也可以小心控制字段变化,但这件事本身说明了一个问题:Java 原生序列化和 Java 类结构耦合得太紧了。

而在真实项目里,DTO、缓存对象、消息对象是会不断演进的。今天加一个字段,明天改一个类型,后天拆一个对象。如果每次类变动都担心旧数据还能不能反序列化,维护成本会很高。

第四个问题是性能和体积都不算优秀

Java 原生序列化为了保存完整的对象信息,会写入不少额外元数据。它追求的是“尽量自动地还原 Java 对象”,而不是“尽量小、尽量快”。

在高并发 RPC、缓存、大量消息传输这些场景里,序列化和反序列化的性能会直接影响接口耗时、网络传输、CPU 消耗。
这也是为什么很多框架后来都会选择 Hessian、Kryo、Protobuf、Fastjson、Jackson 等方案,而不是继续依赖 Java 原生序列化。

第五个问题,也是最容易被忽视的问题:安全风险。

Java 反序列化漏洞在安全领域已经是老生常谈了。
如果服务端反序列化了不可信来源的数据,而 classpath 里又存在某些可以被利用的类,就可能被构造出危险的调用链。

简单说就是:你以为自己只是在“读一个对象”,但攻击者可能借这个过程执行了不该执行的逻辑。

所以很多公司会明确限制,甚至禁止在对外接口、消息消费、缓存读取等场景中使用 Java 原生反序列化。

写到这里,并不是说 Serializable 一无是处。它在某些非常受控的内部场景里还能用,比如本地临时存储、一些老系统兼容。但如果是新项目,尤其是涉及服务通信、缓存、消息队列、跨语言调用,就不建议优先考虑它。

更稳妥的思路是:先看场景,再选格式。


2. 接口返回:优先考虑 JSON

如果是 HTTP 接口,尤其是给前端、移动端、第三方系统调用的接口,最常见的选择还是 JSON。

比如 Spring Boot 项目里,通常会用 Jackson 把对象转换成 JSON:

@RestController
public class UserController {

    @GetMapping("/user")
    public UserVO getUser() {
        return new UserVO("张三", 18);
    }
}

返回结果大概是:

{
  "name": "张三",
  "age": 18
}

JSON 的优势不是性能最强,而是通用、直观、好调试

前端能直接处理,Postman 能直接看,日志里打印出来也能读。
对于大多数管理后台、普通业务接口来说,这一点比极致性能更重要。

当然,JSON 也有缺点。它体积偏大,字段名会重复出现;类型表达能力也有限,比如时间、枚举、精度比较高的数字,都需要额外约定。
但在普通 Web 业务里,这些问题通常可以接受。

所以我的理解是:只要不是性能特别敏感的内部通信,JSON 仍然是最稳的默认选项。


3. RPC 调用:更适合 Protobuf 这类强 schema 方案

如果是服务和服务之间的高频调用,就不能只看“好不好读”了,还要看性能、体积、跨语言能力和接口约束。

这类场景里,Protobuf 会更常见。

它的核心不是“二进制”三个字,而是 .proto 文件提供了一份明确的结构定义。

message User {
  string name = 1;
  int32 age = 2;
}

这个定义有点像接口契约。
Java 服务、Go 服务、Python 服务都可以根据同一份 .proto 生成自己的代码。大家不用猜字段叫什么,也不用猜字段类型是什么。

相比 JSON,Protobuf 的数据体积通常更小,解析速度也更快,而且字段编号让它在版本演进时更有秩序。比如新增字段时,只要不复用旧编号,老服务通常也能忽略自己不认识的新字段。

这点在微服务里很重要。

因为线上服务不可能每次都同时发布。A 服务升级了,B 服务可能还没升级。如果序列化协议对版本变化太敏感,就很容易出现调用失败。

RPC 场景的重点是:

服务之间能不能长期稳定地按同一份契约通信。

这也是为什么 gRPC 常和 Protobuf 一起出现。它们解决的不是单个对象怎么保存,而是服务之间怎么高效、稳定、可演进地通信。


4. 缓存数据:优先考虑可维护性,再考虑性能

缓存里的序列化选择比较容易纠结。

比如 Redis 里要存一个用户信息,可以存 JSON:

{
  "id": 1001,
  "name": "张三",
  "vip": true
}

也可以存二进制格式,比如 Kryo、Protobuf、Hessian。

如果是业务缓存,我个人更倾向先考虑 JSON。原因很简单:出了问题好排查。

线上排查 Redis 数据时,看到 JSON 至少能知道当前缓存里存了什么。
如果是一段二进制,排查就麻烦很多,需要对应的类、对应的版本、对应的反序列化工具。

不过,如果缓存量很大、访问频率很高,JSON 的体积和性能可能就会成为问题。这时候再考虑 Protobuf、Kryo 这类更紧凑的方案。

这里有个很实用的判断方式:

缓存是给人排查更多,还是给机器高频读写更多?

如果是普通业务缓存,JSON 的可读性很值钱。
如果是高吞吐、低延迟、数据量巨大的缓存,二进制协议会更合适。

另外,缓存对象一定要注意版本兼容。

很多线上问题不是“序列化失败”,而是“代码已经发版了,Redis 里还留着旧结构的数据”。
比如原来字段叫 userName,后来改成 name,新代码读取旧缓存时就可能出现空值。

所以缓存里的对象尽量不要直接复用复杂的业务实体,更推荐单独定义缓存 DTO,并且对字段变化保持克制。


5. 消息队列:重点是兼容性和可演进

消息队列里的数据和接口返回不一样。

接口请求失败了,调用方可以重试;但 MQ 里的消息可能会堆积,可能会延迟消费,也可能在几天后才被某个消费者处理。

这意味着消息格式必须足够稳定。

如果用 JSON,好处是简单直观,排查消息内容也方便。很多业务系统一开始都会这么做。
但随着消费者变多、语言变多、消息结构变复杂,JSON 的约束力就会显得不够。

比如一个字段到底能不能为空?
某个数字到底是 int 还是 long?
新增字段后老消费者能不能正常消费?

这些问题如果只靠口头约定,很容易出事故。

所以在更规范的系统里,消息体也会采用 Protobuf、Avro 这类有 schema 的方案。它们能更清楚地描述消息结构,也更适合做版本演进。

这里我觉得最重要的一点是:不要把 MQ 消息当成普通 Java 对象随手发出去。

消息一旦发出,就可能被很多系统依赖。
今天你改一个字段名,可能影响的不是当前服务,而是某个你甚至不熟悉的下游消费者。

所以 MQ 的序列化选择,本质上是在选择一种“长期契约”。
数据格式越随意,后面演进成本越高。


6. 一个更实际的选择思路

如果让我在项目里选序列化方案,我不会先问“哪个最快”,而是会先问几个更具体的问题。

这份数据要不要跨语言?如果要,Java 原生序列化基本可以排除。

这份数据以后要不要长期保存?如果要,就要重视版本兼容,不能只图现在写起来方便。

这份数据出问题时,人需不需要直接排查?如果需要,JSON 这种可读格式会更友好。

这份数据传输频率高不高、体积大不大?如果非常高频,Protobuf、Kryo 这类二进制方案才更值得考虑。

这份数据是不是来自外部?如果是,就不要随便反序列化成复杂对象,更不能信任 Java 原生反序列化。

所以实际工程里常见的选择大概是:

场景 更常见的选择 主要原因
HTTP API JSON / Jackson 通用、可读、前后端协作方便
内部 RPC Protobuf / Hessian 性能更好,适合服务间调用
Redis 缓存 JSON / Protobuf / Kryo 小业务看可读性,高性能场景看体积和速度
MQ 消息 JSON / Protobuf / Avro 重点是兼容性和长期演进
大数据存储 Avro / Parquet 适合批量分析和 schema 演进

不过表格只是参考,真正的项目不会这么绝对。

比如很多公司内部 HTTP 接口也会用 Protobuf;有些缓存为了排查方便会坚持使用 JSON;还有些老系统因为历史原因仍然保留 Java 原生序列化。

工程里没有“永远正确”的序列化方案,只有“当前场景下成本最低的方案”。

我觉得比较稳的结论是:

如果是给人看、方便联调和排查,优先 JSON。
如果是给机器高频通信,优先 Protobuf 这类紧凑格式。
如果是新项目,尽量不要把 Java 原生序列化作为默认选择。

写在文后

期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐