Java基础学习笔记--对象拷贝与序列化

一、深拷贝 vs 浅拷贝

1. 核心原理与区别:浅拷贝只是复制引用地址,而深拷贝新开辟了内存空间

  • 浅拷贝(Shallow Copy):创建一个新的对象,然后将当前对象的所有字段值复制到新对象中。对于基本数据类型(int、long、double 等),直接复制值;对于引用类型(如数组、对象),只复制引用地址,不复制引用指向的实际对象。因此,原对象和拷贝对象中的引用类型字段指向同一个内存地址,修改其中一个会影响另一个
  • 深拷贝(Deep Copy):创建一个新的对象,并递归地复制当前对象中所有引用类型字段所指向的对象。原对象和拷贝对象在内存中完全独立,修改任何一个都不会影响另一个

内存表现举例
假设有一个 Person 类,包含 name(String)和 address(Address 对象):

  • 浅拷贝后:person1.name 和 person2.name 各自独立(String 是不可变类,无所谓共享);但 person1.address 和 person2.address 指向同一个 Address 对象。修改 person1.address.setCity("北京"),person2.address.getCity() 也会变成"北京"。
  • 深拷贝后:person1.address 和 person2.address 指向完全不同的两个 Address 对象。修改其中一个不影响另一个

2. 浅拷贝的实现方式

方式一:手动实现 Cloneable 接口并重写 clone() 方法
super.clone() 底层由 JVM 实现,会创建一个与原对象类型相同的新对象,并逐字段复制内存值。但这对引用类型只复制引用地址,所以是浅拷贝。

方式二:BeanUtils.copyProperties(Spring自带)
底层通过反射获取源对象的所有属性,然后逐个调用 setter 方法赋值。对于引用类型,同样只复制引用地址。

3. 深拷贝的实现方式

方式一:手动递归 new(最直观但最繁琐)
优点:完全可控,可以自定义拷贝逻辑。缺点:每增加一个引用类型字段都要修改拷贝方法,维护成本高,容易遗漏。

方式二:通过序列化实现深拷贝(最简洁)
原理:序列化将对象写入字节流时,会递归序列化所有引用类型的对象;反序列化时,JVM 会在堆内存中重新分配所有对象的内存空间,因此天然实现了深拷贝。

方式三:第三方库(如 Kryo)
Kryo 等第三方库通过更高效的方式实现深拷贝,性能远高于 Java 原生序列化方式。

方式四:Hutool 的 BeanUtil.copyProperties 

底层会自动创建新的对象

4. 使用场景与避坑指南

使用场景

  • 浅拷贝:适用于对象中只有基本类型和不可变引用类型(如 String)的场景,或者你明确希望多个对象共享某个引用类型的数据。
  • 深拷贝:适用于需要完全独立副本的场景,比如多线程中共享数据的快照、游戏存档的保存与恢复、配置对象的隔离修改等。

常见坑

  1. Cloneable 接口没有 clone() 方法:Cloneable 是一个标记接口,真正的 clone() 方法定义在 Object 类中。如果忘记重写,调用 clone() 会抛出 CloneNotSupportedException。
  2. 浅拷贝导致的数据污染:在集合框架中非常常见。比如 ArrayList 的 clone() 方法就是浅拷贝,拷贝后的列表中的引用类型元素与原列表共享。
  3. 循环引用导致栈溢出:通过序列化实现深拷贝时,如果两个对象互相引用(A 引用 B,B 又引用 A),序列化会进入无限递归,最终抛出 StackOverflowError。

二、序列化体系:Java 原生序列化 vs JSON 序列化

1. 什么是序列化?

序列化(Serialization)是将内存中的对象状态转换为可存储或可传输的字节流的过程。反序列化则是将字节流还原为内存中的对象。核心目的有两个:持久化存储和网络传输。

2. Java 原生序列化(ObjectOutputStream)

底层原理
Java 原生序列化是 JVM 级别的底层机制。序列化时,JVM 通过反射获取类的所有字段(getDeclaredFields()),逐一检查字段的修饰符:

  • 被 static 或 transient 修饰的字段,直接跳过
  • 其他普通字段,即使被 private 修饰,也会通过反射直接读取内存中的值,原封不动地写入字节流。

反序列化时,JVM 会直接在堆内存中"凭空"分配一块空间,创建一个全新的对象实例,完全绕过构造函数(不会调用任何 Constructor),然后将字节流中的二进制数据直接"填"进这块内存里。

使用场景

  • 分布式 Session 持久化(集群部署时跨服务器的 Session 共享)。
  • 老式 RPC / RMI 通信。
  • 复杂对象的深拷贝。
  • 复杂对象的持久化存储(如游戏存档)。

3. JSON 序列化(Jackson / Fastjson / Gson)

底层原理
JSON 序列化是纯 Java 应用层的代码,底层大量使用反射机制。

  • 序列化时:扫描对象的 public 字段或 getter 方法,通过反射调用获取值,拼接成 JSON 文本。
  • 反序列化时:通过反射调用无参构造函数 new 出新对象,然后调用 setter 方法塞入值。

使用场景

  • 前后端 HTTP 交互。
  • 跨语言数据传输。
  • 微服务间 RESTful API 通信。

4. 两者核心差异对比表

表格

对比维度

Java 原生序列化

JSON 序列化(Jackson / Fastjson)

数据读取方式

反射直接读取内存中的字段(含 private)

反射调用 getter 方法

数据写入方式

反射直接修改内存中的字段(含 private)

