备忘录把「回到过去」变成两个方法调用:save() 存下一版,restore() 回到某一版。
存一版花多少时间、占多少内存,模式定义里没有答案。全量深拷贝、增量日志、写时拷贝三条路都能实现回滚,代价相差两个数量级。
本文用 1 万条记录(含标签集合)的状态、1000 个事务、每事务改 10 条,量三条路的每事务耗时与分配字节,再量历史上限 10/100/1000 版时的保留内存和回滚耗时。

一、回滚能力买的是什么

发起人(Originator)把状态装进一个备忘录对象,管理者(Caretaker)把备忘录收进版本链,需要时再把某一版交回发起人恢复。快照粒度、版本存放位置、版本何时失效,这些都不在模式定义里。

三个问题决定价格:

  • 存的是全部状态,还是这一版改动的增量。
  • 版本链保留多少版。
  • 代价在存的那一刻付,还是在每次改动时付。

第一个问题把实现分成三条路,后两个问题决定账单什么时候来。

三条路

全量深拷贝save() 把当前状态整份复制一份挂到链上,回滚时把复制出来的那份换回来。

增量日志save() 只记一个位置,每次改动之前把旧值写进日志,回滚时按逆序重放。

写时拷贝:状态本身不可变,每次改动产生一份新的、只复制被改动路径的状态,版本链上存旧的根引用,回滚就是换回引用。

三张账单落在不同位置:

与原型的分工

原型模式解决「怎么复制一个对象」(原型),备忘录解决「怎么回到过去」。两者的实现里都有复制,边界不同:原型的一次 clone() 之后就结束了,复制出来的对象做什么与原型无关;备忘录要维持一条版本链,链上每一版的存活时间、内存占用、能否再次回滚,都算备忘录的账,本文只算这一笔。

版本链放哪儿

本文的三条路都默认版本留在内存里,回滚是对象引用之间的操作。第四条路把版本写成字节流:Serializable、JSON,或者数据库里的一行。它的账本换了一套计量单位,分配字节变成序列化耗时加 IO 字节,三条路之间的价差会重新排列。

写时拷贝的结构共享在落盘时消失,每一版都得写出完整字节流,它的优势只剩下「不必为快照做额外工作」。增量日志反而最适合落盘,它写出来的本来就是增量。全量深拷贝一旦超出内存就只能靠磁盘换深度:1 万条记录一版 1 MB,30 天的历史在磁盘上同样是 30 GB 量级。

落盘还多出一项内存里没有的成本:版本的兼容性。类加了字段之后旧字节流还能不能读出来,取决于序列化方案,这一项不在本文的测量范围内。

复杂度写在纸上

路径 建一版 每次改动 回滚一版 版本能否重复使用
全量深拷贝 O(N),N = 记录数 O(1) O(1) 不能,回滚即消费
增量日志 O(1) O(1) 登记 O(改动数)
写时拷贝 O(1) O(√N),见局限一节 O(1)

二、实验设计

状态与操作流

1 万条记录,每条四个字段:idnameqtytags(3 个字符串的 ArrayList)。初始状态约 1 MB,存在一个 ArrayList 里,按 id 顺序索引。

1000 个事务,每个事务先 save() 建一版,再改 10 条记录。改动类型 5 种循环:改名字、改数量、加标签、删标签、换标签,每 10 次改动里有 6 次会碰到标签集合。行号由固定种子生成,三条路跑完全相同的操作流。

三条实现

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* 全量深拷贝:一次 save 复制 1 万条 */
void snapshot() { history.add(copy(live)); }

static ArrayList<MRow> copy(ArrayList<MRow> src) {
ArrayList<MRow> out = new ArrayList<>(src.size());
for (MRow r : src) out.add(r.copy()); // 每条记录 + 新标签列表
return out;
}

void rollbackOne() { live = history.remove(history.size() - 1); }

/* 增量日志:save 只写一个下标,代价在改动时 */
void snapshot() {
if (markCount == marks.length) { // 历史到顶,丢掉最旧的一版
int from = marks[0];
log.subList(0, from).clear(); // 截断它的条目,O(剩余条目)
System.arraycopy(marks, 1, marks, 0, marks.length - 1);
for (int i = 0; i < markCount - 1; i++) marks[i] -= from;
markCount--;
}
marks[markCount++] = log.size();
}

