溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

[譯] PHP7 數(shù)組:HashTable

發(fā)布時間:2020-07-22 13:26:29 來源:網(wǎng)絡 閱讀:536 作者:wz669 欄目:web開發(fā)

http://joshuais.me/yi-php7-shu-zu-hashtable/?utm_source=tuicool&utm_medium=referral

[譯] PHP7 數(shù)組:HashTable

 November 17, 2016

簡介

幾乎每個C程序中都會使用到哈希表。鑒于C語言只允許使用整數(shù)作為數(shù)組的鍵名,PHP 設計了哈希表,將字符串的鍵名通過哈希算法映射到大小有限的數(shù)組中。這樣無法避免的會產(chǎn)生碰撞,PHP 使用了鏈表解決這個問題。

眾多哈希表的實現(xiàn)方式,無一完美。每種設計都著眼于某一個側重點,有的減少了 CPU 使用率,有的更合理地使用內(nèi)存,有的則能夠支持線程級的擴展。

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

數(shù)據(jù)結構

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

  • 哈希表的鍵名可能是字符串或者是整數(shù)。當是字符串時,我們聲明類型為 zend_string;當是整數(shù)時,聲明為 zend_ulong。

  • 哈希表的順序遵循表內(nèi)元素的插入順序。

  • 哈希表的容量是自動伸縮的。

  • 在內(nèi)部,哈希表的容量總是2的倍數(shù)。

  • 哈希表中每個元素一定是 zval 類型的數(shù)據(jù)。

以下是 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個字節(jié)。

其中最重要的字段是 arData,它是一個指向 Bucket 類型數(shù)據(jù)的指針,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 類型數(shù)據(jù)的指針,而是直接使用數(shù)據(jù)本身。因為在 PHP7 中,zval 不再使用堆分配,因為需要堆分配的數(shù)據(jù)會作為 zval 結構中的一個指針存儲。(比如 PHP 的字符串)。

下面是 arData 在內(nèi)存中存儲的結構:

[譯] PHP7 數(shù)組:HashTable

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

插入元素

PHP 會保證數(shù)組的元素按照插入的順序存儲。這樣當使用 foreach 循環(huán)數(shù)組時,能夠按照插入的順序遍歷。假設我們有這樣的數(shù)組:

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

所有的數(shù)據(jù)在內(nèi)存上都是相鄰的。

[譯] PHP7 數(shù)組:HashTable

這樣做,處理哈希表的迭代器的邏輯就變得相當簡單。只需要直接遍歷 arData 數(shù)組即可。遍歷內(nèi)存中相鄰的數(shù)據(jù),將會極大的利用 CPU 緩存。因為 CPU 緩存能夠讀取到整個 arData 的數(shù)據(jù),訪問每個元素將在微妙級。

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 */}

如你所見,數(shù)據(jù)被順序存放到 arData 中。為了實現(xiàn)這樣的結構,我們需要知道下一個可用的節(jié)點的位置。這個位置保存在數(shù)組結構體中的 nNumUsed 字段中。

每當添加一個新的數(shù)據(jù)時,我們保存后,會執(zhí)行 ht->nNumUsed++。當 nNumUsed 值到達哈希表所有元素的最大值(nNumOfElements)時,會觸發(fā)“壓縮或者擴容”的算法。

以下是向哈希表插入元素的簡單實現(xiàn)示例:

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 數(shù)組的結尾插入,而不會填充已經(jīng)被刪除的節(jié)點。

刪除元素

當刪除哈希表中的一項元素時,哈希表不會自動伸縮實際存儲的數(shù)據(jù)空間,而是設置了一個值為 UNDEF 的 zval,表示當前節(jié)點已經(jīng)被刪除。

如下圖所示:

[譯] PHP7 數(shù)組:HashTable

因此,在循環(huán)數(shù)組元素時,需要特殊判斷空節(jié)點:

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 */}

即使是一個十分巨大的哈希表,循環(huán)每個節(jié)點并跳過那些刪除的節(jié)點也是非??焖俚?,這得益于 arData 的節(jié)點在內(nèi)存中存放的位置總是相鄰的。

哈希定位元素

當我們得到一個字符串的鍵名,我們必須使用哈希算法計算得到哈希后的值,并且能夠通過哈希值索引找到 arData 中對應的那個元素。

我們并不能直接使用哈希后的值作為 arData 數(shù)組的索引,因為這樣就無法保證元素按照插入順序存儲。

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

[譯] PHP7 數(shù)組:HashTable

所以,當我們通過算法哈希了鍵名后,我們需要一張 轉(zhuǎn)換表,轉(zhuǎn)換表保存了哈希后的結果與實際存儲的節(jié)點的映射關系。

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

以下是有8個元素的哈希表 + 轉(zhuǎn)換表的數(shù)據(jù)結構:

[譯] PHP7 數(shù)組:HashTable

現(xiàn)在,當我們要訪問 foo 所指的元素時,通過哈希算法得到值后按照哈希表分配的元素大小做取模,就能得到我們在轉(zhuǎn)換表中存儲的節(jié)點索引值。

