原型模式只解决一件事:让一个对象复制出第二个实例。JDK 从 1.0 起就提供了 Object.clone(),它的签名到今天没动过。
它的默认语义只有逐字段赋值:字段里的数组、集合、嵌套对象在副本里仍是原来那几个。
本文用一个含嵌套数组与集合的对象图,把四条拷贝路各跑 100 万个,量耗时与分配字节,浅拷贝还是深拷贝由运行时的布尔结果给出。

一、原型换到的东西

new 的成本有时花在构造器之前:参数要从十个数据源拼出来,或者对象的形状取决于启动阶段读到的配置。原型模式的前提是手上已经有一个配好的对象,接下来只剩复制。

JDK 自己的四个样本

JDK 里几处需要「拿到一个和现有对象一样的新对象」的地方,做法各不相同:

位置 复制动作 嵌套引用
Object#clone VM 内建,逐字段赋值 原样带走
ArrayList#clone super.clone()Arrays.copyOf(elementData, size) 原样带走
Arrays.copyOf 基本类型走 arraycopy,引用类型逐元素赋值 原样带走
HashMap 拷贝构造 逐条 putVal(hash(key), key, value, false, evict) 原样带走

前三行是内存搬运:数组一旦有了长度,复制就是一次块拷贝。散列表的形状由哈希决定,第四行做不到块拷贝,只能把条目重新插一遍。四个样本在最后一列上一致:被引用的成员对象都不复制。

原型模式的坑全在这一列上。后面测的四条路,区别只在「谁在什么时机把那层引用换成新对象」。

复制与重新构造的分界

同一个需求可以用原型做,也可以用建造者做。给配置对象加一位超时参数:原型是 src.withTimeout(30),内部复制后改一个字段;建造者是 builder.timeout(30).build(),从头把必填项再走一遍(见 建造者模式)。前者的前提是源对象已经在手上,且它的其余字段不可变或可安全共享;后者不需要源对象,但要求每个参数都能重新算出来。

JDK 里 LocalDate.plusDaysInstant.plusSecondsString.substring(JDK 7u6 之后)都属于前一类:返回新实例,原实例不动。这些类的字段全不可变,所以「复制」退化成「新对象 + 原引用」,不存在深度问题。

和备忘录的分界

原型交出来的副本要能独立使用:调用方会继续读它、继续改它。备忘录保存的是能回到过去的快照,份数和生命周期由调用方管(见 备忘录模式)。两者都涉及复制状态,判断标准是副本的用途:要参与后续业务的是原型,只用来恢复状态的是备忘录。

注册表:把模板集中起来

原型模式常见的落地形态是一张注册表:

1
2
3
4
5
6
7
8
9
Map<String, Order> templates = new HashMap<>();

void register(String key, Order template) {
templates.put(key, template);
}

Order create(String key) {
return templates.get(key).deepCopy();
}

注册表里放的是模板对象,每次取用都复制一份。它的边界在享元那一侧:享元共享的是不可变对象,多个使用者拿到的是同一个实例(见 享元模式);注册表共享模板,发出去的是副本,每个使用者改自己的那份。判断标准是「改完之后要不要互相看见」:要,就共享同一个实例;不要,就发一份副本。

注册表实现里最容易出错的地方是 register 存进去的引用。存完模板之后如果调用方又改了原对象,模板跟着变;create 返回的副本如果浅拷贝,改副本会反过来改模板。这两个方向都得自己防。

二、实验设计

对象图

1
2
3
4
5
6
7
8
9
static final class Order implements Cloneable, Serializable {
String id; // 1 个字符串
long amount; // 标量
int[] quantities; // 8 个 int
List<String> tags; // 5 个字符串
Map<String, Integer> counts; // 5 条键值
Address address; // 3 个字符串
Item[] items; // 4 个对象
}

两个嵌套类同样实现 CloneableSerializable。这张图同时覆盖三种可变嵌套:数组、集合、对象。

四条路

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
37
// 路 1:Object.clone,JDK 的默认语义
@Override public Order clone() {
return (Order) super.clone();
}