void apply(int[] op) {
MRow r = live.get(op[0]);
Undo u = new Undo(r, op[1]);
if (op[1] == 0) u.oldName = r.name; // 改名字,存旧引用
else if (op[1] == 1) u.oldQty = r.qty; // 改数量,存旧值
else u.oldTags = new ArrayList<>(r.tags); // 碰标签,复制旧列表
log.add(u);
mutate(r, op[1], op[2]);
}

void rollbackOne() {
int from = marks[--markCount];
for (int i = log.size() - 1; i >= from; i--) restore(log.get(i));
log.subList(from, log.size()).clear();
}

/* 写时拷贝:状态不可变,每次改动复制被改动的那条路径 */
void snapshot() { history.push(root); } // 一个引用

void apply(int[] op) {
int c = op[0] / CHUNK, o = op[0] % CHUNK; // CHUNK = 100
IRow[][] nr = root.clone(); // 复制根数组,100 个引用
IRow[] chunk = root[c].clone(); // 复制所在分块,100 个引用
chunk[o] = mutate(chunk[o], op[1], op[2]);
nr[c] = chunk;
root = nr;
}

void rollbackOne() { root = history.pop(); }

三段机制的代码量:深拷贝 27 行,增量日志 54 行,写时拷贝 89 行(非空非注释行,含大括号;写时拷贝那份还包含初始状态构造和 5 个改动分支)。新增一种改动时要碰几个地方,这才是维护成本的差别。深拷贝只改一个 mutate 分支,快照和回滚不用动。增量日志要改两处:登记旧值的分支和恢复的分支,漏掉任何一处都会回滚到错误的状态。写时拷贝改一处,代价是 mutate 必须返回新对象,不能原地修改。

怎么确认三条路算的是同一件事

跑一条 1000 条记录、100 个事务的操作流,每个事务结束后算一次状态校验和(id、name、qty、tags 的滚动哈希加上标签总数),三条路的 100 个校验和逐一相等。之后各回滚 30 版,取到的状态与深拷贝路径记录的第 70 版样本一致:

1
2
3
4
5
deep  逐版校验和已记录
undo 与 deep 一致 = true
undo 回滚 30 版后 == 第 70 版: true (history=70)
cow 与 deep 一致 = true
cow 回滚 30 版后 == 第 70 版: true (history=70)

对不上就没必要往下测。

计量方法

  • 计时:System.nanoTime() 分别包住 snapshot() 与改动循环,累加后除以事务数。每个 JDK 跑 4 轮,第一轮预热丢弃,取后 3 轮中位数。计时期间持一把全局文件锁(22 个同类实验并行会互相抢 CPU)。
  • 分配字节:com.sun.management.ThreadMXBean#getThreadAllocatedBytes,是 JVM 里的计数器,不是估算。
  • 保留内存:System.gc() 前后堆占用量差值,再与按实测对象尺寸做的对象图核算交叉验证。
  • 回滚计时:单次回滚只有几十到几百纳秒,一次 GC 停顿就足以淹没它。做法是预热一轮,再按分块计时(1000 版时每块 50 版),取块均值的最小值。

局限

  • 单机微基准,Apple M1 Pro,判断量级可以,不能当成硬件上限。
  • 名字与标签取自固定的字符串池,三条路共享这些 String 对象。这一条对全量深拷贝是乐观的折扣:真实文档里 name 各不相同的话,深拷贝还要多复制字符串内容。
  • 每事务改 10 条是刻意选的中间值。改动数越少,深拷贝越亏,它按记录总数收费;改动数越多,日志条目越多。
  • 写时拷贝用两层分块(100 个分块 × 100 条),一次改动复制 200 个引用,是 O(√N)。32 叉持久化向量把这一步压到 O(log₃₂N),在 1 万条这个量级上节点数相当(约 96 个引用加 3 层对象),量级结论不变。
  • 有界历史需要淘汰最旧的版本,淘汰本身有成本。深拷贝路径从 ArrayList 头部摘掉一版要把后面的引用整体前移;增量日志要截断日志头部并重算剩下的版本起点。计时跑法上限 10 版,所以增量日志的 snapshot() 里含了淘汰成本,但每 10 个事务才触发一次,且只搬移 100 条条目。上限 1000 版时每淘汰一版要搬移上万条,那一档的成本远大于本文测到的。

