PHP7 雜湊表實現原理

發表於2016-11-22

簡介

幾乎每個C程式中都會使用到雜湊表。鑑於C語言只允許使用整數作為陣列的鍵名,PHP 設計了雜湊表,將字串的鍵名通過雜湊演算法對映到大小有限的陣列中。這樣無法避免的會產生碰撞,PHP 使用了連結串列解決這個問題。

眾多雜湊表的實現方式,無一完美。每種設計都著眼於某一個側重點,有的減少了 CPU 使用率,有的更合理地使用記憶體,有的則能夠支援執行緒級的擴充套件。

實現雜湊表的方式之所以存在多樣性,是因為每種實現方式都只能在各自的關注點上提升,而無法面面俱到。

資料結構

開始介紹之前,我們需要事先宣告一些事情:

  • 雜湊表的鍵名可能是字串或者是整數。當是字串時,我們宣告型別為 zend_string;當是整數時,宣告為 zend_ulong
  • 雜湊表的順序遵循表內元素的插入順序。
  • 雜湊表的容量是自動伸縮的。
  • 在內部,雜湊表的容量總是2的倍數。
  • 雜湊表中每個元素一定是 zval 型別的資料。

以下是 HashTable 的結構體:

這個結構體佔56個位元組。

其中最重要的欄位是 arData,它是一個指向 Bucket 型別資料的指標,Bucket 結構定義如下:

Bucket 中不再使用指向一個 zval 型別資料的指標,而是直接使用資料本身。因為在 PHP7 中,zval 不再使用堆分配,因為需要堆分配的資料會作為 zval 結構中的一個指標儲存。(比如 PHP 的字串)。

下面是 arData 在記憶體中儲存的結構:

PHP7 雜湊表實現原理

我們注意到所有的Bucket都是按順序存放的。

插入元素

PHP 會保證陣列的元素按照插入的順序儲存。這樣當使用 foreach 迴圈陣列時,能夠按照插入的順序遍歷。假設我們有這樣的陣列:

所有的資料在記憶體上都是相鄰的。

PHP7 雜湊表實現原理

這樣做,處理雜湊表的迭代器的邏輯就變得相當簡單。只需要直接遍歷 arData 陣列即可。遍歷記憶體中相鄰的資料,將會極大的利用 CPU 快取。因為 CPU 快取能夠讀取到整個 arData 的資料,訪問每個元素將在微妙級。

如你所見,資料被順序存放到 arData 中。為了實現這樣的結構,我們需要知道下一個可用的節點的位置。這個位置儲存在陣列結構體中的 nNumUsed 欄位中。

每當新增一個新的資料時,我們儲存後,會執行 ht->nNumUsed++。當 nNumUsed 值到達雜湊表所有元素的最大值(nNumOfElements)時,會觸發“壓縮或者擴容”的演算法。

以下是向雜湊表插入元素的簡單實現示例:

我們可以看到,插入時只會在 arData 陣列的結尾插入,而不會填充已經被刪除的節點。

刪除元素

當刪除雜湊表中的一項元素時,雜湊表不會自動伸縮實際儲存的資料空間,而是設定了一個值為 UNDEFzval,表示當前節點已經被刪除。

如下圖所示:

PHP7 雜湊表實現原理

因此,在迴圈陣列元素時,需要特殊判斷空節點:

即使是一個十分巨大的雜湊表,迴圈每個節點並跳過那些刪除的節點也是非常快速的,這得益於 arData 的節點在記憶體中存放的位置總是相鄰的。

雜湊定位元素

當我們得到一個字串的鍵名,我們必須使用雜湊演算法計算得到雜湊後的值,並且能夠通過雜湊值索引找到 arData 中對應的那個元素。

我們並不能直接使用雜湊後的值作為 arData 陣列的索引,因為這樣就無法保證元素按照插入順序儲存。

舉個例子:如果我插入的鍵名先是 foo,然後是 bar,假設 foo 雜湊後的結果是5,而 bar 雜湊後的結果是3。如果我們將 foo 存在 arData[5],而 bar 存在 arData[3],這意味著 bar 元素要在 foo 元素的前面,這和我們插入的順序正好是相反的。

PHP7 雜湊表實現原理

所以,當我們通過演算法雜湊了鍵名後,我們需要一張 轉換表,轉換表儲存了雜湊後的結果與實際儲存的節點的對映關係。

這裡在設計的時候取了個巧:將轉換表儲存以 arData 起始指標為起點做鏡面對映儲存。這樣,我們不需要額外的空間儲存,在分配 arData 空間的同時也分配了轉換表。

以下是有8個元素的雜湊表 + 轉換表的資料結構:

PHP7 雜湊表實現原理

