当前位置: 首页 > news >正文

【后端开发】为什么不能直接把对象写进磁盘?一次把序列化/反序列化讲明白

文章目录

  • 前言
    • 一、先把误区说清楚:对象本来就是二进制,为什么还要序列化?
    • 二、那序列化到底做了什么?
    • 三、实际工程中怎么选择序列化?
      • 1. Java 原生序列化为什么不太推荐?
      • 2. 接口返回:优先考虑 JSON
      • 3. RPC 调用:更适合 Protobuf 这类强 schema 方案
      • 4. 缓存数据:优先考虑可维护性,再考虑性能
      • 5. 消息队列:重点是兼容性和可演进
      • 6. 一个更实际的选择思路
  • 写在文后

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

前言

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

classUser{Stringname;Addressaddress;}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

还是前面的例子:

classUser{Stringname;Addressaddress;}

在程序运行时,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这一套机制把对象写出去、读回来。

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

classUserimplementsSerializable{privateStringname;privateIntegerage;}

看起来只要加一个接口,对象就能被序列化了。早期很多 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:

@RestControllerpublicclassUserController{@GetMapping("/user")publicUserVOgetUser(){returnnewUserVO("张三",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 APIJSON / Jackson通用、可读、前后端协作方便
内部 RPCProtobuf / Hessian性能更好,适合服务间调用
Redis 缓存JSON / Protobuf / Kryo小业务看可读性,高性能场景看体积和速度
MQ 消息JSON / Protobuf / Avro重点是兼容性和长期演进
大数据存储Avro / Parquet适合批量分析和 schema 演进

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

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

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

我觉得比较稳的结论是:

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

写在文后

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

http://www.cnnetsun.cn/news/2115195.html

相关文章:

  • FanControl终极指南:5分钟掌握Windows风扇控制神器,告别噪音与高温烦恼
  • AzurLaneAutoScript:5分钟掌握碧蓝航线全自动游戏助手终极指南
  • 协作机器人Ask-to-Clarify框架:解决指令模糊性的关键技术
  • Copilot Next + Dev Container + GitHub Codespaces 三重自动化工作流(微软内部SRE团队正在用的生产级配置)
  • 从C到C++:用面向对象重构RC4算法,打造一个可复用的加密工具类
  • DDrawCompat完整指南:让老游戏在现代Windows上流畅运行的终极解决方案
  • Bodymovin扩展终极指南:如何将After Effects动画轻松转换为网页格式
  • Windows Cleaner终极指南:3个简单步骤彻底解决C盘爆满问题
  • 从配置到崩溃:Nushell类型转换异常的7个鲜为人知的解决方案
  • 专用预训练模型:垂直领域的高效AI解决方案
  • Unity TMP表情包制作全攻略:从Sprite Sheet工具到代码动态调用,解决你的目录困惑
  • 用MATLAB手把手教你仿真机载SAR回波:从几何模型到数据验证的保姆级流程
  • 别再只用nn.Conv2d了!Pytorch实战:用组卷积和深度可分离卷积给模型‘瘦身’
  • S32K3功能安全实战:手把手教你用MCAL配置FCCU,搞定内存ECC错误处理
  • gh_mirrors/me/meta核心原理解析:findPlugins和registerPlugin实现机制
  • JVM Full GC 频繁原因与调优
  • LiteMall开源商城系统实战指南:Spring Boot + Vue + 微信小程序全栈深度解析
  • UI Grounding技术:多模态模型在界面自动化中的应用
  • AutoUnipus:大学生必备的U校园自动化学习神器
  • CodePercept:用代码解析STEM视觉内容的多模态模型
  • 构建AI智能体评估生态系统的关键技术与实践
  • 告别以太网瓶颈:手把手教你为AI训练集群选配InfiniBand网卡与交换机(从HDR到NDR)
  • 别再只给Gerber了!与PCB工厂高效沟通:坐标文件和钻孔文件的正确打开方式
  • PvZWidescreen终极指南:免费让《植物大战僵尸》完美适配宽屏显示器
  • 必备的Android智能充电控制:如何让你的手机电池寿命延长一倍
  • GHelper:重新定义华硕笔记本硬件控制的轻量级开源方案
  • 大模型、向量模型初始化
  • 魔兽世界宏编辑革命:GSE-Advanced-Macro-Compiler的完整自动化解决方案
  • 【Hot 100 刷题计划】 LeetCode 142. 环形链表 II | C++ 哈希表直觉解法
  • 保姆级教程:用群晖DSM 7.2的Synology Photos,打造个人专属的‘朋友圈’相册