三、实测一:每个事务的代价

路径 快照 ns/事务(JDK 25 / 21) 改动 ns/事务 每事务合计 ns 分配字节/事务 每轮 GC
无快照(对照) 42 / 37 387 / 485 429 / 522 59 0 次
增量日志 136 / 134 554 / 898 690 / 1,032 824 0 次
写时拷贝 66 / 63 4,299 / 3,907 4,365 / 3,970 9,691 0 次
全量深拷贝 152,544 / 145,824 1,473 / 1,457 154,017 / 147,281 993,897 2 到 3 次,5 到 8 ms

三条快照路径的每事务耗时与分配、历史深度与保留内存、建版与回滚的对比,以及代价随每事务改动条数的变化

223 倍

深拷贝每个事务 154,017 纳秒,增量日志 690 纳秒,相差 223 倍。分配字节 993,897 对 824,相差 1,206 倍。写时拷贝夹在中间:每事务 4,365 纳秒、9,691 字节,比增量日志贵 6.3 倍和 11.8 倍,比深拷贝便宜 35 倍和 103 倍。

两个 JDK 的数并排看,差异在几个百分点量级。深拷贝的快照耗时 152,544 对 145,824,上一轮跑出来是 147,023 对 148,639,符号两次相反,属于运行间的抖动。写时拷贝的改动耗时 4,299 对 3,907,两次运行方向一致,JDK 25 慢 10% 到 17%,原因没有追查。这三条路的价格由机制决定,JDK 21 到 25 没有改变任何一条的算法。

换成吞吐看:只做这 10 次改动,单线程每秒能跑约 233 万个事务;加一次全量深拷贝,掉到每秒约 6,500 个。154 微秒在数据库往返面前不算什么,在一次纯内存计算面前是 359 倍。

快照那一步不总是贵的那一步

单看 snapshot():深拷贝 152,544 纳秒,增量日志 136 纳秒,写时拷贝 66 纳秒。写时拷贝那 66 纳秒就是一次引用入栈。增量日志的 136 纳秒分两部分:写一个下标;历史上限到顶时搬移日志头部,截断最旧一版的 10 个条目、重算剩下 10 个版本的起点,上限 10 版意味着每 10 个事务触发一次。

省下的代价没有消失,它出现在改动一侧。对照行说明改动本身有多便宜:不做任何快照时,同一批 10 次改动只要 387 纳秒、59 字节。增量日志把改动推到 554 纳秒、824 字节,多出来的部分是 10 个日志条目加 6 份标签列表副本。写时拷贝把改动推到 4,299 纳秒、9,691 字节,其中 8,720 字节是 10 次路径复制(每次 872 字节),剩下的是 10 条新记录和 6 份新标签列表。

选路之前先看清自己的负载:读多写少,把代价放在写侧更划算;一次事务改几千条,深拷贝反而合适。

只有一条路的分配量进得了 GC 的视野

1000 个事务跑完,深拷贝分配 993,897,000 字节,约 948 MiB。G1 每轮收集 2 到 3 次,5 到 8 毫秒。另外两条路 1000 个事务合计分配 0.82 MB 和 9.7 MB,一轮下来 GC 次数是 0。

948 MiB 不是常驻内存,因为版本上限是 10,链上只留 10 份。分配量本身仍然是代价:994 KB 的复制占了快照耗时的主要部分,同时把年轻代快速填满,对象成批进入老年代。

代价随改动条数怎么变

固定 1 万条记录,把每事务的改动条数从 1 扫到 10,000(40 个事务,只数分配字节)。两个 JDK 的数相差不到 0.1%,分配计数由算法决定,与 VM 版本无关:

每事务改动条数 全量深拷贝 增量日志 写时拷贝
1 条 1,000,056 B 177 B 973 B
10 条 999,832 B 944 B 9,699 B
100 条 997,874 B 8,432 B 96,919 B
1,000 条 991,312 B 81,868 B 968,165 B
10,000 条 1,012,014 B 799,136 B 9,605,348 B

