深入探究immutable.js的實現機制(二)

顧二凡發表於2019-03-03

本文是深入探究immutable.js系列的第二篇。

深入探究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這條路徑下,如下圖:
深入探究immutable.js的實現機制(二)
顯然,這圖裡展示的結構已經進行了最簡單的優化,因為現在只存了一個值,所以把與110無關的節點去掉了。還能進行什麼優化嗎?我們發現,中間那兩個節點也是可以去掉的,如下圖:
深入探究immutable.js的實現機制(二)
獲取該值時,我們先從0找下來,發現這直接是一個根節點,那取它儲存的值就行了。就是說在不產生混淆的情況下,我們可以用盡可能少的二進位制位去標識這個 key 。這樣我們就進行了高度上的壓縮,既減少了空間,又減少了查詢和修改的時間。
如果要新增一個值,它的 key 結尾也是0,該怎麼做呢?很簡單,如下圖:
深入探究immutable.js的實現機制(二)
我們只要在需要的時候增加或減少節點即可。

節點內部壓縮-Bitmap

上一篇我們提到, Immutable.js 的 Trie 裡,每個節點陣列的長度是 32 ,然而在很多情況下,這 32 個位置大部分是用不到的,這麼大的陣列顯然也佔用了很大空間。使用Bitmap,我們就可以對陣列進行壓縮。
我們先拿長度為 8 的陣列舉例:
深入探究immutable.js的實現機制(二)
我們實際上只是用了陣列的下標對 key 進行索引,這樣想陣列第 5、6、7 位顯然目前是毫無作用的,那 0、2、3 呢?我們有必要為了一個下標 4 去維持一個長度為5的陣列嗎?我們只需要指明“假想陣列”中下標為 1 和為 4 的位置有數就可以了。這裡就可以用到bitmap,如下:
深入探究immutable.js的實現機制(二)
我們採用了一個數,以其二進位制形式表達“假想的長度為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等等呢?這是通過實際測試得出的,見下圖:
深入探究immutable.js的實現機制(二)
圖中分別是查詢和更新的時間,看上去似乎 8 或 16 更好?考慮到平時的使用中,查詢比更新頻次高很多,所以 Immutable.js 選擇了 32。

回顧

現在,我們就能理解第一篇文章開頭的截圖了:
深入探究immutable.js的實現機制(二)
深入探究immutable.js的實現機制(二)
我們可以看到, map 裡主要有三種型別的節點:

  • HashArrayMapNode,擁有的子節點數量 >16 ,擁有的陣列長度為 32
  • BitmapIndexedNode,擁有的子節點數量 ≤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 中的mapdeleteAll方法以及 Map 的建構函式。而在一個不可變資料結構中實現臨時的可變資料結構的關鍵(有點拗口XD),就是這個ownerID。下圖對比了使用與不使用Transient時的區別:
深入探究immutable.js的實現機制(二)
顯然,使用Transient後由於無需每次生成新的節點,效率會提高空間佔用會減少。在開啟Transient時,根節點會被賦與一個新的ownerID,在Transient完成前的每一步操作只需遵循下面的邏輯即可:

  1. 若要操作的節點的ownerID與父節點的不一致,則生成新的節點,把舊節點上的值拷貝過來,其ownerID更新為父節點的ownerID,然後進行相應操作;
  2. 若要操作的節點的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 函式對abcbCc的 hash 結果都是 96354,在同一個 map 裡用這兩個 key 就會造成 hash 衝突,我們把這個 map log 出來如下:
深入探究immutable.js的實現機制(二)
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…



相關文章