如我們所見,轉(zhuǎn)換表中的節(jié)點的索引與數(shù)組數(shù)據(jù)元素的節(jié)點索引是相反數(shù)的關系,nTableMask 等于哈希表大小的負數(shù)值,通過取模我們就能得到0到-7之間的數(shù),從而定位到我們所需元素所在的索引值。綜上,我們?yōu)?nbsp;arData 分配存儲空間時,需要使用 tablesize * sizeof(bucket) + tablesize * sizeof(uint32) 的計算方式計算存儲空間大小。

在源碼里也清晰的劃分了兩個區(qū)域:

#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)))

碰撞沖突

接下來我們看看如何解決哈希表的碰撞沖突問題。哈希表的鍵名可能會被哈希到同一個節(jié)點。所以,當我們訪問到轉(zhuǎn)換后的節(jié)點,我們需要對比鍵名是否我們查找的。如果不是,我們將通過 zval.u2.next 字段讀取鏈表上的下一個數(shù)據(jù)。

注意這里的鏈表結構并沒像傳統(tǒng)鏈表一樣在在內(nèi)存中分散存儲。我們直接讀取 arData整個數(shù)組,而不是通過堆(heap)獲取內(nèi)存地址分散的指針。

這是 PHP7 性能提升的一個重要點。數(shù)據(jù)局部性讓 CPU 不必經(jīng)常訪問緩慢的主存儲,而是直接從 CPU 的 L1 緩存中讀取到所有的數(shù)據(jù)。

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

	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);

同樣的規(guī)則也適用于刪除元素:

#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;

轉(zhuǎn)換表和哈希表的初始化

HT_INVALID_IDX 作為一個特殊的標記,在轉(zhuǎn)換表中表示:對應的數(shù)據(jù)節(jié)點沒有有效的數(shù)據(jù),直接跳過。

哈希表之所以能極大地減少那些創(chuàng)建時就是空值的數(shù)組的開銷,得益于他的兩步的初始化過程。當新的哈希表被創(chuàng)建時,我們只創(chuàng)建兩個轉(zhuǎn)換表節(jié)點,并且都賦予 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;}

注意到這里不需要使用堆分配內(nèi)存,而是使用靜態(tài)的內(nèi)存區(qū)域,這樣更輕量。

然后,當?shù)谝粋€元素插入時,我們會完整的初始化哈希表,這時我們才創(chuàng)建所需的轉(zhuǎn)換表的空間(如果不確定數(shù)組大小,則默認是8個元素)。這時,我們將使用堆分配內(nèi)存。

#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 宏能夠使用負數(shù)偏移量訪問轉(zhuǎn)換表中的節(jié)點。哈希表的掩碼總是負數(shù),因為轉(zhuǎn)換表的節(jié)點的索引值是 arData 數(shù)組的相反數(shù)。這才是C語言的編程之美:你可以創(chuàng)建無數(shù)的節(jié)點,并且不需要關心內(nèi)存訪問的性能問題。

以下是一個延遲初始化的哈希表結構:

[譯] PHP7 數(shù)組:HashTable

哈希表的碎片化、重組和壓縮

當哈希表填充滿并且還需要插入元素時,哈希表必須重新計算自身的大小。哈希表的大小總是成倍增長。當對哈希表擴容時,我們會預分配 arBucket 類型的C數(shù)組,并且向空的節(jié)點中存入值為 UNDEF 的 zval。在節(jié)點插入數(shù)據(jù)之前,這里會浪費 (new_size - old_size) * sizeof(Bucket) 字節(jié)的空間。

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

編程就是一個不斷設計妥協(xié)式的解決方案的過程。在底層編程中,就是對 CPU 還是內(nèi)存的一次取舍。

哈希表可能全是 UNDEF 的節(jié)點。當我們插入許多元素后,又刪除了它們,哈希表就會碎片化。因為我們永遠不會向 arData 中間節(jié)點插入數(shù)據(jù),這樣我們就可能會看到很多 UNDEF 節(jié)點。

舉個例子來說:

[譯] PHP7 數(shù)組:HashTable

重組 arData 可以整合碎片化的數(shù)組元素。當哈希表需要被重組時,首先它會自我壓縮。當它壓縮之后,會計算是否需要擴容,如果需要的話,同樣是成倍擴容。如果不需要,數(shù)據(jù)會被重新分配到已有的節(jié)點中。這個算法不會在每次元素被刪除時運行,因為需要消耗大量的 CPU 計算。

以下是壓縮后的數(shù)組:

[譯] PHP7 數(shù)組:HashTable

壓縮算法會遍歷所有 arData 里的元素并且替換原來有值的節(jié)點為 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 哈希表的實現(xiàn)基礎已經(jīng)介紹完畢,關于哈希表還有一些進階的內(nèi)容沒有翻譯,因為接下來我準備繼續(xù)分享 PHP 內(nèi)核的其他知識點,關于哈希表感興趣的同學可以移步到原文。

原文地址

http://jpauli.github.io//2016/04/08/hashtables.html


向AI問一下細節(jié)

免責聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權內(nèi)容。

AI