// 路 2:拷贝构造,按 JDK 集合的惯例只重建一层
Order(Order o) {
this.id = o.id;
this.amount = o.amount;
this.quantities = o.quantities.clone(); // 新数组
this.tags = new ArrayList<>(o.tags); // 新 List,元素引用旧
this.counts = new HashMap<>(o.counts); // 新 Map,键值引用旧
this.address = o.address; // 引用旧
this.items = o.items.clone(); // 新数组,元素引用旧
}

// 路 3:手写深拷贝
Order deepCopy() {
Item[] its = new Item[items.length];
for (int i = 0; i < its.length; i++) {
its[i] = new Item(items[i]);
}
return new Order(id, amount, quantities.clone(), new ArrayList<>(tags),
new HashMap<>(counts), new Address(address), its);
}

// 路 4:序列化往返
static Order viaSerialization(Order o) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream(256);
try (ObjectOutputStream out = new ObjectOutputStream(bos)) {
out.writeObject(o);
}
try (ObjectInputStream in =
new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()))) {
return (Order) in.readObject();
}
}

另外补一条对照:clone() 之后手工把六个字段换成新对象(下面记作「clone + 补深」)。它的深度与路 3 相同,用来把「机制」和「深度」分开:同样是深拷贝,一边是一次 VM 级复制加五次字段替换,另一边是一次构造器调用加八次分配。

怎么量

分配字节用 com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes(),取一个长循环前后的差值除以次数。这份计数来自线程的分配统计,不受 CPU 争用影响。

耗时每轮 100 万次,热身后测 3 轮,取中位数,同时记录最小值。计时循环跑在全局文件锁里,同一时刻只有一个进程在抢 CPU。

副本要写进一个静态字段再消费。拷贝的用途就是让副本留下来继续用;不写出去,C2 的逃逸分析会把整个副本消掉,测出来的就不是拷贝的成本。这一点有对照组:副本不出循环时,clone 在稳定轮里的分配是 0.0 B/次。

每个变体有独立的测量循环,调用点单态。四条路如果共用一个接口或一个 lambda 调用点,调用点会变成 megamorphic,JIT 不再内联,快的路会被拖慢,这会直接污染结论。

空转基线单独测一行:只读源对象的字段、不拷贝,实测 0.0 B/次,说明消费动作本身不产生分配。

两个 JDK:21.0.8 与 25,都是 -Xmx2g,同一台 M1 Pro(arm64)。原始输出留在 /tmp/pattern-runs/Prototype.log,含每轮的毫秒数、GC 次数与 getThreadAllocatedBytes 的原始差值。

局限

这是手写脚手架,不是 JMH。数字只用来分档,结论只建立在量级上。耗时包含 GC 时间(每轮的 GC 次数在日志里,可以核对),所以中位数和最小值一起给出。对象图是中等规模(深拷贝一次 600 B),换成很大的图,序列化那一列的比例会变,其余三列的相对关系不变。

三、实测一:哪个是浅拷贝

方法:每一种写法都从一个全新的源对象开始,先比对副本与原对象的字段值,再用副本改七个位置,回头看原对象有没有被改到。

路径 值一致 id amount int[] List Map Address Item[]元素
Object.clone true 独立 独立 泄漏 泄漏 泄漏 泄漏 泄漏
拷贝构造 true 独立 独立 独立 独立 独立 泄漏 泄漏
手写 deepCopy() true 独立 独立 独立 独立 独立 独立 独立
clone + 补深 true 独立 独立 独立 独立 独立 独立 独立
序列化往返 true 独立 独立 独立 独立 独立 独立 独立

(「泄漏」= 改副本改到了原对象。两个 JDK 上逐格相同。)

五行里有两处陷阱,位置不同。

第一处是 Object.clone:七个位置全泄漏。javadoc 的原文写得很直白:performs a "shallow copy" of this object, not a "deep copy" operation

第二处是拷贝构造。它照着 JDK 集合的写法重建了数组、List、Map,却把 addressitems 的元素按引用带走。危险在于这一行长得最像对的:改副本的 List 不动源对象,改副本的 items[0].sku 动。写单测时如果只验 List,这条路的 bug 会一直躺着。

