Java序列化与反序列化:让对象走出JVM

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 类本身,序列化数据只需要保存 idnameage 等字段值。反序列化后之所以还能调用方法,是因为当前程序里仍然存在 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 原生序列化并不是完全不能用,但不适合随意处理来自网络的外部数据。


五、SerializableserialVersionUIDtransient 分别做什么?

这三个词经常一起出现,但职责完全不同。

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 类把 ageInteger 改成复杂对象,旧数据可能无法直接恢复。

因此,在服务之间传输数据时,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 并恢复成对象,就需要反序列化。

相关推荐
阿里嘎多学长1 小时前
2026-07-22 GitHub 热点项目精选
开发语言·程序员·github·代码托管
噢,我明白了1 小时前
Java中日期和字符串的处理
java·开发语言·日期
爱刷碗的苏泓舒1 小时前
C 语言 if-else 与 switch-case 分支语句对比
c语言·开发语言
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
dkbnull1 小时前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai1 小时前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器
-银雾鸢尾-1 小时前
C#中的泛型约束
开发语言·c#
顺风尿一寸1 小时前
记一次 Spring AOP 与定时任务引发的死锁排查
java
用户446139430272 小时前
从单体到微服务:我们项目的拆分思路和踩坑记录
java