反射调用 setter 方法

对象创建方式

绕过构造函数,直接在堆内存分配空间

反射调用无参构造函数 new

依赖的接口

必须实现 Serializable

无要求,普通 POJO 即可

输出格式

二进制字节流

纯文本(JSON 字符串)

跨语言能力

仅限 Java 生态

跨语言通用

transient 影响

会被跳过,不序列化

取决于框架(Jackson/Gson 跳过,Fastjson 不跳过)


三、transient 关键字与自定义序列化

1. transient 的核心作用

transient 是 Java 原生序列化中的一个关键字。它的作用是告诉 JVM:在序列化时跳过这个字段,不要将它写入字节流。反序列化时,被 transient 修饰的字段会被赋予类型的默认值(引用类型为 null,int 为 0,boolean 为 false 等)。

2. 三大典型使用场景

  1. 保护敏感信息:密码、密钥等敏感数据不应被序列化到磁盘或通过网络传输。
  2. 排除不可序列化的资源:线程(Thread)、文件流(InputStream/OutputStream)、数据库连接等资源对象无法被序列化,必须用 transient 修饰。
  3. 派生字段优化(节省空间):某些字段可以通过其他字段计算得出,不需要单独存储。

3. 框架差异陷阱:transient 对 JSON 框架的不同影响

  • Jackson / Gson:会跳过。默认对齐 Java 原生序列化语义,发现 transient 字段会直接忽略。
  • Fastjson:不会跳过。底层完全不理会 transient 关键字,只认 getter 方法。

实战建议:让 transient 回归它原生序列化的本职工作。在 JSON 序列化时,使用各框架自己的注解来控制字段的显示与隐藏(如 Jackson 的 @JsonIgnore、Fastjson 的 @JSONField(serialize = false))。

4. 进阶:自定义序列化逻辑(readObject / writeObject)

Java 原生序列化提供了自定义序列化机制。如果你的类中定义了以下私有方法,JVM 会在序列化/反序列化时自动调用:

  • 加密序列化:在 writeObject 中加密,在 readObject 中解密。这样 password 字段虽然加了 transient,但在字节流中存的是密文,反序列化时再解密恢复。
  • 懒加载 / 动态重建运行时资源:在 readObject 中重新建立数据库连接或重新绑定当前线程。

四、安全与工程实践

1. serialVersionUID 的作用与最佳实践

serialVersionUID 是 Serializable 接口的版本号。JVM 在反序列化时会检查类版本是否匹配。如果版本号不一致,会抛出 InvalidClassException

最佳实践:始终显式定义 serialVersionUID(如 private static final long serialVersionUID = 1L;)。这样可以保证即使类结构发生变化,旧数据仍然可以反序列化(虽然新增的字段会是默认值)。

2. 反序列化安全漏洞(RCE 漏洞原理与防御)

漏洞原理:Java 原生序列化在反序列化时,会直接执行字节流中对象图里所有类的 readObject 方法。如果攻击者能控制输入的字节流,就可以构造恶意的序列化数据,在反序列化时触发任意代码执行(RCE)。

防御手段

  1. 尽量避免使用 Java 原生序列化,在网络传输场景中使用 JSON 或 Protobuf 替代。
  2. 自定义 ObjectInputStream 的 resolveClass 方法,在反序列化时白名单校验允许反序列化的类。
  3. 在 JVM 启动参数中设置 jdk.serialFilter 限制可反序列化的类。

3. 实战常见踩坑总结

  1. 前后端字段名不一致:后端驼峰,前端下划线。解决:使用 @JsonProperty 或 @JSONField(name="") 注解。
  2. 日期格式不一致:解决:使用 @JsonFormat 注解或全局配置。
  3. 空值处理:Jackson 默认忽略 null 字段。解决:使用 @JsonInclude(Include.ALWAYS)。
  4. 循环引用:JSON 序列化时进入无限递归。解决:使用 @JsonManagedReference 和 @JsonBackReference 或 @JsonIgnore。
  5. serialVersionUID 未定义:类结构修改后导致反序列化失败。解决:始终显式定义。
  6. transient 与 JSON 框架的兼容性:混用不同框架导致数据不一致。解决:统一使用各框架自己的注解控制。

五、核心总结速查表

知识点

核心要点

浅拷贝

复制基本类型值,引用类型只复制引用地址,共享同一个对象

深拷贝

递归复制所有引用类型对象,原对象和拷贝对象完全独立

深拷贝最佳实现

通过序列化实现,代码最简洁;手动递归 new 性能高但维护成本高

Java 原生序列化

JVM 级别直接操作内存,通过反射读取 private 字段,绕过构造函数

JSON 序列化

应用层反射调用 getter/setter,输出纯文本,跨语言通用

两者核心区别

原生序列化操作内存(含 private),JSON 序列化通过 getter(公开接口)

transient 作用

告诉 JVM 序列化时跳过该字段,反序列化时恢复为默认值(就是null)

transient + JSON

Jackson/Gson 会跳过,Fastjson 不会跳过(只认 getter)

transient 最佳实践

用各框架自己的注解控制 JSON 序列化,transient 仅用于原生序列化

readObject/writeObject

自定义序列化逻辑,可实现加密、懒加载、动态重建资源

serialVersionUID

版本号,反序列化时校验类版本,必须显式定义

反序列化漏洞

恶意字节流在反序列化时执行任意代码,防御手段是白名单校验

posted @ 2026-09-02 21:46  倾听-静轩水月  阅读(16)  评论(0)    收藏  举报