Java的DeepCopy

前言

这两天遇到了一个Bug(是的, 我就是bug开发工程师),情景如下:

我们封装了对DB查询的缓存,对于一个查询请求来说, 首先从redis里读取,如果命中缓存,则直接返回结果. 如果未命中缓存,从db中查询数据,返回结果,同时异步将查询到的数据添加到redis中.

在这个过程中, 发生了ConcurrentModifyException. 经过查看代码, 确定了问题出在异步填充缓存这里.

当从db中查询到数据, 首先返回给调用方, 之后异步执行序列化及写入redis. 在序列化过程中, 有某个属性是列表,遍历的过程中, 调用方拿到了数据,对列表进行了更改,导致产生异常.

解决方案就是本文的主题, 在异步填充缓存时, 序列化的不应该是原对象, 而是该对象的一个copy. 而Java中的copy, 也是比较讲究的,因此简单学习一下.

直接引用

在编码过程中, 我们经常有获取和目标对象属性值完全一致的另外一个对象. 最直接的,也就是最常用的就是直接引用.

首先我们定义了一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class Person{
String name;
int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String str() {
return name + "\n" + age;
}
}

然后对他进行一次引用并打印:

1
2
3
4
5
6
7
8
public static void main(String [] args){
Person p1 = new Person("huyanshi", 18);
System.out.println(p1);
System.out.println(p1.str());
Person p2 = p1;
System.out.println(p2);
System.out.println(p2.str());
}

打印结果如下:

1
2
3
4
5
6
daily.javacopy.Copy$Person@51cdd8a
huyanshi
18
daily.javacopy.Copy$Person@51cdd8a
huyanshi
18

可以看到, 通过 = 来进行直接赋值, 我们获得了一个完全一致的对象, 因此他们本质上都是同一个对象, 只是新创建了两个引用,指向了堆上的同一块内存区域.

由于内存地址完全一致, 当对一个引用进行更改, 另一个引用看到的对象必然也会发生变化,不太符合我们的题目要求.

clone 浅拷贝

众所周知, 猫是液体, java的万类之爹, Object, 是提供了clone方法的, 让我们试试.

1
2
3
4
5
6
7
Person p1 = new Person("huyanshi", 18);
System.out.println(p1);
System.out.println(p1.str());
Person p2 = (Person) p1.clone();
System.out.println(p2);
System.out.println(p2.str());

打印结果如下:

1
2
3
4
5
6
daily.javacopy.Person@51cdd8a
huyanshi
18
daily.javacopy.Person@d44fc21
huyanshi
18

可以看到, 虽然两个对象的属性值完全一样, 但是他们在堆中已经不是同一个对象了, 这个时候对任意一个对象进行改动, 另外一个就不会跟着变了, 那这不是满足需求了吗? 为啥要叫他浅拷贝呢?

因此他只能解决一层对象嵌套, 比如我们的Person类, 如果再引用一个对象, 他就不会进行复制了.

让我们给Person加入一个对象字段.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Edu {

public String start;
public String end;
public String schoolName;

public Edu(String start, String end, String schoolName) {
this.start = start;
this.end = end;
this.schoolName = schoolName;
}
}



