PHP7 雜湊表實現原理

來源:互聯網
上載者:User
簡介

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

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

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

資料結構

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

  • 雜湊表的鍵名可能是字串或者是整數。當是字串時,我們宣告類型為 zend_string;當是整數時,聲明為 zend_ulong。

  • 雜湊表的順序遵循表內元素的插入順序。

  • 雜湊表的容量是自動調整的。

  • 在內部,雜湊表的容量總是2的倍數。

  • 雜湊表中每個元素一定是 zval 類型的資料。

以下是 HashTable 的結構體:

struct _zend_array {      zend_refcounted_h gc;    union {        struct {            ZEND_ENDIAN_LOHI_4(                zend_uchar    flags,                zend_uchar    nApplyCount,                zend_uchar    nIteratorsCount,                zend_uchar    reserve)        } v;        uint32_t flags;    } u;    uint32_t          nTableMask;    Bucket           *arData;    uint32_t          nNumUsed;    uint32_t          nNumOfElements;    uint32_t          nTableSize;    uint32_t          nInternalPointer;    zend_long         nNextFreeElement;    dtor_func_t       pDestructor;};

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

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

typedef struct _Bucket {      zval              val;    zend_ulong        h;                /* hash value (or numeric index)   */    zend_string      *key;              /* string key or NULL for numerics */} Bucket;

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

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

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

插入元素

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

$a = [9 => "foo", 2 => 42, []];var_dump($a); array(3) {      [9]=>    string(3) "foo"    [2]=>    int(42)    [10]=>    array(0) {    }}

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

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

size_t i;  Bucket p;  zval val; for (i=0; i < ht->nTableSize; i++) {      p   = ht->arData[i];    val = p.val;    /* do something with val */}

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

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

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

idx = ht->nNumUsed++; /* take the next avalaible slot number */  ht->nNumOfElements++; /* increment number of elements */  /* ... */p = ht->arData + idx; /* Get the bucket in that slot from arData */  p->key = key; /* Affect it the key we want to insert at */  /* ... */p->h = h = ZSTR_H(key); /* save the hash of the current key into the bucket */  ZVAL_COPY_VALUE(&p->val, pData); /* Copy the value into the bucket's value : add operation */

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

刪除元素

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

如所示:

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

size_t i;  Bucket p;  zval val; for (i=0; i < ht->nTableSize; i++) {      p   = ht->arData[i];    val = p.val;    if (Z_TYPE(val) == IS_UNDEF) { /* empty hole ? */        continue; /* skip it */    }    /* do something with val */}

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

雜湊定位元素

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

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

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

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

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

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

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

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

在源碼裡也清晰的劃分了兩個地區:

#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))#define HT_DATA_SIZE(nTableSize) ((size_t)(nTableSize) * sizeof(Bucket))#define HT_SIZE_EX(nTableSize, nTableMask) (HT_DATA_SIZE((nTableSize)) + HT_HASH_SIZE((nTableMask)))#define HT_SIZE(ht) HT_SIZE_EX((ht)->nTableSize, (ht)->nTableMask)  Bucket *arData;  arData = emalloc(HT_SIZE(ht)); /* now alloc this */

我們將宏替換的結果展開:

(((size_t)(((ht)->nTableSize)) * sizeof(Bucket)) + (((size_t)(uint32_t)-(int32_t)(((ht)->nTableMask))) * sizeof(uint32_t)))

碰撞衝突

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

注意這裡的鏈表結構並沒像傳統鏈表一樣在在記憶體中分散儲存。我們直接讀取 arData 整個數組,而不是通過堆(heap)擷取記憶體位址分散的指標。

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

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

idx = ht->nNumUsed++;    ht->nNumOfElements++;    if (ht->nInternalPointer == HT_INVALID_IDX) {        ht->nInternalPointer = idx;    }    zend_hash_iterators_update(ht, HT_INVALID_IDX, idx);    p = ht->arData + idx;    p->key = key;    if (!ZSTR_IS_INTERNED(key)) {        zend_string_addref(key);        ht->u.flags &= ~HASH_FLAG_STATIC_KEYS;        zend_string_hash_val(key);    }    p->h = h = ZSTR_H(key);    ZVAL_COPY_VALUE(&p->val, pData);    nIndex = h | ht->nTableMask;    Z_NEXT(p->val) = HT_HASH(ht, nIndex);    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);

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