改动 1 条和改动 10,000 条,全量深拷贝都分配 1.0 MB,两次的差异来自标签列表长度的分布,在 ±1% 以内。它是一条平线,高度由记录总数决定,与这一版改了多少无关。

增量日志每改一条花 80 到 177 字节,写时拷贝每改一条约 960 到 970 字节。两条直线斜率不同,交点就落在不同的地方:

  • 写时拷贝与深拷贝在每事务约 1,040 条改动处打平,占 1 万条状态的一成。改得比这更多,按需复制比整份复制更贵:10,000 条改动时它分配 9.6 MB,是深拷贝的 9.5 倍。
  • 增量日志在 1 万条这个规模上没有交点。改满全部 10,000 条时它分配 799,136 字节,是深拷贝的 0.79 倍。代价转移到回滚侧:这一版的日志里有 10,000 个条目,回滚要重放 10,000 次。

一次事务改掉状态的一成以上,写时拷贝的「按需」优势就消失了。

四、实测二:历史深度换内存

历史上限 全量深拷贝 增量日志 写时拷贝
10 版 10.0 MB 7.4 KB 51.7 KB
100 版 100.0 MB 74.0 KB 531.6 KB
1000 版 1,000.1 MB 740.0 KB 5.3 MB

(MB 按 10⁶ 字节计。核算口径:版本链在活状态之上多保留的字节。)

每版的成本是常数,与事务里改了几条无关:深拷贝 1,000,072 字节,增量日志 740 字节,写时拷贝 5,316.5 字节。深拷贝是增量日志的 1,351 倍,是写时拷贝的 188 倍。(深拷贝这个数与第三节表里的 993,897 字节差 0.6%,两次的标签列表长度分布略有不同。)

后两个数可以对上账。一个事务 10 次改动,改名字和改数量各 2 次,每次一个 40 字节的日志条目(存旧字符串引用或旧 long);剩下 6 次碰标签,每次除条目外还要复制一份旧标签列表,56 字节(ArrayList 对象 24 字节加底层数组 32 字节,3 个元素和 4 个元素对齐到同一大小)。4 × 40 + 6 × 96 = 736 字节,加上版本起点在 int[] 里占的 4 字节,正好 740。

写时拷贝的账是另一组加法:10 次改动各复制一次分块与根数组,实测 872 字节一次,合计 8,720;10 条新记录 400 字节;6 份新标签列表 528 字节。三项合计 9,648,占实测每版 9,706 字节的 99%。

用户说「要能回滚到 30 天前」,这句话翻译成内存预算就是这段时间里每次提交各留一份状态。每天 1000 次提交、每次 1 MB,一天 1 GB,30 天 30 GB。同样一条历史换成增量日志,按每事务 740 字节算是 22 MB。

System.gc() 前后的堆占用差值交叉验证 depth=1000 这一行:深拷贝 996.06 MB,增量日志 733,144 字节,写时拷贝 5,209,960 字节。三个数与核算值相差在 12% 以内,误差来自 GC 后的碎片与测量本身的抖动。深拷贝那个 996 MB 与核算的 1,000 MB 差 0.4%。

256 MB 堆装得下多少版

堆上限 全量深拷贝 增量日志 写时拷贝
256 MB 第 264 版 OutOfMemoryError 10,000 版正常 10,000 版正常
512 MB 第 534 版 OutOfMemoryError 未测 未测
1024 MB 1,000 版刚好装下 未测 未测

264 乘 1 MB 约 264 MB,534 乘 1 MB 约 534 MB,比例线性。同样 256 MB 的堆,增量日志和写时拷贝都建满了 10,000 版,因为它们的版本链加起来还不到 1 MB 和 50 MB。

这是全量深拷贝最硬的限制:堆上限直接换算成历史深度上限,换算比例是一版一份完整状态。产品需求里的「保留 30 天」在这个换算下等于「30 天内每次提交的状态都留在内存里」。

五、实测三:回滚的代价

