Java 序列化与反序列化:让对象走出 JVM
第一次接触"序列化"和"反序列化"时,很容易把它想得特别玄乎。实际上,它解决的是一个非常朴素的问题:
Java 对象只能活在当前 JVM 的内存里,但程序经常需要把对象存起来,或者发送到别的地方。
例如,我们在代码中创建了一个用户对象:
java
User user = new User(1L, "Tom", 18);
这个对象可以被当前程序使用,但 Redis、文件、浏览器、消息队列和另一个服务都不认识"Java 内存中的 User 对象"。它们通常只接受字符串或字节数据。因此,对象在离开 JVM 之前,必须先被转换成一种能够保存和传输的格式。
这一步叫作序列化 。数据到达目标后,再重新恢复成 Java 对象,这一步叫作反序列化。

最通俗的类比是寄快递:
- Java 对象是你手里的物品;
- 序列化是把物品装进标准快递箱;
- JSON、字节数组是快递箱的格式;
- 网络、Redis、文件和消息队列是运输渠道;
- 反序列化是收件人拆箱,把数据重新还原成可以使用的对象。
所以,序列化不是"保存对象的内存地址",而是提取对象中有意义的状态,再按某种规则编码。
一、为什么 Java 对象不能直接保存或传输?
假设有这样一个类:
java
public class User {
private Long id;
private String name;
private Integer age;
// 构造方法、Getter、Setter 省略
}
创建对象后:
java
User user = new User(1L, "Tom", 18);
我们看到的是:
text
User
├── id = 1
├── name = "Tom"
└── age = 18
但 JVM 内部还会维护对象引用、内存地址、类信息以及垃圾回收相关状态。这些信息只对当前 JVM 有意义。
假设服务 A 告诉服务 B:
text
User 对象在内存地址 0x123456
服务 B 根本无法使用这个地址,因为它拥有自己的内存空间。即使同一台机器上的两个 Java 程序,内存也不是互通的。
真正需要传输的是对象的内容:
text
id = 1
name = Tom
age = 18
因此,我们可以把它转换成 JSON:
json
{
"id": 1,
"name": "Tom",
"age": 18
}
现在这段数据可以写入文件、放进 Redis、通过 HTTP 发送,也可以交给 Python、Go 或 JavaScript 程序读取。
这个转换过程就是:
text
Java 对象 → JSON
也就是序列化。
收到 JSON 后,再把字段填回一个 User 对象:
text
JSON → Java 对象
就是反序列化。
这里要注意一个非常重要的区别:
序列化保存的是对象的状态,不是把整个 JVM 内存复制出去。
对象的方法通常也不会被保存。例如 user.sayHello() 的方法代码属于 User 类本身,序列化数据只需要保存 id、name、age 等字段值。反序列化后之所以还能调用方法,是因为当前程序里仍然存在 User 类的代码。
二、一次普通的 Spring 接口,已经发生了两次转换
序列化在实际开发中最常见的场景,就是前后端通过 JSON 交互。
前端发送:
json
{
"username": "tom",
"password": "123456"
}
后端代码:
java
@PostMapping("/login")
public UserVO login(@RequestBody LoginRequest request) {
User user = userService.login(
request.getUsername(),
request.getPassword()
);
return new UserVO(
user.getId(),
user.getUsername()
);
}
这段代码看起来没有任何序列化操作,但 Spring 已经悄悄替我们做完了。
请求进入时:
text
HTTP 请求中的 JSON
↓
Jackson 读取 JSON
↓
创建 LoginRequest 对象
↓
把 username、password 填入对象
这叫反序列化。
Controller 返回 UserVO 时:
text
UserVO 对象
↓
Jackson 读取对象字段
↓
转换成 JSON
↓
写入 HTTP 响应
这叫序列化。