現在,當我們要訪問 foo 所指的元素時,通過雜湊演算法得到值後按照雜湊表分配的元素大小做取模,就能得到我們在轉換表中儲存的節點索引值。

如我們所見,轉換表中的節點的索引與陣列資料元素的節點索引是相反數的關係,nTableMask 等於雜湊表大小的負數值,通過取模我們就能得到0到-7之間的數,從而定位到我們所需元素所在的索引值。綜上,我們為 arData 分配儲存空間時,需要使用 tablesize * sizeof(bucket) + tablesize * sizeof(uint32) 的計算方式計算儲存空間大小。

在原始碼裡也清晰的劃分了兩個區域:

我們將巨集替換的結果展開:

碰撞衝突

接下來我們看看如何解決雜湊表的碰撞衝突問題。雜湊表的鍵名可能會被雜湊到同一個節點。所以,當我們訪問到轉換後的節點,我們需要對比鍵名是否我們查詢的。如果不是,我們將通過 zval.u2.next 欄位讀取連結串列上的下一個資料。

注意這裡的連結串列結構並沒像傳統連結串列一樣在在記憶體中分散儲存。我們直接讀取 arData 整個陣列,而不是通過堆(heap)獲取記憶體地址分散的指標。

這是 PHP7 效能提升的一個重要點。資料區域性性讓 CPU 不必經常訪問緩慢的主儲存,而是直接從 CPU 的 L1 快取中讀取到所有的資料。

所以,我們看到向雜湊表新增一個元素是這樣操作的:

同樣的規則也適用於刪除元素:

轉換表和雜湊表的初始化

HT_INVALID_IDX 作為一個特殊的標記,在轉換表中表示:對應的資料節點沒有有效的資料,直接跳過。

雜湊表之所以能極大地減少那些建立時就是空值的陣列的開銷,得益於他的兩步的初始化過程。當新的雜湊表被建立時,我們只建立兩個轉換表節點,並且都賦予 HT_INVALID_IDX 標記。

注意到這裡不需要使用堆分配記憶體,而是使用靜態的記憶體區域,這樣更輕量。

然後,當第一個元素插入時,我們會完整的初始化雜湊表,這時我們才建立所需的轉換表的空間(如果不確定陣列大小,則預設是8個元素)。這時,我們將使用堆分配記憶體。

HT_HASH 巨集能夠使用負數偏移量訪問轉換表中的節點。雜湊表的掩碼總是負數,因為轉換表的節點的索引值是 arData 陣列的相反數。這才是C語言的程式設計之美:你可以建立無數的節點,並且不需要關心記憶體訪問的效能問題。

以下是一個延遲初始化的雜湊表結構:

PHP7 雜湊表實現原理

雜湊表的碎片化、重組和壓縮

當雜湊表填充滿並且還需要插入元素時,雜湊表必須重新計算自身的大小。雜湊表的大小總是成倍增長。當對雜湊表擴容時,我們會預分配 arBucket 型別的C陣列,並且向空的節點中存入值為 UNDEF 的 zval。在節點插入資料之前,這裡會浪費 (new_size – old_size) * sizeof(Bucket) 位元組的空間。

如果一個有1024個節點的雜湊表,再新增元素時,雜湊表將會擴容到2048個節點,其中1023個節點都是空節點,這將消耗 1023 * 32 bytes = 32KB 的空間。這是 PHP 雜湊表實現方式的缺陷,因為沒有完美的解決方案。

程式設計就是一個不斷設計妥協式的解決方案的過程。在底層程式設計中,就是對 CPU 還是記憶體的一次取捨。

雜湊表可能全是 UNDEF 的節點。當我們插入許多元素後,又刪除了它們,雜湊表就會碎片化。因為我們永遠不會向 arData 中間節點插入資料,這樣我們就可能會看到很多 UNDEF 節點。

舉個例子來說:

PHP7 雜湊表實現原理

重組 arData 可以整合碎片化的陣列元素。當雜湊表需要被重組時,首先它會自我壓縮。當它壓縮之後,會計算是否需要擴容,如果需要的話,同樣是成倍擴容。如果不需要,資料會被重新分配到已有的節點中。這個演算法不會在每次元素被刪除時執行,因為需要消耗大量的 CPU 計算。

以下是壓縮後的陣列:

PHP7 雜湊表實現原理

壓縮演算法會遍歷所有 arData 裡的元素並且替換原來有值的節點為 UNDEF。如下所示:

結語

到此,PHP 雜湊表的實現基礎已經介紹完畢,關於雜湊表還有一些進階的內容沒有翻譯,因為接下來我準備繼續分享 PHP 核心的其他知識點,關於雜湊表感興趣的同學可以移步到原文。

相關文章