第 4 行说明机制本身不妨碍深拷贝:clone 补深之后全独立,「VM 快照 + 五次字段替换」同样能给出完整的深拷贝。

深度是逐字段决定的

一个对象图里每个可变字段各有一档深度。「这个类是不是深拷贝」这种问法没有答案:Order 的五个嵌套字段可以各自选,quantities.clone() 要复制,address 共享也行(前提是 Address 不可变)。判据落在字段上。

Object.clone 的 javadoc 给的建议与此一致:如果类里只有基本类型字段或不可变对象的引用,super.clone() 之后一个字段都不用改;否则要把构成内部深结构的可变对象复制一遍再换掉引用。这段文字的语气像建议,内容是唯一的正确用法。

把这套判据落到 Order 上,五个嵌套字段各自有答案:

字段 判据 决定
quantities 副本要能独立改数量 clone()
tags 副本要能加减标签 new ArrayList<>()
counts 同一份计数不会跨副本共享 new HashMap<>()
address 订单地址随副本走,但地址对象没人单独改 共享
items 元素里的数量会被副本改 逐元素 new Item()

五个字段里四个复制、一个共享,这就是这份对象图的「深度」。任何一句「Order 是深拷贝」都没法表达这个分布。表里第二列才是要维护的东西:共享的前提写在业务上,代码里看不出来,值得写进注释。

再往下还有一层:address 共享是因为它不可变。如果哪天 Address 加了一个 setter,这个共享决定就失效了,而改动点在另一个类里。把共享的字段类型标成不可变(record、final 字段、无 setter),判据才站得住。

四、实测二:分配字节

100 万次操作的平均值,两个 JDK 各测一遍:

路径 JDK 21.0.8 JDK 25 分配了什么
Object.clone 48.0 B 48.0 B 实例本身
拷贝构造 448.0 B 448.0 B 一层结构
手写 deepCopy() 600.0 B 600.0 B 全图
clone + 补深 600.0 B 600.0 B 全图
序列化往返 21512.0 B 20080.0 B 全图 + 流水线

48 B 正好是 Order 实例的大小:12 字节对象头、1 个 long、6 个引用(压缩指针下各 4 字节),对齐到 48。这条路除此之外不分配任何东西,六个字段全是原引用,所以它在泄漏矩阵里全露。

448 到 600 差的 152 B,恰好是 1 个 Address(12 + 3 个引用 = 24 B)加 4 个 Item(各 12 + 4 + 4 + 8 = 28 → 32 B),24 + 128 = 152。深度版多出来的就是它没共享的那几个节点。

序列化那一列是另一个量级:同样一份数据,分配是手写深拷贝的 36 倍,是 clone 的 448 倍。

21 KB 花在哪

对象图序列化之后是 910 B。把往返拆成四段,看分配从哪一步开始跳:

阶段 累计分配 本步增量
new ByteArrayOutputStream(256) 296.0 B 296
+ new ObjectOutputStream 2560.0 B 2264
+ writeObject + flush 5096.0 B 2536
+ ObjectInputStream + readObject 21512.0 B(JDK 25:20080.0 B) 16416(JDK 25:14984)

输出流一侧 2.2 KB,来自它构造时建好的几块缓冲与表:1 KB 的字节缓冲(BlockDataOutputStream.bufjava.base/java/io/ObjectOutputStream.java:1638)、256 字符的字符缓冲(1635 行定义长度,1642 行分配)、5 字节的块头缓冲,以及一张 10 槽的句柄表(HandleTablespinenextobjs 三个数组各 10 项,2087-2089 行)。写出去再加 2.5 KB,主要来自 ByteArrayOutputStream 容纳 910 B 时的翻倍扩容(256 → 512 → 1024)。

大头在读回来这一步。ObjectInputStream 同样自带缓冲、句柄表、校验列表,readObject 还要为每个读回来的字符串建过程对象。这份图一次往返有 18 个字符串(1 个 id、5 个 tag、5 个 map 键、3 个地址字段、4 个 sku),每个字符串都有一段创建成本。