#define HT_HASH_TO_BUCKET_EX(data, idx) ((data) + (idx))#define HT_HASH_TO_BUCKET(ht, idx) HT_HASH_TO_BUCKET_EX((ht)->arData, idx) h = zend_string_hash_val(key); /* get the hash from the key (assuming string key here) */  nIndex = h | ht->nTableMask; /* get the translation table index */ idx = HT_HASH(ht, nIndex); /* Get the slot corresponding to that translation index */  while (idx != HT_INVALID_IDX) { /* If there is a corresponding slot */      p = HT_HASH_TO_BUCKET(ht, idx); /* Get the bucket from that slot */    if ((p->key == key) || /* Is it the right bucket ? same key pointer ? */        (p->h == h && /* ... or same hash */         p->key && /* and a key (string key based) */         ZSTR_LEN(p->key) == ZSTR_LEN(key) && /* and same key length */         memcmp(ZSTR_VAL(p->key), ZSTR_VAL(key), ZSTR_LEN(key)) == 0)) { /* and same key content ? */        _zend_hash_del_el_ex(ht, idx, p, prev); /* that's us ! delete us */        return SUCCESS;    }    prev = p;    idx = Z_NEXT(p->val); /* get the next corresponding slot from current one */}return FAILURE;

轉換表和雜湊表的初始化

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

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

#define HT_MIN_MASK ((uint32_t) -2)#define HT_HASH_SIZE(nTableMask) (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))#define HT_SET_DATA_ADDR(ht, ptr) do { (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); } while (0) static const uint32_t uninitialized_bucket[-HT_MIN_MASK] = {HT_INVALID_IDX, HT_INVALID_IDX}; /* hash lazy init */ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)  {    /* ... */    ht->nTableSize = zend_hash_check_size(nSize);    ht->nTableMask = HT_MIN_MASK;    HT_SET_DATA_ADDR(ht, &uninitialized_bucket);    ht->nNumUsed = 0;    ht->nNumOfElements = 0;}

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

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

#define HT_HASH_EX(data, idx) ((uint32_t*)(data))[(int32_t)(idx)]#define HT_HASH(ht, idx) HT_HASH_EX((ht)->arData, idx) (ht)->nTableMask = -(ht)->nTableSize;HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT));  memset(&HT_HASH(ht, (ht)->nTableMask), HT_INVALID_IDX, HT_HASH_SIZE((ht)->nTableMask))

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

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

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

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

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

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

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

舉個例子來說:

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

以下是壓縮後的數組:

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

Bucket *p;  uint32_t nIndex, i;  HT_HASH_RESET(ht);  i = 0;  p = ht->arData; do {      if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {        uint32_t j = i;        Bucket *q = p;        while (++i < ht->nNumUsed) {            p++;            if (EXPECTED(Z_TYPE_INFO(p->val) != IS_UNDEF)) {                ZVAL_COPY_VALUE(&q->val, &p->val);                q->h = p->h;                nIndex = q->h | ht->nTableMask;                q->key = p->key;                Z_NEXT(q->val) = HT_HASH(ht, nIndex);                HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(j);                if (UNEXPECTED(ht->nInternalPointer == i)) {                    ht->nInternalPointer = j;                }                q++;                j++;            }        }        ht->nNumUsed = j;        break;    }    nIndex = p->h | ht->nTableMask;    Z_NEXT(p->val) = HT_HASH(ht, nIndex);    HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);    p++;} while (++i < ht->nNumUsed);

結語

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

以上就是PHP7 雜湊表實現原理的內容,更多相關內容請關注topic.alibabacloud.com(www.php.cn)!

  • 聯繫我們

    該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

    如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

    A Free Trial That Lets You Build Big!

    Start building with 50+ products and up to 12 months usage for Elastic Compute Service

    • Sales Support

      1 on 1 presale consultation

    • After-Sales Support

      24/7 Technical Support 6 Free Tickets per Quarter Faster Response

    • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.