1.概述
转载:源码解析 | 万字长文详解 Flink 中的 CopyOnWriteStateTable
作者:fanrui
现如今想阅读 HashMap 源码实际上比较简单,因为网上一大堆博客去分析 HashMap 和 ConcurrentHashMap。而本文是全网首篇详细分析 CopyOnWriteStateTable 源码的博客,阅读复杂集合类源码的过程是相当有挑战的,笔者在刚开始阅读也遇到很多疑问,最后一一解决了。本文有一万两千多字加不少的配图,实属不易。
详细阅读完本文,无论是针对面试还是开阔视野一定会对大家有帮助的。
声明:笔者的源码分析都是基于 flink-1.9.0 release 分支,其实阅读源码不用非常在意版本的问题,各版本的主要流程基本都是类似的。如果熟悉了某个版本的源码,之后新版本有变化,我们重点看一下变化之处即可。
本文主要讲述 Flink 中 CopyOnWriteStateTable 相关的知识,当使用 MemoryStateBackend 和 FsStateBackend 时,默认情况下会将状态数据保存到 CopyOnWriteStateTable 中。CopyOnWriteStateTable 中保存多个 KeyGroup 的状态,每个 KeyGroup 对应一个 CopyOnWriteStateMap。
CopyOnWriteStateMap 是一个类似于 HashMap 的结构,但支持了两个非常有意思的功能:
-
hash 结构为了保证读写数据的高性能,都需要有扩容策略,CopyOnWriteStateMap 的扩容策略是一个渐进式 rehash 的策略,即:不是一下子将数据全迁移的新的 hash 表,而是慢慢去迁移数据到新的 hash 表中。
-
Checkpoint 时 CopyOnWriteStateMap 支持异步快照,即:Checkpoint 时可以在做快照的同时,仍然对 CopyOnWriteStateMap 中数据进行修改。
问题来了:数据修改了,怎么保证快照数据的准确性呢?
了解 Redis 的同学应该知道 Redis 也是一个大的 hash 结构,扩容策略也是渐进式 rehash。Redis 的 RDB 在持久化数据的过程中同时也是对外服务的,对外服务意味着数据可能被修改,那么 RDB 如何保证持久化好的数据一定是正确的呢?
举个例子:17 点00分00秒 RDB 开始持久化数据,过了 1 秒 Redis 中某条数据被修改了,过了一分钟 RDB 才持久化结束。RDB 预期的持久化结果应该是 17 点00分00秒那一刻 Redis 的完整快照,请问持久化过程中那些修改操作是否会影响 Redis 的快照。答:当然可以做到不影响。
Flink 在 Checkpoint 时的快照与 Redis 类似,都是想在快照时依然对外提供服务,减少服务停顿时间。Flink 具体如何实现上述功能的呢?带着问题详细阅读下文。
2.StateTable 简介
MemoryStateBackend
和 FsStateBackend
的 KeyedStateBackend
都使用 HeapKeyedStateBackend
存储数据,HeapKeyedStateBackend
持有 Map> registeredKVStates
来存储 StateName 与具体 State 的映射关系
。registeredKVStates
的 key 就是 StateName,value 为具体的 State 数据
。具体 State 的数据存储在 StateTable 中。
StateTable 有两个实现:CopyOnWriteStateTable 和 NestedMapsStateTable。
CopyOnWriteStateTable
属于 Flink 自己定制化的数据结构,Checkpoint
时支持异步 Snapshot
。
NestedMapsStateTable
直接嵌套 Java 的两层 HashMap
来存储数据,Checkpoint
时需要同步快照。
下面详细介绍 CopyOnWriteStateTable。
3.CopyOnWriteStateTable
StateTable
中持有 StateMap[] keyGroupedStateMaps
真正的存储数据。StateTable
会为每个 KeyGroup
的数据初始化一个 StateMap
来对 KeyGroup
做数据隔离。对状态进行操作时,StateTable
会先根据 key
计算对应的 KeyGroup
,拿到相应的 StateMap
,才能对状态进行操作。
CopyOnWriteStateTable
中使用 CopyOnWriteStateMap
存储数据,这里主要介绍 CopyOnWriteStateMap
的实现。CopyOnWriteStateMap
中就是一个数组 + 链表构成的 hash 表
。
CopyOnWriteStateMap
中元素类型都是是:StateMapEntry
。hash
表的第一层先是一个 StateMapEntry
类型的数组,即:StateMapEntry[]
。在 StateMapEntry
类中有个 StateMapEntry next 指针构成链表
。
CopyOnWriteStateMap
相比普通的 hash 表,有以下几点需要重点关注:
CopyOnWriteStateMap
的扩容策略是渐进式 rehash
,而不是一下子扩容完- 为了
支持异步的 Snapshot
,需要将Snapshot
时StateMap
的快照保存下来,具体的保存策略怎么实现的? - 为了支持
CopyOnWrite
功能,所以在修改数据时,要进行一系列 copy 的操作,不能修改原始数据,否则会影响 Snapshot。Snapshot 异步快照流程及 Snapshot 完成时,如何 release 掉旧版本数据?
3.1 渐进式 rehash 策略
渐进式 rehash 策略表示 CopyOnWriteStateMap
中当前有一个 hash 表对外服务,但是当前 hash 表中元素太多需要扩容了,需要将数据迁移到一个容量更大的 hash 表
中。
Java 的 HashMap 在扩容时会一下子将旧 hash 表中所有数据都移动到大 hash 表中,这样的策略存在的问题是如果 HashMap 当前存储了 1 G 的数据,那么瞬间需要将 1 G 的数据迁移完,可能会比较耗时。而 CopyOnWriteStateMap
在扩容时,不会一下子将数据全部迁移完
,而是在每次操作 CopyOnWriteStateMap
时,慢慢去迁移数据到大的 hash 表
中。
例如:可以在每次 get、put 操作时,迁移 4 条数据到大 hash 表中,这样经过一段时间的 get 和 put 操作,所有的数据就能迁移完成。所以渐进式 rehash 策略,会分很多次将所有的数据迁移到新的 hash 表中。
3.1.1 扩容简述
在内存中有两个 hash 表,一个是 primaryTable 作为主桶,一个是 rehashTable 作为扩容期间用的桶
。初始阶段只有 primaryTable
,当 primaryTable
中元素个数大于设定的阈值时,就要开始扩容。
扩容过程:申请一个相比 primaryTable 容量大一倍的 hash 表保存到 rehashTable 中,慢慢地将 primaryTable 中的元素迁移到 rehashTable 中。对应到源码中:putEntry 方法中判断 size() > threshold 时,会调用 doubleCapacity 方法申请新的 hash 表赋值给 rehashTable
。
如下图所示 primaryTable
中桶的个数为 4,rehashTable
中桶的个数为 8。
扩容时 primaryTable 中 0 位置上的元素会迁移到 rehashTable 的 0 和 4 位置上,同理 primaryTable 中 1 位置上的元素会迁移到 rehashTable 的 1 和 5 位置上。
3.1.2 选择 Table 的策略
假设 primaryTable
中 0 桶的数据已经迁移到 rehashTable 桶了,那么之后无论是 put 还是 get 操作 0 桶的数据,那么都会去操作 rehashTable。而 1、2、3 桶还未迁移,所以 1、2、3 桶还需要操作 primaryTable
桶。对应到源码中会有一个选桶的操作,选择到底使用 primaryTable 还是 rehashTable。
源码实现如下所示:
// 选择当前元素到底使用 primaryTable 还是 incrementalRehashTable
private StateMapEntry<K, N, S>[] selectActiveTable(int hashCode) {
// 计算 hashCode 应该被分到 primaryTable 的哪个桶中
int curIndex = hashCode & (primaryTable.length - 1);
// 大于等于 rehashIndex 的桶还未迁移,应该去 primaryTable 中去查找。
// 小于 rehashIndex 的桶已经迁移完成,应该去 incrementalRehashTable 中去查找。
return curIndex >= rehashIndex ? primaryTable : incrementalRehashTable;
}
首先通过 int curIndex = hashCode & (primaryTable.length - 1); 计算当前 hashCode 应该分到 primaryTable 的哪个桶中。
rehashIndex
用来标记当前 rehash 迁移的进度,即:rehashIndex 之前的数据已经从 primaryTable 迁移到 rehashTable 桶中。假设 rehashIndex = 1,表示 primaryTable 1 桶之前的数据全部迁移完成了,即:0 桶数据全部迁移完了。
策略:大于等于 rehashIndex 的桶还未迁移,应该去 primaryTable 中去查找。小于 rehashIndex 的桶已经迁移完成,应该去 incrementalRehashTable 中去查找。
3.1.3 迁移过程
每次有 get、put、containsKey、remove 操作时,都会调用 computeHashForOperationAndDoIncrementalRehash 方法触发迁移操作。
computeHashForOperationAndDoIncrementalRehash 方法作用:
- 检测是否处于 rehash 中,如果正在 rehash 就会调用 incrementalRehash 迁移一波数据
- 计算 key 和 namespace 对应的 hashCode
重点关注 incrementalRehash 方法实现:
private void incrementalRehash() {
StateMapEntry<K, N, S>[] oldMap = primaryTable;
StateMapEntry<K, N, S>[] newMap = incrementalRehashTable;
int oldCapacity = oldMap.length;
int newMask = newMap.length - 1;
int requiredVersion = highestRequiredSnapshotVersion;
int rhIdx = rehashIndex;
// 记录本次迁移了几个元素
int transferred = 0;
// 每次至少迁移 MIN_TRANSFERRED_PER_INCREMENTAL_REHASH 个元素到新桶、
// MIN_TRANSFERRED_PER_INCREMENTAL_REHASH 默认为 4
while (transferred < MIN_TRANSFERRED_PER_INCREMENTAL_REHASH) {
// 遍历 oldMap 的第 rhIdx 个桶
StateMapEntry<K, N, S> e = oldMap[rhIdx];
// 每次 e 都指向 e.next,e 不为空,表示当前桶中还有元素未遍历,需要继续遍历
// 每次迁移必须保证,整个桶被迁移完,不能是某个桶迁移到一半
while (e != null) {
// 遇到版本比 highestRequiredSnapshotVersion 小的元素,则 copy 一份
if (e.entryVersion < requiredVersion) {
e = new StateMapEntry<>(e, stateMapVersion);
}
// 保存下一个要迁移的节点节点到 n
StateMapEntry<K, N, S> n = e.next;
// 迁移当前元素 e 到新的 table 中,插入到链表头部
int pos = e.hash & newMask;
e.next = newMap[pos];
newMap[pos] = e;
// e 指向下一个要迁移的节点
e = n;
// 迁移元素数 +1
++transferred;
}
oldMap[rhIdx] = null;
// rhIdx 之前的桶已经迁移完,rhIdx == oldCapacity 就表示迁移完成了
// 做一些初始化操作
if (++rhIdx == oldCapacity) {
XXX
return;
}
}
// primaryTableSize 中减去 transferred,增加 transferred
primaryTableSize -= transferred;
incrementalRehashTableSize += transferred;
rehashIndex = rhIdx;
}
incrementalRehash 方法中第一层 while 循环用于控制每次迁移的最小元素个数。然后遍历 oldMap 的第 rhIdx 个桶,e 指向当前遍历的元素,每次 e 都指向 e.next,e 不为空,表示当前桶中还有元素未遍历,需要继续遍历。每次迁移必须保证,整个桶被迁移完,不能是某个桶迁移到一半。
迁移过程中,将当前元素 e 重新计算 hash 值,插入到 newMap 相应桶的头部(头插法)。其中 e.entryVersion < requiredVersion 时,需要创建一个新的 Entry,这里是为了支持 CopyOnWrite 功能,下面会介绍。
3.2 StateMap 的 Snapshot 策略
StateMap 的 Snapshot 策略是指:为了支持异步的 Snapshot,需要将 Snapshot 时 StateMap 的快照保存下来。
传统的方法就是将 StateMap 的全量数据在内存中深拷贝一份,然后拷贝的这一份数据去慢慢做快照,原始的数据可以对外服务。但是深拷贝需要拷贝所有的真实数据,所以效率会非常低。为了提高效率,Flink 只是对数据进行了浅拷贝。
3.2.1 浅拷贝原理分析
浅拷贝就是只拷贝引用,不拷贝数据。
假如 StateMap 没有处于扩容中,Snapshot 流程相对比较简单,创建一个新的 snapshotData,直接将 primaryTable 的数据拷贝到 snapshotData 中即可。
如图所示,对于浅拷贝可以理解为两个 Table 的 0 号桶中都引用的同一个链表,也就是将 snapshotData 指向图中的 Entry a 即可。其他桶的浅拷贝也是类似,就不一一画图了。
假如 StateMap 当前处于扩容中,Snapshot 流程相对比较繁琐,创建一个新的 snapshotData,需要将 primaryTable 和 rehashTable 的数据都拷贝到 snapshotData 中。
如图所示,将原始两个 Table 数据拷贝到 snapshotData 中,但是 snapshotData 数组的长度并不是 primaryTable 的长度 + rehashTable 的长度。而是分别计算 primaryTable 和 rehashTable 中有几个桶中有数据。例如上图案例所示,primaryTable 中有 3 个桶中有元素,rehashTable 中有 2 个桶中有元素,所以snapshotData 的桶数量为 5 即可,没必要 4 + 8 = 12 个桶。
上图中也是省略了 Entry,Entry 引用的浅拷贝与之前没有扩容的情况类似。
3.2.2 浅拷贝源码详解
首先调用 CopyOnWriteStateTable 的 stateSnapshot 方法对整个 StateTable 进行快照。stateSnapshot 方法会创建 CopyOnWriteStateTableSnapshot,CopyOnWriteStateTableSnapshot 的构造器中会调用 CopyOnWriteStateTable 的 getStateMapSnapshotList 方法。
getStateMapSnapshotList 方法源码如下所示:
List<CopyOnWriteStateMapSnapshot<K, N, S>> getStateMapSnapshotList() {
List<CopyOnWriteStateMapSnapshot<K, N, S>> snapshotList =
new ArrayList<>(keyGroupedStateMaps.length);
// 调用所有 CopyOnWriteStateMap 的 stateSnapshot 方法
// 生成 CopyOnWriteStateMapSnapshot 保存到 list 中
for (int i = 0; i < keyGroupedStateMaps.length; i++) {
CopyOnWriteStateMap<K, N, S> stateMap =
(CopyOnWriteStateMap<K, N, S>) keyGroupedStateMaps[i];
snapshotList.add(stateMap.stateSnapshot());
}
return snapshotList;
}
CopyOnWriteStateTable 中为每个 KeyGroup 维护了一个 StateMap 到 keyGroupedStateMaps 中,getStateMapSnapshotList 方法会调用所有 CopyOnWriteStateMap 的 stateSnapshot 方法。
CopyOnWriteStateMap 的 stateSnapshot 方法相关源码如下所示:
public CopyOnWriteStateMapSnapshot<K, N, S> stateSnapshot() {
return new CopyOnWriteStateMapSnapshot<>(this);
}
CopyOnWriteStateMapSnapshot(CopyOnWriteStateMap<K, N, S> owningStateMap) {
super(owningStateMap);
// 对 StateMap 的数据进行浅拷贝,生成 snapshotData
this.snapshotData = owningStateMap.snapshotMapArrays();
// 记录当前的 StateMap 版本到 snapshotVersion 中
this.snapshotVersion = owningStateMap.getStateMapVersion();
this.numberOfEntriesInSnapshotData = owningStateMap.size();
}
CopyOnWriteStateMap 的 stateSnapshot 方法会创建 CopyOnWriteStateMapSnapshot,CopyOnWriteStateMapSnapshot 的构造器中会调用 StateMap 的 snapshotMapArrays 方法对 StateMap 的数据进行浅拷贝生成 snapshotData。且将当前的 StateMap 版本到 snapshotVersion 中。
StateMap 的 snapshotMapArrays 方法对浅拷贝原理进行了代码实现,代码如下所示:
public class CopyOnWriteStateMap<K, N, S> extends StateMap<K, N, S> {
// 当前 StateMap 的 version
private int stateMapVersion;
// 所有 正在进行中的 snapshot 的 version
private final TreeSet<Integer> snapshotVersions;
// 正在进行中的那些 snapshot 的最大版本号
private int highestRequiredSnapshotVersion;
StateMapEntry<K, N, S>[] snapshotMapArrays() {
// 1、stateMapVersion 版本 + 1,赋值给 highestRequiredSnapshotVersion,
// 并加入snapshotVersions
synchronized (snapshotVersions) {
++stateMapVersion;
highestRequiredSnapshotVersion = stateMapVersion;
snapshotVersions.add(highestRequiredSnapshotVersion);
}
// 2、 将现在 primary 和 Increment 的元素浅拷贝一份到 copy 中
// copy 策略:copy 数组长度为 primary 中剩余的桶数 + Increment 中有数据的桶数
// primary 中剩余的数据放在 copy 数组的前面,Increment 中低位数据随后,
// Increment 中高位数据放到 copy 数组的最后
StateMapEntry<K, N, S>[] table = primaryTable;
final int totalMapIndexSize = rehashIndex + table.length;
final int copiedArraySize = Math.max(totalMapIndexSize, size());
final StateMapEntry<K, N, S>[] copy = new StateMapEntry[copiedArraySize];
if (isRehashing()) {
final int localRehashIndex = rehashIndex;
final int localCopyLength = table.length - localRehashIndex;
// for the primary table, take every index >= rhIdx.
System.arraycopy(table, localRehashIndex, copy, 0, localCopyLength);
table = incrementalRehashTable;
System.arraycopy(table, 0, copy, localCopyLength, localRehashIndex);
System.arraycopy(table, table.length >>> 1, copy,
localCopyLength + localRehashIndex, localRehashIndex);
} else {
System.arraycopy(table, 0, copy, 0, table.length);
}
return copy;
}
}
CopyOnWriteStateMap 中三个比较重要的属性:
- stateMapVersion:表示当前 StateMap 的版本,每次 Snapshot 时版本号加一
- snapshotVersions:存放所有正在进行中的 snapshot 的版本号(因为可能存在多个同时进行的 Snapshot)
- highestRequiredSnapshotVersion:表示正在进行中的那些 snapshot 的最大版本号,如果当前没有正在进行中的 Snapshot,那么赋值为 0
snapshotMapArrays 方法第一步按照上述规则更新这三个属性,第二步将现在 primaryTable 和 rehashTable 的元素浅拷贝一份到 copy 数组中。
注:copy 数组的长度与上述原理分析不完全一致,原理分析时应该是 copiedArraySize = totalMapIndexSize;实际上 copiedArraySize = Math.max(totalMapIndexSize, size())。
源码注释写到:理论上 totalMapIndexSize 就够了,这里考虑 size 主要是为了兼容 StateMap 的 TransformedSnapshotIterator 功能。
3.3 CopyOnWrite 实现原理
上一部分得出结论,每次 Snapshot 时仅仅是浅拷贝一份,所以 Snapshot 和 StateMap 共同引用真实的数据。假如 Snapshot 还没将数据 flush 到磁盘,但是 StateMap 中对数据进行了修改,那么 Snapshot 最后 flush 的数据就是错误的。Snapshot 的目标是:将 Snapshot 快照中原始的数据刷到磁盘,既然叫快照,所以不允许被修改。
3.3.1 CopyOnWrite 原理简述
那 StateMap 如何来保证修改数据的时候,不会修改 Snapshot 的数据呢?其实原理很简单:StateMap 和 Snapshot 共享了一大堆数据,既然 Snapshot 要求数据不能修改,那么 StateMap 在修改某条数据时可以将这条数据复制一份产生一个副本,所以 Snapshot 和 StateMap 就会各自拥有自己的副本,所以 StateMap 对数据的修改就不会影响 Snapshot 的快照。
当然为了节省内存和提高效率,StateMap 只会拷贝那些要改变的数据,尽量多的实现共享,不能实现共享的数据只能 Copy 一份再修改了,这就是类名用 CopyOnWrite 修饰的原因。
3.3.2 CopyOnWrite 原理详解
上一部分 Snapshot 时,仅仅对 Table 做了一份浅拷贝,而且可以看到拷贝前后,桶内的数据不变,且桶跟桶之间是没有交集的,所以这里的原理详解主要就分析一个桶中的链表如何实现 CopyOnWrite。
3.3.2.1 修改链表头部节点的场景
如上图所示,primaryTable 和 snapshotTable 的 0 号桶都指向 Entry a,假设现在应用层要修改 Entry a 的数据,整体流程:
- 深拷贝一个 Entry a 对象为 Entry a copy
- 将 Entry a copy 放到 primaryTable 的链表中,且 next 指向 Entry b
- 应用层修改 Entry a copy 的 data,将 data1 修改为设定的 data2
这里 Entry b 和 c 没有修改,所以不用拷贝,属于 primaryTable 和 snapshotTable 共享的。
这里就引出了 CopyOnWriteStateMap 的设计目标(自己的理解,并不是官方观点):在保证 Snapshot 数据正确性的前提下,尽量的少拷贝数据提高性能。
3.3.2.2 修改链表中间节点的场景
如上图所示,primaryTable 和 snapshotTable 的 0 号桶都指向 Entry a,假设现在应用层要修改 Entry b 的数据,整体流程:
- 深拷贝一个 Entry b 对象为 Entry b copy
- 将 Entry b copy 串在 primaryTable 的链表中,且 next 指向 Entry c
- 应用层修改 Entry b copy 的 data,将 data 修改为设定的 data2
但是上述流程成立吗?如上图所示 Entry a 和 c 是 primaryTable 和 snapshotTable 共享的。每个 Entry 只有一个 next 指针,所以 Entry a 可以同时指向 Entry b 和 b copy 吗?肯定是不可以的,所以 Entry a 不可以共享。下图是正确流程。
如下图所示,在修改 Entry b 时,不仅仅要将 Entry b 拷贝一份,而且还要将链表中 Entry b 之前的 Entry 必须全部 copy 一份,这样才能保证在满足正确性的前提下修改 Entry b,毕竟正确性是第一位。
正确整体流程:
- 深拷贝 Entry a 和 b 对象为 Entry a copy 和 b copy
- 将 Entry a copy 和 b copy 串在 primaryTable 的链表中,且 Entry b 的 next 指向 Entry c
- 应用层修改 Entry b copy 的 data,将 data 修改为设定的 data2
总结:假设要修改 Entry b,那么要将 Entry b 以及链表中 Entry b 之前的 Entry 必须全部 copy 一份,Entry b 之后的 Entry 可以共享。
3.3.2.3 插入新数据的场景
如上图所示是插入新数据的场景,会使用头插法插入 Entry d,头插法不需要拷贝原始链表的任何数据,只需要插入最新的数据到链表头部即可。这样 primaryTable 可以访问到插入的数据,且不影响 SnapshotData 访问原始快照的数据。
注:这里必须是插入新数据的场景,对于 Map 类型,插入旧数据对应的可能是修改操作
3.3.2.4 链表头部有新节点再修改链表中间节点的场景
如上图所示是链表头部有新节点 Entry d 再修改 Entry b 的场景,此时正确的流程是:
-
深拷贝 Entry a 和 b 对象为 Entry a copy 和 b copy
-
将 Entry a copy 和 b copy 串在 Entry d 的链表中,且 Entry b 的 next 指向 Entry c
-
应用层修改 Entry b copy 的 data,将 data 修改为设定的 data2
之前说过要修改 Entry b 需要将 Entry b 之前的 Entry 全部 copy 一份,但是此时并不需要对 Entry d 进行 copy。之前 copy 是因为 Entry b 之前的元素有被 snapshotData 引用,但是这里 Entry d 并不被 snapshotData 引用,只有 primaryTable 只有 Entry d,所以不需要 copy。
修改 Entry b 时,Entry b 之前的 Entry 哪些需要 copy,哪些不需要 copy,具体如何区分会在后续的源码环节详细介绍。
3.3.2.5 get 链表中间节点的场景
理论来讲,访问中间节点的场景数据数据是非常安全的。
如下图所示 Flink 应用层通过 primaryTable 访问 Entry b,理论来讲只是读取的场景就不需要 copy 副本了。因为之前 copy 副本都是因为应用层修改了数据,为了保证 Snapshot 数据的不可变特性,所以专门 copy 一个副本让 primaryTable 去修改。但神奇的是 CopyOnWriteStateMap 在 get 操作时,也需要将 Entry b 以及 Entry b 之前的所有 Entry 拷贝一个副本。
为什么呢?虽然是 get 访问操作,但是应用层拿到了 Entry b 中的 data 对象,万一应用层修改了 data 对象里的属性怎么办呢?例如 Entry 中的 data 是 Person 对象,Person 对象可能有一些 setter 方法,可以修改其 name 和 age。如果应用层修改了 name 或 age,那么在 Snapshot 的过程中,还是出现了数据修改的情况。
所以 CopyOnWriteStateMap 把 get 操作跟 put 操作同等对待,无论是 get 还是 put 都需要将 Entry 及其之前的 Entry copy 一份。
3.3.2.6 remove 数据的场景
需要区分两种 case:remove 的 Entry 是链表头节点;remove 的 Entry 不是链表头节点。
Case1:remove 的 Entry 是链表头节点的场景比较简单,将桶直接指向 Entry a 的 next Entry b 即可。
Case 2:remove 的 Entry 不是链表头节点,需要将 Entry b 之前的所有 Entry 拷贝一份(新插入的 Entry 不需要拷贝),且 Entry b 前一个节点的副本直接指向 Entry b 的下一个节点。具体为什么 Entry a 需要拷贝一份与 put 和 get 操作类似,因为 Entry a 的 next 指针没办法指向两个节点,所以 primaryTable 和 snapshotTable 要有各自的头结点。
- 插入新的 Entry 使用头插法插入到链表中
- 假设要修改 Entry b,那么要将 Entry b 以及链表中 Entry b 之前的 Entry 必须全部 copy 一份(新插入的数据不需要拷贝),Entry b 之后的 Entry 可以共享
- 访问 Entry b 的场景与修改 Entry b 的场景类似
- 假如修改或访问的数据是 copy 后的数据,那么实际上不需要再 copy 了,因为 copy 后的数据已经保证是 primaryTable 独占的数据,不与 Snapshot 共享
- remove 数据的场景,分为两种 case:
- 如果 remove 的 Entry 是链表头节点,将桶直接指向头结点的 next 节点即可。
- 如果 remove 的 Entry 不是链表头节点,需要将目标 Entry 之前的所有 Entry 拷贝一份,且目标 Entry 前一个节点的副本直接指向目标 Entry 的下一个节点。当然如果前继节点已经是新版本了,则不需要拷贝,直接修改前继 Entry 的 next 指针即可。
懒得转载了 大家自己去看