序列化深拷贝每次要分配约 20 KB,其中数据本身 910 B。它适合做「偶尔一次」的深拷贝,比如把对象存进缓存或跨进程传递;放进每请求的热路径就是另一回事了。

两个 JDK 的 1.4 KB 差

同一份代码,序列化那条路在 JDK 21 上分配 21512 B,JDK 25 上 20080 B,差 1432 B。原因在读字符串的写法上。JDK 25 的 ObjectInputStream 里:

1
2
3
3549	                // Avoid allocating a StringBuilder if there's enough data in buf and
3550 // cbuf is large enough
3551 if (avail >= utflen && utflen <= CHAR_BUF_SIZE) {

java.base/java/io/ObjectInputStream.java:3549-3551)ASCII 字符串直接 new String(buf, pos, ascii, ISO_8859_1) 就地取材,短的非 ASCII 字符串走实例自带的 char[] cbuf。JDK 21 的同一段是 private long readUTFSpan(StringBuilder sbuf, long utflen),每个字符串要先建一个 StringBuilder(默认 16 字符的 byte[],加上对象本身约 80 B)。18 个字符串乘 80 B 得到 1.4 KB,与实测的差额同量级。

分配量由实现细节决定,跨版本没有保证。同一段业务代码在两个 JDK 上的分配可以差出可观的一块,把 getThreadAllocatedBytes 挂进测试用例比猜可靠。

副本不逃逸时 C2 会把它消掉

对照组:测量循环里不把副本写出去,只在一个方法内创建、读字段、丢弃。第一轮分配 3.9 B/次(JDK 25 上 4.3 B/次),之后两轮掉到 0.0 B/次,耗时也从 12.9 ns 降到 6.1 到 6.2 ns,与空转基线(6.3 ns)齐平。逃逸分析判定 Order 实例不逃逸,C2 把整块分配消掉,拷贝耗时也就不存在了。

48 B 与 0.0 B 的差,是「副本要留下来」这件事的代价。一类微基准的数字漂亮到假,原因就在这里:被测对象没逃出测量方法,JIT 先把题目改小了。

五、实测三:耗时

每轮 100 万次,热身后 3 轮,表里是中位数:

路径 JDK 21.0.8 中位数 JDK 21.0.8 最小 JDK 25 中位数 JDK 25 最小
空转基线 consume(src) 6.3 6.2 6.2 6.0
Object.clone 6.7 6.6 7.3 6.9
拷贝构造 104.6 97.6 74.3 67.8
手写 deepCopy() 92.0 87.6 66.6 66.3
clone + 补深 86.8 86.7 63.2 63.0
序列化往返 18641.6 18579.2 16009.1 16008.7

(单位 ns/次。中位数来自 3 轮,最小列取 3 轮里最快的那一轮。)

四条拷贝路的耗时与分配:clone 一档、三条深拷贝一档、序列化单独一档,分配与耗时同向

三个档位

第一档只有 Object.clone 和空转基线,差 0.4 ns(JDK 21)到 1.1 ns(JDK 25)。同一轮里记录的分配是 48 B/次,说明这 48 B 每次都分配了,没有被优化掉。一次 48 B 的 TLAB 分配加上七个字段的复制,在这个循环里的边际成本不到 1.5 ns;空转基线的 6.2 ns 里,大头是消费动作(读七个字段)和写 SINK 那次 volatile 写。

第二档是三条深拷贝路,63 ns 到 105 ns。把 clone 的浅拷贝补成深拷贝,多花的那部分就是它的深度:552 B 的节点加约 60 到 100 ns。这一档的内部排序不可靠,轮间波动就有 ±15%(拷贝构造在 JDK 21 上的三轮是 126、97、104 毫秒),两个 JDK 之间又差 20% 到 30%。能站住的结论是档位差,档内名次排不出来。

第三档只有序列化,16009 ns 到 18642 ns。跨档的比值是稳的:

比值 JDK 21.0.8 JDK 25
手写 deepCopy() ÷ clone 13.7 9.1
序列化 ÷ 手写 deepCopy() 202.6 240.4
序列化 ÷ clone 2782 2193

