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

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

深刻探究immutable.js的實現機制(一)
java

深刻探究immutable.js的實現機制(二)  本篇
node


上一篇咱們研究了 Immutable.js 持久化數據結構的基本實現原理,對其核心數據結構Vector Trie進行了介紹,並着重探究了其中的位分區機制。採用位分區的根本緣由是爲了優化速度,而對於空間的優化, Immutable.js 是怎麼作的呢?接下來先探討下這點。git

HAMT

HAMT全稱hash array mapped trie,其基本原理與上篇所說的Vector Trie很是類似,不過它會對樹進行壓縮,以節約一些空間。 Immutable.js 參考了HAMT對樹進行了高度和節點內部的壓縮。github

樹高壓縮

假設咱們有一個 2 叉 Vector Trie,如今存了一個值,key爲110(二進制形式), 它會被存到0 1 1這條路徑下,以下圖:

顯然,這圖裏展現的結構已經進行了最簡單的優化,由於如今只存了一個值,因此把與110無關的節點去掉了。還能進行什麼優化嗎?咱們發現,中間那兩個節點也是能夠去掉的,以下圖:

獲取該值時,咱們先從0找下來,發現這直接是一個根節點,那取它存儲的值就好了。就是說在不產生混淆的狀況下,咱們能夠用盡量少的二進制位去標識這個 key 。這樣咱們就進行了高度上的壓縮,既減小了空間,又減小了查找和修改的時間。
若是要添加一個值,它的 key 結尾也是0,該怎麼作呢?很簡單,以下圖:

咱們只要在須要的時候增長或減小節點便可。算法

節點內部壓縮-Bitmap

上一篇咱們提到, 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
      );
}複製代碼
可見它與咱們上一篇看到的源碼並無太大不一樣(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的整數次冪,因此不能是 3一、33 等。但八、1六、64等等呢?這是經過實際測試得出的,見下圖:

圖中分別是查找和更新的時間,看上去彷佛 8 或 16 更好?考慮到平時的使用中,查找比更新頻次高不少,因此 Immutable.js 選擇了 32。數組

回顧

如今,咱們就能理解第一篇文章開頭的截圖了:


咱們能夠看到, map 裏主要有三種類型的節點:bash

  • 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時的區別:

顯然,使用 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 用了一個叫作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…

相關文章
相關標籤/搜索