PHP的雜湊表實現
之前已經介紹了雜湊表的基本原理並實現了一個基本的雜湊表,而在實際項目中,對雜湊表的需求遠不止那麼簡單。對效能,靈活性都有不同的要求。下面我們看看PHP中的雜湊表是怎麼實現的。 PHP的雜湊實現 PHP核心中的雜湊表是十分重要的資料結構,PHP的大部分的語言特性都是基於雜湊表實現的,例如:變數的範圍、函數表、類的屬性、方法等,Zend引擎內部的很多資料都是儲存在雜湊表中的。 資料結構及說明 我們之前提到PHP中的雜湊表是使用拉鏈法來解決衝突的,具體點講就是使用鏈表來儲存雜湊到同一個槽位的資料, Zend為了儲存資料之間的關係使用了雙向列表來連結元素。 PHP中的雜湊表實現在Zend/zend_hash.c中,還是按照上一小節的方式,先看看PHP實現中的資料結構, PHP使用如下兩個資料結構來實現雜湊表,HashTable結構體用於儲存整個雜湊表需要的基本資料,而Bucket結構體用於儲存具體的資料內容,如下:
typedef struct _hashtable { uint nTableSize; // hash Bucket的大小,最小為8,以2x增長。 uint nTableMask; // nTableSize-1 , 索引取值的最佳化 uint nNumOfElements; // hash Bucket中當前存在的元素個數,count()函數會直接返回此值 ulong nNextFreeElement; // 下一個數字索引的位置 Bucket *pInternalPointer; // 當前遍曆的指標(foreach比for快的原因之一) Bucket *pListHead; // 儲存數組頭元素指標 Bucket *pListTail; // 儲存數組尾元素指標 Bucket **arBuckets; // 儲存hash數組 dtor_func_t pDestructor; // 在刪除元素時執行的回呼函數,用於資源的釋放 zend_bool persistent; //指出了Bucket記憶體配置的方式。如果persisient為TRUE,則使用作業系統本身的記憶體配置函數為Bucket分配記憶體,否則使用PHP的記憶體配置函數。 unsigned char nApplyCount; // 標記當前hash Bucket被遞迴訪問的次數(防止多次遞迴) zend_bool bApplyProtection;// 標記當前hash桶允許不允許多次訪問,不允許時,最多隻能遞迴3次#if ZEND_DEBUG int inconsistent;#endif} HashTable;
nTableSize欄位用於標示雜湊表的容量,雜湊表的初始容量最小為8。首先看看雜湊表的初始化函數:
ZEND_API int _zend_hash_init(HashTable *ht, uint nSize, hash_func_t pHashFunction, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC){ uint i = 3; //... if (nSize >= 0x80000000) { /* prevent overflow */ ht->nTableSize = 0x80000000; } else { while ((1U << i) < nSize) { i++; } ht->nTableSize = 1 << i; } // ... ht->nTableMask = ht->nTableSize - 1; /* Uses ecalloc() so that Bucket* == NULL */ if (persistent) { tmp = (Bucket **) calloc(ht->nTableSize, sizeof(Bucket *)); if (!tmp) { return FAILURE; } ht->arBuckets = tmp; } else { tmp = (Bucket **) ecalloc_rel(ht->nTableSize, sizeof(Bucket *)); if (tmp) { ht->arBuckets = tmp; } } return SUCCESS;}
例如如果設定初始大小為10,則上面的演算法將會將大小調整為16。也就是始終將大小調整為接近初始大小的 2的整數次方。
為什麼會做這樣的調整呢。我們先看看HashTable將雜湊值對應到槽位的方法,上一小節我們使用了模數的方式來將雜湊值對應到槽位,例如大小為8的雜湊表,雜湊值為100, 則映射的槽位索引為: 100 % 8 = 4,由於索引通常從0開始,所以槽位的索引值為3,在PHP中使用如下的方式計算索引:
h = zend_inline_hash_func(arKey, nKeyLength); nIndex = h & ht->nTableMask;
從上面的_zend_hash_init()函數中可知,ht->nTableMask的大小為ht->nTableSize -1。這裡使用&操作而不是使用模數,這是因為是相對來說模數操作的消耗和按位與的操作大很多。 mask的作用就是將雜湊值對應到槽位所能儲存的索引範圍內。 例如:某個key的索引值是21, 雜湊表的大小為8,則mask為7,則求與時的二進位表示為: 10101 & 111 = 101 也就是十進位的5。 因為2的整數次方-1的二進位比較特殊:後面N位的值都是1,這樣比較容易能將值進行映射, 如果是普通數字進行了二進位與之後會影響雜湊值的結果。那麼雜湊Function Compute的值的平均分布就可能出現影響。 設定好雜湊表大小之後就需要為雜湊表申請儲存資料的空間了,如上面初始化的代碼,根據是否需要持久儲存而調用了不同的記憶體申請方法。如前面PHP生命週期裡介紹的,是否需要持久儲存體現在:持久內容能在多個請求之間訪問,而非持久儲存是會在請求結束時釋放佔用的空間。具體內容將在記憶體管理章節中進行介紹。 HashTable中的nNumOfElements欄位很好理解,每插入一個元素或者unset刪掉元素時會更新這個欄位。這樣在進行count()函數統計數組元素個數時就能快速的返回。nNextFreeElement欄位非常有用。先看一段PHP代碼:
<?php $a = array(10 => 'Hello'); $a[] = 'TIPI'; var_dump($a); // ouput array(2) { [10]=> string(5) "Hello" [11]=> string(5) "TIPI" }
PHP中可以不指定索引值向數組中添加元素,這時將預設使用數字作為索引,和C語言中的枚舉類似,而這個元素的索引到底是多少就由nNextFreeElement欄位決定了。如果數組中存在了數字key,則會預設使用最新使用的key + 1,例如上例中已經存在了10作為key的元素,這樣新插入的預設索引就為11了。資料容器:槽位下面看看儲存雜湊表資料的槽位元據結構體:
typedef struct bucket { ulong h; // 對char *key進行hash後的值,或者是使用者指定的數字索引值 uint nKeyLength; // hash關鍵字的長度,如果數組索引為數字,此值為0 void *pData; // 指向value,一般是使用者資料的副本,如果是指標資料,則指向pDataPtr void *pDataPtr; //如果是指標資料,此值會指向真正的value,同時上面pData會指向此值 struct bucket *pListNext; // 整個hash表的下一元素 struct bucket *pListLast; // 整個雜湊表該元素的上一個元素 struct bucket *pNext; // 存放在同一個hash Bucket內的下一個元素 struct bucket *pLast; // 同一個雜湊bucket的上一個元素 // 儲存當前值所對於的key字串,這個欄位只能定義在最後,實現變長結構體 char arKey[1]; } Bucket;
如上面各欄位的注釋。h欄位儲存雜湊表key雜湊後的值。這裡儲存的雜湊值而不是在雜湊表中的索引值,這是因為索引值和雜湊表的容量有直接關係,如果雜湊表擴容了,那麼這些索引還得重新進行雜湊在進行索引映射,這也是一種最佳化手段。在PHP中可以使用字串或者數字作為數組的索引。數字索引直接就可以作為雜湊表的索引,數字也無需進行雜湊處理。h欄位後面的nKeyLength欄位是作為key長度的標示,如果索引是數位話,則nKeyLength為0。在PHP數組中如果索引字串可以被轉換成數字也會被轉換成數字索引。 所以在PHP中例如'10','11'這類的字元索引和數字索引10, 11沒有區別。 上面結構體的最後一個欄位用來儲存key的字串,而這個欄位卻申明為只有一個字元的數組,其實這裡是一種長見的變長結構體,主要的目的是增加靈活性。以下為雜湊表插入新元素時申請空間的代碼:
p = (Bucket *) pemalloc(sizeof(Bucket) - 1 + nKeyLength, ht->persistent); if (!p) { return FAILURE; } memcpy(p->arKey, arKey, nKeyLength);
如代碼,申請的空間大小加上了字串key的長度,然後把key拷貝到新申請的空間裡。在後面比如需要進行hash尋找的時候就需要對比key這樣就可以通過對比p->arKey和尋找的key是否一樣來進行資料的尋找。申請空間的大小-1是因為結構體內本身的那個位元組還是可以使用的。在PHP5.4中將這個欄位定義成const char* arKey類型了。
Bucket結構體維護了兩個雙向鏈表,pNext和pLast指標分別指向本槽位所在的鏈表的關係。
而pListNext和pListLast指標指向的則是整個雜湊表所有的資料之間的連結關係。 HashTable結構體中的pListHead和pListTail則維護整個雜湊表的頭元素指標和最後一個元素的指標。
PHP中數組的操作函數非常多,例如:array_shift()和array_pop()函數,分別從數組的頭部和尾部彈出元素。 雜湊表中儲存了頭部和尾部指標,這樣在執行這些操作時就能在常數時間內找到目標。 PHP中還有一些使用的相對不那麼多的數組操作函數:next(),prev()等的迴圈中, 雜湊表的另外一個指標就能發揮作用了:pInternalPointer,這個用於儲存當前雜湊表內部的指標。 這在迴圈時就非常有用。
如圖中左下角的假設,假設依次插入了Bucket1,Bucket2,Bucket3三個元素:
插入Bucket1時,雜湊表為空白,經過雜湊後定位到索引為1的槽位。此時的1槽位只有一個元素Bucket1。其中Bucket1的pData或者pDataPtr指向的是Bucket1所儲存的資料。此時由於沒有連結關係。pNext, pLast,pListNext,pListLast指標均為空白。同時在HashTable結構體中也儲存了整個雜湊表的第一個元素指標,和最後一個元素指標,此時HashTable的pListHead和pListTail指標均指向Bucket1。
插入Bucket2時,由於Bucket2的key和Bucket1的key出現衝突,此時將Bucket2放在雙鏈表的前面。由於Bucket2後插入共置於鏈表的前端,此時Bucket2.pNext指向Bucket1,由於Bucket2後插入。 Bucket1.pListNext指向Bucket2,這時Bucket2就是雜湊表的最後一個元素,這是HashTable.pListTail指向Bucket2。
3.插入Bucket3,該key沒有雜湊到槽位1,這時Bucket2.pListNext指向Bucket3,因為Bucket3後插入。同時HashTable.pListTail改為指向Bucket3。
簡單來說就是雜湊表的Bucket結構維護了雜湊表中插入元素的先後順序,雜湊表結構維護了整個雜湊表的頭和尾。在操作雜湊表的過程中始終保持預算之間的關係。
雜湊表的操作介面
初始化操作,例如zend_hash_init()函數,用於初始化雜湊表介面,分配空間等。
尋找,插入,刪除和更新操作介面,這是比較常規的操作。
迭代和迴圈,這類的介面用於迴圈對雜湊表進行操作。
複製,排序,倒置和銷毀等操作。
本小節選取其中的插入操作進行介紹。在PHP中不管是對數組的添加操作(zend_hash_add),還是對數組的更新操作(zend_hash_update),其最終都是調用_zend_hash_add_or_update函數完成,這在物件導向編程中相當於兩個公有方法和一個公用的私人方法的結構,以實現一定程度上的代碼複用。
ZEND_API int _zend_hash_add_or_update(HashTable *ht, const char *arKey, uint nKeyLength, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC){ //...省略變數初始化和nKeyLength <=0 的異常處理 h = zend_inline_hash_func(arKey, nKeyLength); nIndex = h & ht->nTableMask; p = ht->arBuckets[nIndex]; while (p != NULL) { if ((p->h == h) && (p->nKeyLength == nKeyLength)) { if (!memcmp(p->arKey, arKey, nKeyLength)) { // 更新操作 if (flag & HASH_ADD) { return FAILURE; } HANDLE_BLOCK_INTERRUPTIONS(); //..省略debug輸出 if (ht->pDestructor) { ht->pDestructor(p->pData); } UPDATE_DATA(ht, p, pData, nDataSize); if (pDest) { *pDest = p->pData; } HANDLE_UNBLOCK_INTERRUPTIONS(); return SUCCESS; } } p = p->pNext; } p = (Bucket *) pemalloc(sizeof(Bucket) - 1 + nKeyLength, ht->persistent); if (!p) { return FAILURE; } memcpy(p->arKey, arKey, nKeyLength); p->nKeyLength = nKeyLength; INIT_DATA(ht, p, pData, nDataSize); p->h = h; CONNECT_TO_BUCKET_DLLIST(p, ht->arBuckets[nIndex]); //Bucket雙向鏈表操作 if (pDest) { *pDest = p->pData; } HANDLE_BLOCK_INTERRUPTIONS(); CONNECT_TO_GLOBAL_DLLIST(p, ht); // 將新的Bucket元素添加到數組的連結資料表的最後面 ht->arBuckets[nIndex] = p; HANDLE_UNBLOCK_INTERRUPTIONS(); ht->nNumOfElements++; ZEND_HASH_IF_FULL_DO_RESIZE(ht); /* 如果此時數組的容量滿了,則對其進行擴容。*/ return SUCCESS;}
整個寫入或更新的操作流程如下:
產生hash值,通過與nTableMask執行與操作,擷取在arBuckets數組中的Bucket。
如果Bucket中已經存在元素,則遍曆整個Bucket,尋找是否存在相同的key值元素,如果有並且是update調用,則執行update資料操作。
建立新的Bucket元素,初始化資料,並將新元素添加到當前hash值對應的Bucket鏈表的最前面(CONNECT_TO_BUCKET_DLLIST)。
將新的Bucket元素添加到數組的連結資料表的最後面(CONNECT_TO_GLOBAL_DLLIST)。
將元素個數加1,如果此時數組的容量滿了,則對其進行擴容。這裡的判斷是依據nNumOfElements和nTableSize的大小。如果nNumOfElements > nTableSize則會調用zend_hash_do_resize以2X的方式擴容(nTableSize << 1)。
參考資料:
http://nikic.github.com/2012/03/28/Understanding-PHPs-internal-array-implementation.html