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)的场景,或者你明确希望多个对象共享某个引用类型的数据。
- 深拷贝:适用于需要完全独立副本的场景,比如多线程中共享数据的快照、游戏存档的保存与恢复、配置对象的隔离修改等。
常见坑:
- Cloneable 接口没有 clone() 方法:Cloneable 是一个标记接口,真正的 clone() 方法定义在 Object 类中。如果忘记重写,调用 clone() 会抛出 CloneNotSupportedException。
- 浅拷贝导致的数据污染:在集合框架中非常常见。比如 ArrayList 的 clone() 方法就是浅拷贝,拷贝后的列表中的引用类型元素与原列表共享。
- 循环引用导致栈溢出:通过序列化实现深拷贝时,如果两个对象互相引用(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. 三大典型使用场景
- 保护敏感信息:密码、密钥等敏感数据不应被序列化到磁盘或通过网络传输。
- 排除不可序列化的资源:线程(Thread)、文件流(InputStream/OutputStream)、数据库连接等资源对象无法被序列化,必须用 transient 修饰。
- 派生字段优化(节省空间):某些字段可以通过其他字段计算得出,不需要单独存储。
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)。
防御手段:
- 尽量避免使用 Java 原生序列化,在网络传输场景中使用 JSON 或 Protobuf 替代。
- 自定义 ObjectInputStream 的 resolveClass 方法,在反序列化时白名单校验允许反序列化的类。
- 在 JVM 启动参数中设置 jdk.serialFilter 限制可反序列化的类。
3. 实战常见踩坑总结
- 前后端字段名不一致:后端驼峰,前端下划线。解决:使用 @JsonProperty 或 @JSONField(name="") 注解。
- 日期格式不一致:解决:使用 @JsonFormat 注解或全局配置。
- 空值处理:Jackson 默认忽略 null 字段。解决:使用 @JsonInclude(Include.ALWAYS)。
- 循环引用:JSON 序列化时进入无限递归。解决:使用 @JsonManagedReference 和 @JsonBackReference 或 @JsonIgnore。
- serialVersionUID 未定义:类结构修改后导致反序列化失败。解决:始终显式定义。
- 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 |
版本号,反序列化时校验类版本,必须显式定义 |
|
反序列化漏洞 |
恶意字节流在反序列化时执行任意代码,防御手段是白名单校验 |
浙公网安备 33010602011771号