耗时和分配同向:48 B 对 6.7 ns,600 B 对 92 ns,21512 B 对 18642 ns(JDK 21 口径)。分配多两个数量级,耗时也多两个数量级。

计时那几轮的同口径分配与前面那张表一致,误差在 1% 以内(序列化 21656 B 对 21512 B,两个数来自不同的测量阶段),这在两套口径之间交叉验证了分配数字。

序列化的耗时里有回收成本

整段计时里 GC 次数是 395(JDK 21)与 368(JDK 25)。这个量级来自序列化那一条路:3 轮 × 100 万次 × 20 KB ≈ 60 GB 垃圾,堆只有 2 GB。所以 16 到 18 µs 里含着这些垃圾的回收,日志里有每个变体各自的轮次毫秒数可以核对。

换算到线上:每秒 1000 次拷贝的位置上,序列化是每秒钟 16 毫秒的 CPU 加 20 MB 垃圾;同一个位置换成手写深拷贝,两项都掉两个数量级。所以这条路只适合放在低频边界(落盘、进缓存、跨进程),不放在每请求路径上。

六、JDK 源码里的证据

转述容易把边界说糊,下面几段逐字取自 JDK 25 的 src.zip

Object.clone@implSpecjava.base/java/lang/Object.java:215-219):

1
2
3
4
5
215	     * Otherwise, this method creates a new instance of the class of this
216 * object and initializes all its fields with exactly the contents of
217 * the corresponding fields of this object, as if by assignment; the
218 * contents of the fields are not themselves cloned. Thus, this method
219 * performs a "shallow copy" of this object, not a "deep copy" operation.

as if by assignment 这四个词是浅拷贝的准确描述:副本的字段赋值过去,和 b.field = a.field 没有区别。同一份 javadoc 的 196-200 行给出的补救办法:

1
2
3
4
5
196	     * By convention, the object returned by this method should be independent
197 * of this object (which is being cloned). To achieve this independence,
198 * it may be necessary to modify one or more fields of the object returned
199 * by {@code super.clone} before returning it. Typically, this means
200 * copying any mutable objects that comprise the internal "deep structure"

方法本身是 native(Object.java:234-235):

1
2
234	    @IntrinsicCandidate
235 protected native Object clone() throws CloneNotSupportedException;

@IntrinsicCandidate 说明它是一个 VM 内建:复制一个实例的字段布局,没有 Java 层循环。四条路里只有它把「复制字段」这一步交给了 VM。

Cloneable 这个标记接口的措辞(java.base/java/lang/Cloneable.java:29-32):