public class Person implements Cloneable {
String name;
int age;
Edu edu;

public Person(String name, int age, Edu edu) {
this.name = name;
this.age = age;
this.edu = edu;
}

public String str() {
return name + "\n" + age + edu.toString();
}

@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

再次运行刚才的程序, 打印如下:

1
2
3
4
5
6
7
8
9
daily.javacopy.Person@d44fc21
daily.javacopy.Edu@23faf8f2
huyanshi
18daily.javacopy.Edu@23faf8f2
==========分割线=============
daily.javacopy.Person@2d6eabae
daily.javacopy.Edu@23faf8f2
huyanshi
18daily.javacopy.Edu@23faf8f2

可以发现, 虽然经过clone之后的person对象变成了真正的两个对象,但是他们指向的Edu类, 仍然是同一个对象.

这就是浅拷贝的原因, 他只能拷贝你调用的对象, 对他的属性中的对象, 是不进行clone的.

深拷贝

由于上面的思路, java自带的clone方法, 不会帮你clone属性里面的对象, 那么我们自己实现以下就好了.

给Person类重写clone方法:

1
2
3
4
5
6
7
@Override
protected Object clone() throws CloneNotSupportedException {
Person person = (Person) super.clone();
person.edu = (Edu) this.edu.clone();
return person;
}

重新执行代码, 可以看到edu也被clone了.

1
2
3
4
5
6
7
8
9
daily.javacopy.Person@d44fc21
daily.javacopy.Edu@23faf8f2
huyanshi
18daily.javacopy.Edu@23faf8f2
==========分割线=============
daily.javacopy.Person@2d6eabae
daily.javacopy.Edu@4e7dc304
huyanshi
18daily.javacopy.Edu@4e7dc304

那么我们找到了第一个进行深拷贝的方法, 那就是 重写clone方法, 这个方法有个劣势, 就是当你属性里对象很多, 你重写的clone就会非常麻烦, 同时,每次添加/减少字段, 都需要修改clone方法,十分麻烦.

除此之外, 还有一种深拷贝的思路, 那就是将当前的对象完全序列化, 之后再进行反序列化拿到新的对象, 这样也是完整的深拷贝. Apache Commons Lang (自己实现也行,用json什么序列化都行) 提供了序列化工具, 我们可以简单试用一下.

1
2
3
4
5
6
7
8
Edu edu = new Edu("2020-11-11", "2020-11-12", "加里敦大学");
Person p1 = new Person("huyanshi", 18,edu);
System.out.println(p1);
System.out.println(p1.edu);
Person p2 = SerializationUtils.clone(p1);
System.out.println("==========分割线=============");
System.out.println(p2);
System.out.println(p2.edu);

执行代码会发现确实是深拷贝, 让我们看看他是怎么做的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static <T extends Serializable> T clone(T object) {
if (object == null) {
return null;
} else {
// 序列化成byte[]
byte[] objectData = serialize(object);
ByteArrayInputStream bais = new ByteArrayInputStream(objectData);

try {
SerializationUtils.ClassLoaderAwareObjectInputStream in = new SerializationUtils.ClassLoaderAwareObjectInputStream(bais, object.getClass().getClassLoader());

Serializable var5;
try {
// 反序列化成对象
T readObject = (Serializable)in.readObject();
var5 = readObject;
} catch (Throwable var7) {
try {
in.close();
} catch (Throwable var6) {
var7.addSuppressed(var6);
}

throw var7;
}

in.close();
return var5;
} catch (ClassNotFoundException var8) {
throw new SerializationException("ClassNotFoundException while reading cloned object data", var8);
} catch (IOException var9) {
throw new SerializationException("IOException while reading or closing cloned object data", var9);
}
}
}

可以看到, 比较简单, 就是序列化成字节数组,之后再反序列化回来, 也从代码中可以看到, 想要用此方法来进行深拷贝的类,及其所有的属性类, 都必须要实现Serializable接口, 否则没有办法使用.

由此我们可以总结下两种深拷贝的优劣势了.

重写clone方法
优点:
- 底层实现较简单
- 不需要引入第三方包
- 系统开销小
缺点:
- 可用性较差,每次新增成员变量可能需要修改clone()方法
- 拷贝类(包括其成员变量)需要实现Cloneable接口

Apache序列化
优点:
- 可用性强,新增成员变量不需要修改拷贝方法
缺点:
- 需要引入Apache Commons Lang第三方JAR包
- 拷贝类(包括其成员变量)需要实现Serializable接口
- 序列化与反序列化存在一定的系统开销

那么当我们想要选用一种实现方法的时候, 该怎么选呢? 说实话这些方法都挺恶心的…一点都不简单实用, 当你必须需要一个深拷贝的办法时, 首先要考虑的不是你想用哪个, 而是看你的实际情况能用哪个. 比如待拷贝的类是否实现了cloneable,Serializable, 当前系统是要求代码好看点呢还是极致要求性能呢? 根据这些情况, 针对性的选择一种, 如果实在没有办法, 我们还是可以手动全部new一遍的嘛.

压测

都说序列化的方式来实现深拷贝性能不好, 那么来进行一个简单的性能对比吧.

测试所用的类, 就用上面简单的Person类吧.

我在1分钟内的时间,反复调用两种序列化,测试结果如下:

拷贝方法 RPS Avg(ms) Min(ms) Max(ms) StdDev Total TP50 TP90 TP95 TP99 TP999 TP9999
重写clone 13602164.28 0.00 0 94 0.05 816129857 0 0 0 0 0 0
apache序列化 383011.93 0.02 0 126 0.55 22980716 0 0 0 0 10 22

大家只看第一列数据,也就是1分钟之内能进行深拷贝的次数, 就知道, 重写clone的方法完全是吊打序列化, 所以基本上选择的时候, 就看, 你愿意用性能换取方便吗?


完。





联系我

最后,欢迎关注我的个人公众号【 呼延十 】,会不定期更新很多后端工程师的学习笔记。
也欢迎直接公众号私信或者邮箱联系我,一定知无不言,言无不尽。



以上皆为个人所思所得,如有错误欢迎评论区指正。

欢迎转载,烦请署名并保留原文链接。

联系邮箱:huyanshi2580@gmail.com

更多学习笔记见个人博客或关注微信公众号 <呼延十 >——>呼延十