路径 回滚一版 ns(JDK 25 / 21) 回滚整条 1000 版
全量深拷贝 235 / 255 235 µs
增量日志 120 / 202 120 µs
写时拷贝 40 / 41 40 µs

三条路的回滚都在 1 微秒以内,最大差距 6 倍。对比建一版的差距(见图右下角),回滚快慢不是选型依据。depth=10 和 depth=100 的回滚循环太短,JIT 来不及编译,那两档的数不可比,这里只列 depth=1000。

回滚之后还能再回滚吗

深拷贝的 live = history.remove(history.size() - 1) 把那一版从链上摘下来当活状态用。之后的任何改动都会写进这份副本,链上再没有干净的第 N 版。要同时保留它,就得再拷一份,也就是再付 152 微秒和 994 KB。

增量日志不同:重放不改动日志条目本身。本文的实现顺手把日志截断了,保留条目、只移动游标同样可行,代价是一个 int。

写时拷贝不用再复制一份。版本不可变,root = history.pop() 之后链上每一版都还在,反复回到同一版不花额外代价。

回滚之后再编辑

带撤销的编辑器通常还有重做。用户撤销三步再输入一个字,重做栈就该清空。这条规则对三条路的影响不同:

深拷贝路径上,回滚换回来的那份副本就是活状态,用户接着输入的第一个字符会写进这份副本,链上原本干净的第 N 版随之消失。想保留它,复制就得挪到回滚那一刻,152 微秒和 994 KB 再付一次。undo/redo 栈常见的「撤销后一编辑就丢失全部重做记录」,成本上的原因就在这里:要么复制,要么丢弃。

增量日志和写时拷贝没有这个分叉。日志条目留在原地,游标往后挪一格就是重做;写时拷贝把旧根引用留在另一个栈里,重做就是把根换回去。

回滚的规模

回滚一版的成本和历史深度无关,回滚整条的成本随深度线性增长(见上表最后一行)。需要「回到任意一版」的界面要把版本列出来,贵的是为每一版存一个可读的摘要:校验和或时间戳,几百字节;摘要要从状态里现算时,又变成一次 O(N) 遍历。

六、JDK 源码里的快照原语

全量复制最终落在 System.arraycopy

JDK 里最短的一次浅拷贝是 ArrayList.clone()

1
2
3
4
5
6
7
8
9
10
11
12
/* java.base/java/util/ArrayList.java:343-353(JDK 25) */
public Object clone() {
try {
ArrayList<?> v = (ArrayList<?>) super.clone();
v.elementData = Arrays.copyOf(elementData, size);
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
}

(JDK 21 同一段在 ArrayList.java:342-351Arrays.copyOf 那行在 :345。)

Arrays.copyOf 只做一次转调:Arrays.java:3477-3479copyOf(T[], int) 返回 copyOfRange(original, 0, newLength, original.getClass()),复制本身发生在 Arrays.java:3801-3813copyOfRange 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* java.base/java/util/Arrays.java:3801-3813(JDK 25) */
public static <T,U> T[] copyOfRange(U[] original, int from, int to, Class<? extends T[]> newType) {
int newLength = to - from;
if (newLength < 0) {
throw new IllegalArgumentException(from + " > " + to);
}
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, from, copy, 0,
Math.min(original.length - from, newLength));
return copy;
}

System.arraycopy:3810)是 intrinsics,复制 1 万个引用很快。贵的是它前面那行分配。JDK 21 的对应位置是 Arrays.java:3481:3805System.arraycopy:3813,四行代码一字不差。

clone() 复制的是引用,1 万条记录的外层数组是 40 KB,元素对象仍由两个列表共享。本文的 MRow.copy() 连元素一起复制,1 万条 = 960 KB 记录对象加约 40 KB 外层数组,实测一次全量快照 1,000,240 字节。这个差额就是「共享元素」与「独占元素」的分界,也解释了为什么 clone() 在可变对象上不构成快照。共享可变对象是另一类问题的来源,边界在不可变与防御性拷贝那一篇里。

copyOfRange 里那个三元表达式还有一层:目标类型是 Object[] 时直接 new Object[newLength],否则走 Array.newInstance 反射创建。ArrayList.clone() 传进来的正是 Object[],走前一个分支。