1
2
3
4
29	 * A class implements the {@code Cloneable} interface to
30 * indicate to the {@link java.lang.Object#clone()} method that it
31 * is legal for that method to make a
32 * field-for-field copy of instances of that class.

同一个文件 43-46 行有一句常被忽略的话:

1
2
3
4
43	 * Note that this interface does <i>not</i> contain the {@code clone} method.
44 * Therefore, it is not possible to clone an object merely by virtue of the
45 * fact that it implements this interface. Even if the clone method is invoked
46 * reflectively, there is no guarantee that it will succeed.

Cloneable 里没有 clone 方法,实现它只是给了 Object.clone 一个许可位,不实现就抛 CloneNotSupportedException

ArrayList#clone 展示了 JDK 自己怎么写这条路的补深部分(java.base/java/util/ArrayList.java:343-348):

1
2
3
4
5
6
7
343	    public Object clone() {
344 try {
345 ArrayList<?> v = (ArrayList<?>) super.clone();
346 v.elementData = Arrays.copyOf(elementData, size);
347 v.modCount = 0;
348 return v;
349 } catch (CloneNotSupportedException e) {

三个动作:super.clone() 拿到浅副本,Arrays.copyOf 换掉内部数组,modCount 清零。数组元素是引用,所以这个 clone 对 List<Order> 里的每一个 Order 都不复制。

Arrays.copyOf 在两边夹了一条快路径(java.base/java/util/Arrays.java:3587-3594):

1
2
3
4
5
6
7
8
9
3587	    public static int[] copyOf(int[] original, int newLength) {
3588 if (newLength == original.length) {
3589 return original.clone();
3590 }
3591 int[] copy = new int[newLength];
3592 System.arraycopy(original, 0, copy, 0,
3593 Math.min(original.length, newLength));
3594 return copy;
3595 }

长度相同时转手调 original.clone(),数组的 clone 是另一个 VM 内建;否则 arraycopy 加长度裁剪。两个分支都是块拷贝。

HashMap 的拷贝构造走的是另一条路(java.base/java/util/HashMap.java:490-494):

1
2
3
4
5
490	    @SuppressWarnings("this-escape")
491 public HashMap(Map<? extends K, ? extends V> m) {
492 this.loadFactor = DEFAULT_LOAD_FACTOR;
493 putMapEntries(m, false);
494 }

putMapEntries 内部按 size / loadFactor + 1 算容量、建表,再对每个条目调用 putVal(hash(key), key, value, false, evict)。同样的 5 条数据,ArrayList 那边是一次 arraycopy,这边是 5 次哈希与插入。散列表结构决定了它没法块拷贝。

HashMap 把浅拷贝和补深合在同一个方法里(java.base/java/util/HashMap.java:1458-1476):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1458	     * Returns a shallow copy of this {@code HashMap} instance: the keys and
1459 * values themselves are not cloned.
1460 *
1461 * @return a shallow copy of this map
1462 */
1463 @SuppressWarnings("unchecked")
1464 @Override
1465 public Object clone() {
1466 HashMap<K,V> result;
1467 try {
1468 result = (HashMap<K,V>)super.clone();
1469 } catch (CloneNotSupportedException e) {
1470 // this shouldn't happen, since we are Cloneable
1471 throw new InternalError(e);
1472 }
1473 result.reinitialize();
1474 result.putMapEntries(this, false);
1475 return result;
1476 }

super.clone() 先把原表一起带过来,reinitialize() 清空这些字段,putMapEntries 重建内部结构。这就是「clone + 手工补深」在 JDK 里的写法,注释第一句把深度交代清楚:keys and values themselves are not cloned。

最后是序列化那条路里的一次改进(java.base/java/io/ObjectInputStream.java:3536-3555):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
3536	            if (utflen > 0 && utflen < Integer.MAX_VALUE) {
3537 // Scan for leading ASCII chars
3538 int avail = end - pos;
3539 int ascii = JLA.uncheckedCountPositives(buf, pos, Math.min(avail, (int)utflen));
3540 if (ascii == utflen) {
3541 // Complete match, consume the buf[pos ... pos + ascii] range and return.
3542 // Modified UTF-8 and ISO-8859-1 are both ASCII-compatible encodings bytes
3543 // thus we can treat the range as ISO-8859-1 and avoid a redundant scan
3544 // in the String constructor
3545 String utf = new String(buf, pos, ascii, StandardCharsets.ISO_8859_1);
3546 pos += ascii;
3547 return utf;
3548 }
3549 // Avoid allocating a StringBuilder if there's enough data in buf and
3550 // cbuf is large enough
3551 if (avail >= utflen && utflen <= CHAR_BUF_SIZE) {
3552 JLA.uncheckedInflateBytesToChars(buf, pos, cbuf, 0, ascii);
3553 pos += ascii;
3554 int cbufPos = readUTFSpan(ascii, utflen - ascii);
3555 return new String(cbuf, 0, cbufPos);
3556 }

CHAR_BUF_SIZE 是 256(java.base/java/io/ObjectInputStream.java:2850)。这两段重写针对的是 JVM 优化最不喜欢的东西:一个每次都新建、每次都扔的临时容器。

七、结论

什么时候用哪条

  • 类里没有可变嵌套字段(全是标量或不可变引用):clone 或拷贝构造都够用,48 B 到 448 B 一次,写法随意挑。JDK 自己的 ArrayListBitSetDate 都在这一类。
  • 图层数固定、类型已知:手写复制方法,或者 clone 之后逐字段补深。实测两条路的分配相同(600 B),耗时也接近,取决于你更愿意写哪种代码。
  • 只有一层嵌套:拷贝构造,重建数组与集合,成员对象共享。这是 JDK 集合的惯例,也是我上面那版 new Order(src) 的写法。
  • 需要跨流、跨进程、跨缓存传递:序列化往返。它的 20 KB/次换来的是「不用为每个类写复制代码」,在日志、会话、RPC 这类边界上划算。

什么时候不用原型

对象不可变时没有拷贝问题,直接共享引用(见 不可变与防御性拷贝)。构造过程便宜、参数齐全时,原型多引入一层「源对象状态可能过期」的隐式依赖,直接 new 更清楚。类型是接口且实现不断新增时,每个实现都要维护自己的拷贝逻辑,漏一个就有一条静默的共享路径,这种场合序列化或显式映射往往更省心。

还有一条边界:Object.clone() 的 javadoc 里,x.clone().getClass() == x.getClass()x.clone().equals(x) 都用「惯例」描述,不是硬性要求。子类只要不用 super.clone(),这两个等式就会破。

字段带 final 的类也用不了「clone + 补深」:clone() 里给 final 字段换引用会编译失败(无法为 final 变量 a 分配值),只能改成拷贝构造(把复制放进构造器)或序列化。record 的字段全是 final,实测 record Rec(int[] a) 实现 Cloneable 并调用 super.clone() 能编过、能跑,但拿到的是浅拷贝,改副本的数组仍会改到源对象。record 做深拷贝只有两条路:紧凑构造器里 a = a.clone(),或者实现 Serializable 走序列化。

序列化的三份额外代价

第一份是兼容性。序列化把字段名与类型写进流里,类一改名、改类型、换包,旧数据就反序列化不回来。serialVersionUID 显式声明能挡一部分,但字段结构的变化(删字段、改类型)依然要自己写迁移逻辑。这份代价在你把对象写进磁盘、写进消息队列之后才开始付,且没有回头路。

第二份是安全。反序列化会执行流里的类构造与 readObject 回调,攻击面来自数据本身。JDK 9 起有了反序列化过滤器(JEP 290),17 起可以在每个 Stream 上下文上挂自己的过滤策略(JEP 415)。过滤器要有人配:没有配置时反序列化不做限制,而「内部深拷贝」这种纯内存需求一旦实现成序列化往返,就等于把这条攻击面引进来,收益只是少写几行复制代码。

第三份是版本漂移。这次实测里,同一个往返在 JDK 21 与 JDK 25 上分配差 1432 B,耗时也差 14%(18642 对 16009 ns)。分配量不是契约,升级 JDK 会变;如果热路径压在序列化往返上,这份漂移会直接落到吞吐上。

一条判断顺序

  1. 先问这个对象需不需要可变。不可变就没有深拷贝这件事,共享一份实例即可。
  2. 可变但没有嵌套可变字段:clone 或拷贝构造,用最短的那种。
  3. 有嵌套可变字段:列字段清单,逐个决定共享还是复制。共享的对象要么不可变,要么在业务上允许被副本影响。
  4. 决定复制的字段,用手写复制方法写出来。如果实现 Cloneableclone() 里把字段换掉再返回,让两条路给出同样的深度。
  5. 只有当「对象图会变化、不想为每个类写复制代码」并且「这条路径不在热路径上」两条同时成立时,才用序列化。

在第 3 步之后加一条验证:写一个和本文第三部分一样的布尔测试,改副本的每个可变字段,断言原对象不变。这类测试跑一次就知道深度写对了没有。

总结

  • 四条路的分配分别是 48 B、448 B、600 B、21512 B(JDK 25 上 20080 B),最贵的一条是最便宜的 448 倍。
  • 耗时排序与分配同向,clone 那条路走在 VM 内建上,序列化那条路每次重建约 20 KB 的流水线。
  • Object.clone 默认给出浅拷贝,七个测试位置全部泄漏;拷贝构造只重建一层,addressitems 元素仍然共享。
  • 深度是逐字段决定的。「这个类是深拷贝」不是有效描述。
  • 序列化的分配还会随 JDK 版本变化(本次实测两个 JDK 差 1.4 KB),把分配数当契约会吃亏。

参考资料

系列索引:设计模式系列