所以可以这样记:
java
@RequestBody LoginRequest request
通常意味着:
text
JSON → Java 对象
而 Controller 直接返回对象:
java
return userVO;
通常意味着:
text
Java 对象 → JSON
Spring Boot Web 项目默认通常使用 Jackson 完成这件事。我们没有手动调用它,不代表序列化不存在,只是框架把转换过程自动化了。
手动使用 Jackson 时,代码大致如下:
java
ObjectMapper objectMapper = new ObjectMapper();
User user = new User(1L, "Tom", 18);
// 序列化:Java 对象转 JSON 字符串
String json = objectMapper.writeValueAsString(user);
// 反序列化:JSON 字符串转 Java 对象
User restoredUser = objectMapper.readValue(json, User.class);
其中:
java
writeValueAsString(...)
是序列化,而:
java
readValue(...)
是反序列化。
三、RedisTemplate 为什么需要配置序列化器?
下面这段代码很容易让人产生错觉:
java
redisTemplate.opsForValue().set("user:1", user);
看起来似乎是把 User 对象直接塞进了 Redis。
其实不是。
Redis 只负责存储键和值,它并不知道什么是 Java 的 User 类,也不知道 getName() 是什么。真正写入 Redis 的一定是字符串或字节数据。
完整过程是:
text
User 对象
↓
RedisTemplate
↓
RedisSerializer
↓
JSON 或字节数组
↓
Redis
读取时则反过来:
text
Redis 中的数据
↓
RedisSerializer
↓
User 对象