ArrayList(Collection) 的构造里能看到同一次判断,ArrayList.java:181-193:来源本身是 ArrayList 时,toArray() 得到的数组直接接管(elementData = a),来源是别的集合类型时才多复制一次。

同样 1 万个状态,BitSet 只要 1.3 KB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* java.base/java/util/BitSet.java:1097-1109(JDK 21 与 25 行号相同) */
public Object clone() {
if (! sizeIsSticky)
trimToSize();

try {
BitSet result = (BitSet) super.clone();
result.words = words.clone();
result.checkInvariants();
return result;
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}

1 万个状态位是 157 个 long,words.clone() 一次复制 1,296 字节(实测分配量,含 BitSet 对象与长数组)。同一批 10,000 个状态换成记录对象,一次深拷贝是 1,000,040 字节。字节差 772 倍,时间差 197 倍(562 纳秒对 110,933 纳秒,JDK 25,同一次运行、同样预热 200 轮之后)。JDK 21 上这两个数是 817 纳秒对 179,487 纳秒,比例相近。

clone() 前面还有一步条件裁剪:sizeIsSticky 为假时先跑 trimToSize()BitSet.java:1116-1121),把 words 收成 wordsInUse 那么长再复制。本文的位图用 new BitSet(10000) 构造,sizeIsSticky 为真(:167),走的是不裁的分支,所以复制的是一个满长度的长数组。

状态能用位图或稀疏结构表示时,快照的价格与记录数量脱钩,这比选哪条快照路径更值钱。

JDK 自己的写时拷贝

CopyOnWriteArrayList 的写侧是 O(N) 复制,读侧拿到的是共享的不可变数组:

1
2
3
4
5
6
7
8
9
10
11
/* java.base/java/util/concurrent/CopyOnWriteArrayList.java:468-477(JDK 25) */
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}

(JDK 21 同一段在 :461-471。)每次写都换一个新数组,迭代器拿着旧数组继续走,不受后续写入影响。这是「写方付全量、读方零成本」的取舍,跟第三节里写时拷贝路径的取舍同源,只是它把代价放在整个数组上,没有分块。

不可变值可以零成本共享

1
2
3
4
5
/* java.base/java/lang/String.java:144 */
public final class String
...
/* java.base/java/lang/String.java:160 */
private final byte[] value;

类与底层数组都声明为 final,javadoc 第 73 行写着 Strings are constant; their values cannot be changed after they are created.,紧跟着一句 Because String objects are immutable they can be shared.。本文三条路径都只复制 name 的引用,不复制字符串内容,靠的就是这一条。

ImmutableCollections.listCopy 把这条规则推到了集合上:

1
2
3
4
5
6
7
8
9
10
11
/* java.base/java/util/ImmutableCollections.java:185-193(JDK 25) */
@SuppressWarnings("unchecked")
static <E> List<E> listCopy(Collection<? extends E> coll) {
if (coll instanceof List12 || (coll instanceof ListN<?> c && !c.allowNulls)) {
return (List<E>)coll;
} else if (coll.isEmpty()) { // implicit nullcheck of coll
return List.of();
} else {
return (List<E>)List.of(coll.toArray());
}
}

输入已经是不可变列表时,:186-187 直接返回同一个实例。实测:List.copyOf(已是不可变列表) 分配 0 字节,List.copyOf(可变的 3 元素 ArrayList) 分配 88 字节。写时拷贝路径的每一条新标签列表都用 List.copyOf 生成,之后所有版本共享它。

不可变带来的共享有边界。StringLatin1.newStringjava.base/java/lang/StringLatin1.java:756-762,JDK 21 在 :748)里,substring 仍然要 Arrays.copyOfRange 复制字节,共享的是引用,不是任意切片。可共享的前提是值本身不变。

七、什么时候用哪条

什么时候不用备忘录

不需要回滚时,同一批操作只要 59 字节和 429 纳秒(对照行)。为一次「也许以后有人想撤销」引入版本链,等于给每个事务加上 0.82 KB 到 994 KB 的固定成本。先确认回滚是需求,再选实现。

