本文是我正在更新的深入探究immutable.js系列的第二篇。
深入探究immutable.js的實現機制(二) 本篇
上一篇我們研究了 Immutable.js 持久化資料結構的基本實現原理,對其核心資料結構Vector Trie
進行了介紹,並著重探究了其中的位分割槽
機制。採用位分割槽
的根本原因是為了優化速度,而對於空間的優化, Immutable.js 是怎麼做的呢?接下來先探討下這點。
HAMT
HAMT
全稱hash array mapped trie
,其基本原理與上篇所說的Vector Trie
非常相似,不過它會對樹進行壓縮,以節約一些空間。 Immutable.js 參考了HAMT
對樹進行了高度和節點內部的壓縮。
樹高壓縮
假設我們有一個 2 叉 Vector Trie
,現在存了一個值,key為110
,它會被存到0
1
1
這條路徑下,如下圖:
顯然,這圖裡展示的結構已經進行了最簡單的優化,因為現在只存了一個值,所以把與110
無關的節點去掉了。還能進行什麼優化嗎?我們發現,中間那兩個節點也是可以去掉的,如下圖:
獲取該值時,我們先從0
找下來,發現這直接是一個根節點,那取它儲存的值就行了。就是說在不產生混淆的情況下,我們可以用盡可能少的二進位制位去標識這個 key 。這樣我們就進行了高度上的壓縮,既減少了空間,又減少了查詢和修改的時間。
如果要新增一個值,它的 key 結尾也是0
,該怎麼做呢?很簡單,如下圖:
我們只要在需要的時候增加或減少節點即可。
節點內部壓縮-Bitmap
上一篇我們提到, Immutable.js 的 Trie 裡,每個節點陣列的長度是 32 ,然而在很多情況下,這 32 個位置大部分是用不到的,這麼大的陣列顯然也佔用了很大空間。使用Bitmap
,我們就可以對陣列進行壓縮。
我們先拿長度為 8 的陣列舉例:
我們實際上只是用了陣列的下標對 key 進行索引,這樣想陣列第 5、6、7 位顯然目前是毫無作用的,那 0、2、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
);
}複製程式碼
可見它與我們上一篇看到的原始碼並沒有太大不同(Immutable.js 裡如果一個陣列佔用不超過一半( 16 個),就會對其進行壓縮,上一篇的原始碼就是沒有壓縮下的情況),就是多了一個用 bitmap 計算陣列下標的過程,方式也跟上文所講的一樣,對於這個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;
}複製程式碼
為什麼是32
上一篇我們提到了 Immutable.js 的 Vector Trie 採用了 32 作為陣列的長度,也解釋了由於採用了位分割槽
,該數字只能是2的整數次冪,所以不能是 31、33 等。但8、16、64等等呢?這是通過實際測試得出的,見下圖:
圖中分別是查詢和更新的時間,看上去似乎 8 或 16 更好?考慮到平時的使用中,查詢比更新頻次高很多,所以 Immutable.js 選擇了 32。
回顧
現在,我們就能理解第一篇文章開頭的截圖了:
我們可以看到, map 裡主要有三種型別的節點:
HashArrayMapNode
,擁有的子節點數量 >16 ,擁有的陣列長度為 32BitmapIndexedNode
,擁有的子節點數量 ≤16 ,擁有的陣列長度與子節點數量一致,經由 bitmap 壓縮ValueNode
,葉子節點,儲存 key 和 value
此外,每個節點似乎都有個ownerID
屬性,這又是做什麼的呢?它涉及到 Immutable.js 中的可變資料結構。
Transient
其實可以說 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結束複製程式碼
實際上, Immutable.js 裡很多方法都使用了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 體系。
下面再看下開啟後進行操作時的一段原始碼( Map 中的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
與傳進來父節點的是否一致,然後直接在節點上操作或生成新的節點。
hash衝突
這塊的內容就沒什麼新東西了,任何語言或庫裡對於 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…