因此,RedisTemplate 中经常会看到这些序列化器:
java
StringRedisSerializer
JdkSerializationRedisSerializer
Jackson2JsonRedisSerializer
GenericJackson2JsonRedisSerializer
它们不是多余的工具,而是在回答一个关键问题:
这个 Java 对象到底应该以什么格式存进 Redis?取出来时又该怎样恢复?
例如,使用 JSON 序列化器后,Redis 中可能保存:
json
{
"id": 1,
"name": "Tom",
"age": 18
}
这样做的优点是数据比较直观,其他语言也容易读取。
如果使用 Java 原生序列化器,Redis 中保存的往往是一段二进制数据。它可能更像:
text
AC ED 00 05 73 72 ...
人眼看起来像乱码,而且通常只能由兼容的 Java 类恢复。
一个常见的 Redis 配置如下:
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory
) {
RedisTemplate<String, Object> template =
new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key 使用普通字符串
template.setKeySerializer(
new StringRedisSerializer()
);
// value 使用 JSON
template.setValueSerializer(
new GenericJackson2JsonRedisSerializer()
);
template.afterPropertiesSet();
return template;
}
}
这里的重点不是背代码,而是理解两种不同的数据:
key通常只是"user:1"这样的字符串;value可能是复杂的 Java 对象,所以需要 JSON 序列化器。
如果写入和读取时使用了不同的规则,就可能出现乱码、类型转换失败或反序列化异常。例如,之前使用 JDK 序列化写入,后来改成 JSON 序列化读取,新的序列化器通常无法理解旧数据。
这就像寄快递时用一种封装标准,收件人却按另一种标准拆包,当然会出问题。
四、JSON 序列化和 Java 原生序列化不是一回事
日常开发中,"序列化"经常指两类东西。
第一类是 Java 对象和 JSON 之间的转换:
text
Java 对象 ↔ JSON
常见工具包括 Jackson、Gson 等。它的优点是可读、跨语言、适合 Web 接口和微服务。
第二类是 Java 原生序列化:
text
Java 对象 ↔ Java 二进制数据
它依赖:
java
Serializable
ObjectOutputStream
ObjectInputStream
例如:
java
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name;
private Integer age;
}
写入文件:
java
try (
ObjectOutputStream outputStream =
new ObjectOutputStream(
new FileOutputStream("user.dat")
)
) {
outputStream.writeObject(user);
}
读取文件:
java
try (
ObjectInputStream inputStream =
new ObjectInputStream(
new FileInputStream("user.dat")
)
) {
User restoredUser =
(User) inputStream.readObject();
}
writeObject() 是序列化,readObject() 是反序列化。
两种方式虽然都叫序列化,但目的和特点不同:
| 对比项 | JSON 序列化 | Java 原生序列化 |
|---|---|---|
| 可读性 | 很好,可以直接查看 | 较差,通常是二进制 |
| 跨语言 | 容易 | 很差 |
是否要求 Serializable |
通常不要求 | 通常要求 |
| 与 Java 类结构的绑定 | 相对较弱 | 很强 |
| 常见场景 | HTTP、Redis、MQ、微服务 | 旧系统、特定 Java 内部场景 |
| 安全性 | 仍需谨慎配置 | 反序列化不可信数据风险较高 |
现代 Java Web 开发里,JSON 通常更常见。Java 原生序列化并不是完全不能用,但不适合随意处理来自网络的外部数据。
五、Serializable、serialVersionUID 和 transient 分别做什么?
这三个词经常一起出现,但职责完全不同。
Serializable:一张"允许原生序列化"的标签
java
public class User implements Serializable {
}
Serializable 是一个没有方法的标记接口。它相当于告诉 Java:
这个类允许被
ObjectOutputStream按 Java 原生格式序列化。
它并不会自动把对象保存到文件。真正执行保存的仍然是:
java
outputStream.writeObject(user);
没有实现 Serializable 却进行原生序列化,通常会出现:
text
NotSerializableException
需要强调的是:Jackson 转 JSON 通常不要求对象实现 Serializable。所以,不要把"Java 原生序列化的要求"和"所有序列化方式"混在一起。
serialVersionUID:类的序列化版本标识
java
private static final long serialVersionUID = 1L;
假设今天把 User 对象保存到了文件,明天修改了 User 类,增加一个字段:
java
private String email;
反序列化时,Java 需要判断:
文件里的旧版
User,和当前代码里的User,是不是兼容的同一类?
serialVersionUID 就是这个判断的重要依据之一。
如果不手动声明,Java 会根据类结构生成一个值。类稍微变化,自动生成的值就可能变化,随后读取旧数据时可能抛出:
text
InvalidClassException
因此,只要确实使用 Java 原生序列化,通常建议显式声明版本号。
但也不能误解为"只要版本号一样,任意修改类都一定兼容"。删除字段、修改字段类型、改变继承结构等操作,依然可能导致数据丢失或兼容性问题。
transient:这个字段不参加 Java 原生序列化
java
public class User implements Serializable {
private Long id;
private String username;
private transient String temporaryToken;
}
对象序列化再恢复后,temporaryToken 通常会变成默认值:
text
引用类型 → null
整数类型 → 0
布尔类型 → false
transient 适合标记临时状态、缓存字段或不应随原生序列化保存的数据。
但不要把它当成安全保护。首先,字段不被序列化并不代表内存中的数据已经被加密;其次,不同 JSON 库对 transient 的处理方式可能受配置影响。使用 Jackson 时,更明确的做法通常是:
java
@JsonIgnore
private String password;
对于真正敏感的信息,重点应该是避免不必要地传输和存储,并使用可靠的加密、哈希及访问控制,而不是只加一个关键字。
六、对象还会在哪些地方"被打包"?
理解 HTTP 和 Redis 后,其他场景几乎都是同一个道理。
消息队列
订单服务发送消息:
java
OrderMessage message =
new OrderMessage(1001L, 2001L, 2);
RabbitMQ、Kafka 或 RocketMQ 不会把当前 JVM 的对象引用直接交给另一个 JVM。生产者必须先把消息对象转成 JSON、Avro、Protobuf 或字节数组。
text
OrderMessage 对象
↓ 序列化
消息数据
↓ MQ
库存服务收到数据
↓ 反序列化
OrderMessage 对象
RPC 和微服务调用
服务 A 调用服务 B 时,请求参数对象也需要经过类似过程:
text
Java 请求对象
↓ 序列化
网络字节
↓
服务 B
↓ 反序列化
服务 B 中的 Java 请求对象
Dubbo、gRPC 等框架虽然帮我们隐藏了这些细节,但底层仍然需要约定编码格式。
文件与持久化
对象存在于内存中,程序退出后就会消失。想在下次启动时恢复它,就需要把状态写入文件或数据库。
不过,在正常业务系统中,长期数据通常更适合用数据库、JSON、Protobuf 等稳定格式保存,而不是直接把整个 Java 对象使用原生序列化写入文件。因为类结构一变,旧数据兼容会变得麻烦。
分布式 Session
单机 Session 可以放在当前服务器内存中,但多台服务器之间需要共享登录状态时,经常会把 Session 存进 Redis。Session 中的用户信息同样需要序列化,读取时再反序列化。
因此,"对象想离开当前 JVM"几乎就是判断是否需要序列化的万能问题。
七、序列化最容易踩的坑
序列化不是加密
下面的 JSON 已经完成了序列化:
json
{
"username": "tom",
"password": "123456"
}
但任何看到数据的人都能读懂它。
因此:
text
序列化 ≠ 加密
序列化 ≠ 压缩
序列化 ≠ 安全保护
序列化只负责改变数据表示形式。
写入和读取必须遵守同一套规则
使用 JSON 写入的数据,读取时也应该按对应 JSON 格式解析。使用 JDK 原生序列化写入的数据,不能突然拿字符串序列化器读取。
在 Redis、MQ 和文件系统中,序列化协议一旦变化,还需要考虑旧数据迁移和版本兼容。
字段变化会带来兼容问题
假设旧 JSON 是:
json
{
"name": "Tom",
"age": 18
}
后来 Java 类把 age 从 Integer 改成复杂对象,旧数据可能无法直接恢复。
因此,在服务之间传输数据时,DTO 和消息结构不应该随意修改。常见做法包括:
- 新增字段时提供默认值;
- 尽量避免修改已有字段含义;
- 不再使用的字段先保留一段时间;
- 对消息和接口进行版本管理;
- 对未知字段采用合理的兼容策略。
不要随意反序列化不可信的 Java 原生数据
Java 原生反序列化不仅是"把字段放回对象"这么简单。在某些类的组合下,反序列化过程可能触发方法调用。攻击者可能构造恶意数据,利用依赖库中的特殊调用链执行危险操作。
因此:
不要把来自网络、用户上传文件或不可信来源的数据直接交给
ObjectInputStream.readObject()。
对外接口通常更适合使用 JSON,并限制允许绑定的字段和类型。JSON 也不是天然绝对安全,但它比允许外部数据恢复任意 Java 对象更容易控制。
最后,用一条主线记住它
序列化与反序列化不需要死记定义,只需要抓住"对象旅行"这条主线:
text
Java 对象只能存在于当前 JVM
↓
它要去 Redis、文件、网络或 MQ
↓
先序列化成 JSON 或字节数据
↓
数据被保存或传输
↓
另一端按照相同规则反序列化
↓
重新得到可以操作的 Java 对象
以后看到这些代码时,就可以立刻判断:
java
objectMapper.writeValueAsString(user);
Java 对象变成 JSON,是序列化。
java
objectMapper.readValue(json, User.class);
JSON 变成 Java 对象,是反序列化。
java
redisTemplate.opsForValue().set("user:1", user);
表面上在存对象,底层一定先经过 Redis 序列化器。
java
@RequestBody User user
表面上直接拿到了对象,实际上 Spring 已经把 HTTP 请求中的 JSON 反序列化好了。
最终只需要记住两句话:
对象要离开 JVM,就需要序列化。
外部数据要进入 Java 并恢复成对象,就需要反序列化。