判断顺序

  1. 先把状态压小。 能表示成位图、计数器、稀疏映射的状态,快照价格与记录总数脱钩。1 万个状态位用 BitSet 克隆是 1.3 KB,用记录对象深拷贝是 1 MB。
  2. 再问历史深度和写读比例。 几十版加低频回滚,全量深拷贝最省事,994 KB 的一次复制在每秒几次的事务面前无所谓。上千版历史,或者事务频率高,深拷贝的内存会先爆:256 MB 堆上只能存 264 版。
  3. 然后看改动密度。 一个事务改 10 条时增量日志每版 740 字节,深拷贝 1,000,072 字节。事务改掉整个数据集的一多半时,增量日志的条目数逼近全量,优势消失。
  4. 最后看回滚的使用方式。 只允许「撤销最近一步」,深拷贝的消费式回滚够用;要能反复回到同一版,或者要看到完整的版本列表,选版本不可变的那条路。
  5. 把登记代码算进成本。 增量日志最便宜,但它要求每一次状态改动都登记旧值,漏一处就丢一次回滚。本文的三段实现是 27 行、54 行、89 行,行数差距全在登记与恢复的分支上。
  6. 改动密度没有量级之前,先选保守的。 本文里最深的一条价差是 1,351 倍,最浅的一条是 4.8 倍。改动密度、事务频率、历史深度这三个数一旦有数量级上的把握,选型就定了;都还没量过时,按记录数上限估一笔最坏情况,比事后换实现便宜。

总结

  • 三条路径的价格相差两个数量级:1 万条记录、每事务改 10 条时,全量深拷贝 154,017 纳秒和 993,897 字节,增量日志 690 纳秒和 824 字节,写时拷贝 4,365 纳秒和 9,691 字节(JDK 25,中位数)。对照行(不做快照)是 429 纳秒和 59 字节。
  • 快照这一步的价格:深拷贝 152,544 纳秒,增量日志 136 纳秒,写时拷贝 66 纳秒。便宜的那两条把代价移到了改动侧:增量日志的改动从 387 涨到 554 纳秒,写时拷贝涨到 4,299 纳秒。
  • 每版保留字节是常数:深拷贝 1,000,072 字节,增量日志 740 字节,写时拷贝 5,316.5 字节。1000 版对应 1,000.1 MB、740 KB、5.3 MB。
  • 代价随改动条数变化:每事务改 1 条时,深拷贝 1,000,056 字节、增量日志 177 字节、写时拷贝 973 字节;改 10,000 条时,三者分别是 1,012,014、799,136、9,605,348 字节。写时拷贝与深拷贝在每事务约 1,040 条改动处打平,占 1 万条状态的一成。
  • 256 MB 堆上,全量深拷贝建到第 264 版 OutOfMemoryError,512 MB 到第 534 版,1 GB 刚好装下 1000 版。同样 256 MB,增量日志与写时拷贝建满 10,000 版。
  • 回滚一版:深拷贝 235 纳秒,增量日志 120 纳秒,写时拷贝 40 纳秒(depth=1000,分块取最小值)。三条路都低于 1 微秒,不是选型依据。
  • 深拷贝的回滚是消费式的,那一版成了活状态,想保留就得再付一次全量复制。增量日志的条目可以保留只移动游标,写时拷贝的版本不可变,两者都能反复回滚到同一版。
  • ArrayList.clone()ArrayList.java:343-353)只复制引用,Arrays.copyOf 落到 copyOfRangeArrays.java:3801-3813)里的 System.arraycopy 加一次数组分配。深拷贝与浅拷贝的价格差在「元素对象归谁独占」。
  • BitSet.clone()BitSet.java:1097-1109)复制 1 万个状态位只要 1,296 字节、562 纳秒,同样 10,000 条记录深拷贝要 1,000,040 字节、110,933 纳秒。状态表示比快照策略更值钱。
  • ImmutableCollections.listCopyImmutableCollections.java:185-193)对已经是不可变的输入直接返回同一实例,实测 List.copyOf(已是不可变列表) 分配 0 字节。不可变是零成本共享的前提,Stringfinal classprivate final byte[] value 是同一件事。

参考资料

系列索引:设计模式系列