本文是深刻探究immutable.js系列的第二篇。
javascript
深刻探究immutable.js的实现机制(二) 本篇
node
上一篇咱们研究了 Immutable.js 持久化数据结构的基本实现原理,对其核心数据结构Vector Trie
进行了介绍,并着重探究了其中的位分区
机制。采用位分区
的根本缘由是为了优化速度,而对于空间的优化, Immutable.js 是怎么作的呢?接下来先探讨下这点。git
HAMT
全称hash array mapped trie
,其基本原理与上篇所说的Vector Trie
很是类似,不过它会对树进行压缩,以节约一些空间。 Immutable.js 参考了HAMT
对树进行了高度和节点内部的压缩。github
假设咱们有一个 2 叉 Vector Trie
,如今存了一个值,key为110
(二进制形式), 它会被存到0
1
1
这条路径下,以下图:
显然,这图里展现的结构已经进行了最简单的优化,由于如今只存了一个值,因此把与110
无关的节点去掉了。还能进行什么优化吗?咱们发现,中间那两个节点也是能够去掉的,以下图:
获取该值时,咱们先从0
找下来,发现这直接是一个根节点,那取它存储的值就好了。就是说在不产生混淆的状况下,咱们能够用尽量少的二进制位去标识这个 key 。这样咱们就进行了高度上的压缩,既减小了空间,又减小了查找和修改的时间。
若是要添加一个值,它的 key 结尾也是0
,该怎么作呢?很简单,以下图:
咱们只要在须要的时候增长或减小节点便可。算法
上一篇咱们提到, Immutable.js 的 Trie 里,每一个节点数组的长度是 32 ,然而在不少状况下,这 32 个位置大部分是用不到的,这么大的数组显然也占用了很大空间。使用Bitmap
,咱们就能够对数组进行压缩。
咱们先拿长度为 8 的数组举例:
咱们实际上只是用了数组的下标对 key 进行索引,这样想数组第 五、六、7 位显然目前是毫无做用的,那 0、二、3 呢?咱们有必要为了一个下标 4 去维持一个长度为5的数组吗?咱们只须要指明“假想数组”中下标为 1 和为 4 的位置有数就能够了。这里就能够用到bitmap
,以下:
咱们采用了一个数,以其二进制形式表达“假想的长度为8的数组”中的占位状况,1 表示数组里相应下标位置有值,0 则表示相应位置为空。好比这个二进制数第 4 位(从右往左,从 0 开始数)如今是 1 ,就表示数组下标为 4 的位置有值。这样本来的长度为 8 的数组就能够压缩到 2 。
注意这个数组中的元素仍是按照“假想数组”中的顺序排列的,这样咱们若要取“假想数组”中下标为 i 的元素时,首先是判断该位置有没有值,如有,下一步就是获得在它以前有几个元素,即在二进制数里第 i 位以前有多少位为 1 ,假设数量为 a ,那么该元素在当前压缩后的数组里下标就是 a 。
具体操做中,咱们能够经过bitmap & (1 << i - 1)
,获得一个二进制数,该二进制数中只有第 i 位以前有值的地方为 1 ,其他全为 0 ,下面咱们只需统计该二进制数里 1 的数量便可获得下标。计算二进制数中 1 数量的过程被称做popcount
,具体算法有不少,我了解很少就不展开了,前面点击后是维基的地址,感兴趣的能够研究下。
下面咱们看一下这部分的源码:编程
get(shift, keyHash, key, notSetValue) {
if (keyHash === undefined) {
keyHash = hash(key);
}
const bit = 1 << ((shift === 0 ? keyHash : keyHash >>> shift) & MASK);
const bitmap = this.bitmap;
return (bitmap & bit) === 0
? notSetValue
: this.nodes[popCount(bitmap & (bit - 1))].get(
shift + SHIFT,
keyHash,
key,
notSetValue
);
}复制代码
popCount
方法,我把源码也贴出来:
function popCount(x) {
x -= (x >> 1) & 0x55555555;
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
x = (x + (x >> 4)) & 0x0f0f0f0f;
x += x >> 8;
x += x >> 16;
return x & 0x7f;
}复制代码
上一篇咱们提到了 Immutable.js 的 Vector Trie 采用了 32 做为数组的长度,也解释了因为采用了位分区
,该数字只能是2的整数次幂,因此不能是 3一、33 等。但八、1六、64等等呢?这是经过实际测试得出的,见下图:
图中分别是查找和更新的时间,看上去彷佛 8 或 16 更好?考虑到平时的使用中,查找比更新频次高不少,因此 Immutable.js 选择了 32。数组
如今,咱们就能理解第一篇文章开头的截图了:
咱们能够看到, map 里主要有三种类型的节点:bash
HashArrayMapNode
,拥有的子节点数量 >16 ,拥有的数组长度为 32BitmapIndexedNode
,拥有的子节点数量 ≤16 ,拥有的数组长度与子节点数量一致,经由 bitmap 压缩ValueNode
,叶子节点,存储 key 和 value此外,每一个节点彷佛都有个ownerID
属性,这又是作什么的呢?它涉及到 Immutable.js 中的可变数据结构。数据结构
其实能够说 Immutable.js 中的数据结构有两种形态,“不可变”和“可变”。虽然“不可变”是 Immutable.js 的主要优点,但“可变”形态下的操做固然效率更高。有时对于某一系列操做,咱们只须要获得这组操做结束后的状态,若中间的每个操做都用不可变数据结构去实现显然有些多余。这种情景下,咱们就可使用withMutations
方法对相应数据结构进行临时的“可变”操做,最后再返回一个不可变的结构,这就是Transient
,好比这样:
let map = new Immutable.Map({});
map = map.withMutations((m) => {
// 开启Transient
m.set('a', 1); // 咱们能够直接在m上进行修改,不须要 m = m.set('a', 1)
m.set('b', 2);
m.set('c', 3);
});
// Transient结束复制代码
withMutations
构造临时的可变数据结构来提升效率,好比 Map 中的
map
、
deleteAll
方法以及 Map 的构造函数。而在一个不可变数据结构中实现临时的可变数据结构的关键(有点拗口XD),就是这个
ownerID
。下图对比了使用与不使用
Transient
时的区别:
Transient
后因为无需每次生成新的节点,效率会提升空间占用会减小。在开启
Transient
时,根节点会被赋与一个新的
ownerID
,在
Transient
完成前的每一步操做只需遵循下面的逻辑便可:
ownerID
与父节点的不一致,则生成新的节点,把旧节点上的值拷贝过来,其ownerID
更新为父节点的ownerID
,而后进行相应操做;ownerID
与父节点的一致,则直接在该节点上操做;下面先咱们看下 Immutable.js 中开启Transient
的相关源码:
function OwnerID() {}复制代码
function asMutable() {
return this.__ownerID ? this : this.__ensureOwner(new OwnerID());
}复制代码
function withMutations(fn) {
const mutable = this.asMutable();
fn(mutable);
return mutable.wasAltered() ? mutable.__ensureOwner(this.__ownerID) : this;
}复制代码
ownerID
,这个
ownerID
会在接下来的操做中按照上面的逻辑使用。注意这段代码是用 JS 的对象地址去做为 ID ,由于每次 new 以后的对象的地址确定与以前的对象不一样,因此用这种方法能够很简便高效地构造一套 ID 体系。
set
操做就会调用这个
update
方法):
update(ownerID, shift, keyHash, key, value, didChangeSize, didAlter) {
// ...省略前面的代码
const isEditable = ownerID && ownerID === this.ownerID;
const newNodes = setAt(nodes, idx, newNode, isEditable);
if (isEditable) {
this.count = newCount;
this.nodes = newNodes;
return this;
}
return new HashArrayMapNode(ownerID, newCount, newNodes);
}复制代码
ownerID
与传进来父节点的是否一致,而后直接在节点上操做或生成新的节点。
这块的内容就没什么新东西了,任何语言或库里对于 hashMap 的实现都需考虑到 hash 冲突的问题。咱们主要看一下 Immutable.js 是怎么处理的。
要上一篇咱们知道了,在往 Map 里存一对 key、value 时, Immutable.js 会先对 key 进行 hash ,根据 hash 后的值存到树的相应位置里。不一样的 key 被 hash 后的结果是可能相同的,即使几率应当很小。
hash 冲突是一个很基本的问题,解决方法有不少,这里最简单适用的方法就是把冲突的节点扩展成一个线性结构,即数组,数组里直接存一组组 key 和 value ,查找到此处时则遍历该数组找到匹配的 key 。虽然这里的时间复杂度会变成线性的,但考虑到发生 hash 冲突的几率很低,因此时间复杂度的增长能够忽略不计。
我发现 Immutable.js 的 hash 函数对abc
和bCc
的 hash 结果都是 96354
,在同一个 map 里用这两个 key 就会形成 hash 冲突,咱们把这个 map log 出来以下:
Immutable.js 用了一个叫作HashCollisionNode
的节点去处理发生冲突的键值,它们被放在entries
数组里。
你们也能够本身试试,代码以下:
let map = new Immutable.Map({});
for (let i = 0; i < 10; i++) {
map = map.set(Math.random(), i); // 随便塞一点别的数据
}
map = map.set('abc', 'value1');
map = map.set('bCc', 'value2');
console.log(map)复制代码
若是文章里有什么问题欢迎指正。
该文章是我正在更新的深刻探究immutable.js系列的第二篇,说实话这两篇文章写了挺久,现成的资料不多很散,并且基本都是别的编程语言里的。有时间和精力我会继续更新第三篇甚至第四篇,感受仍是有些内容能够展开。
参考:
hypirion.com/musings/und…
io-meter.com/2016/11/06/…
cdn.oreillystatic.com/en/assets/1…
infoscience.epfl.ch/record/1698…
lampwww.epfl.ch/papers/idea…
github.com/funfish/blo…
github.com/facebook/im…