php變量的實現原理

一. PHP變量類型及存儲結構

PHP在聲明或使用變量的時候,並不需要顯式指明其數據類型。

PHP是弱類型語言,這並不表示PHP沒有類型,在PHP中,存在8種變量類型,可以分爲三類

標量類型: booleanintegerfloat(double)string

複合類型: arrayobject

特殊類型: resourceNULL

官方PHP是用C實現的,而C是強類型的語言,那這是怎麼實現PHP中的弱類型的呢?



1. 變量存儲結構

變量的值存儲到以下所示zval結構體中。 zval結構體定義在Zend/zend.h文件,其結構如下:

typedef struct _zval_struct zval;
...
struct _zval_struct {
    /* Variable information */
    zvalue_value value;     /* value */
    zend_uint refcount__gc;
    zend_uchar type;    /* active type */
    zend_uchar is_ref__gc;
};

PHP使用這個結構來存儲變量的所有數據。和其他編譯性靜態語言不同, PHP在存儲變量時將PHP用戶空間的變量類型也保存在同一個結構體中。這樣我們就能通過這些信息獲取到變量的類型。

zval結構體中有四個字段,其含義分別爲:

屬性名 含義 默認值
refcount__gc 表示引用計數 1
is_ref__gc 表示是否爲引用 0
value 存儲變量的值  
type 變量具體的類型  

在PHP5.3之後,引入了新的垃圾收集機制,引用計數和引用的字段名改爲refcount__gc和is_ref__gc。在此之前爲refcount和is__ref。

而變量的值則存儲在另外一個結構體zvalue_value中。值存儲見下面的介紹。

PHP用戶空間指的在PHP語言這一層面,而本書中大部分地方都在探討PHP的實現。 這些實現可以理解爲內核空間。由於PHP使用C實現,而這個空間的範疇就會限制在C語言。 而PHP用戶空間則會受限於PHP語法及功能提供的範疇之內。 
例如有些PHP擴展會提供一些PHP函數或者類,這就是向PHP用戶空間導出了方法或類。

2.變量類型:

zval結構體的type字段就是實現弱類型最關鍵的字段了,type的值可以爲: IS_NULL、IS_BOOL、IS_LONG、IS_DOUBLE、IS_STRING、IS_ARRAY、IS_OBJECT和IS_RESOURCE 之一。 從字面上就很好理解,他們只是類型的唯一標示,根據類型的不同將不同的值存儲到value字段。 除此之外,和他們定義在一起的類型還有IS_CONSTANT和IS_CONSTANT_ARRAY。

這和我們設計數據庫時的做法類似,爲了避免重複設計類似的表,使用一個標示字段來記錄不同類型的數據。

二.變量的值存儲

前面提到變量的值存儲在zvalue_value聯合體中,結構體定義如下:

typedef union _zvalue_value {
    long lval;                  /* long value */
    double dval;                /* double value */
    struct {
        char *val;
        int len;
    } str;
    HashTable *ht;              /* hash table value */
    zend_object_value obj;
} zvalue_value;

這裏使用聯合體而不是用結構體是出於空間利用率的考慮,因爲一個變量同時只能屬於一種類型。 如果使用結構體的話將會不必要的浪費空間,而PHP中的所有邏輯都圍繞變量來進行的,這樣的話, 內存浪費將是十分大的。這種做法成本小但收益非常大。

各種類型的數據會使用不同的方法來進行變量值的存儲,其對應賦值方式如下:

  • 一般類型
變量類型  
boolean ZVAL_BOOL 布爾型/整型的變量值存儲於(zval).value.lval中,其類型也會以相應的IS_*進行存儲。
 Z_TYPE_P(z)=IS_BOOL/LONG;  Z_LVAL_P(z)=((b)!=0); 
integer ZVAL_LONG
float ZVAL_DOUBLE
null ZVAL_NULL NULL值的變量值不需要存儲,只需要把(zval).type標爲IS_NULL。
 Z_TYPE_P(z)=IS_NULL; 
resource ZVAL_RESOURCE 資源類型的存儲與其他一般變量無異,但其初始化及存取實現則不同。
 Z_TYPE_P(z) = IS_RESOURCE;  Z_LVAL_P(z) = l; 
  • 字符串String

字符串的類型標示和其他數據類型一樣,不過在存儲字符串時多了一個字符串長度的字段。

struct {
    char *val;
    int len;
} str;

C中字符串是以\0結尾的字符數組,這裏多存儲了字符串的長度,這和我們在設計數據庫時增加的冗餘字段異曲同工。 因爲要實時獲取到字符串的長度的時間複雜度是O(n),而字符串的操作在PHP中是非常頻繁的,這樣能避免重複計算字符串的長度, 這能節省大量的時間,是空間換時間的做法。 
這麼看在PHP中strlen()函數可以在常數時間內獲取到字符串的長度。 計算機語言中字符串的操作都非常之多,所以大部分高級語言中都會存儲字符串的長度。

  • 數組Array

數組是PHP中最常用,也是最強大變量類型,它可以存儲其他類型的數據,而且提供各種內置操作函數。數組的存儲相對於其他變量要複雜一些, 數組的值存儲在zvalue_value.ht字段中,它是一個HashTable類型的數據。 PHP的數組使用哈希表來存儲關聯數據。哈希表是一種高效的鍵值對存儲結構。PHP的哈希表實現中使用了兩個數據結構HashTable和Bucket。 PHP所有的工作都由哈希表實現,在下節HashTable中將進行哈希表基本概念的介紹以及PHP的哈希表實現。

  • 對象Object

在面嚮對象語言中,我們能自己定義自己需要的數據類型,包括類的屬性,方法等數據。而對象則是類的一個具體實現。 對象有自身的狀態和所能完成的操作。

PHP的對象是一種複合型的數據,使用一種zend_object_value的結構體來存放。其定義如下:

typedef struct _zend_object_value {
    zend_object_handle handle;  //  unsigned int類型,EG(objects_store).object_buckets的索引
    zend_object_handlers *handlers;
} zend_object_value;

PHP的對象只有在運行時纔會被創建,前面的章節介紹了EG宏,這是一個全局結構體用於保存在運行時的數據。 其中就包括了用來保存所有被創建的對象的對象池,EG(objects_store),而object對象值內容的zend_object_handle域就是當前 對象在對象池中所在的索引,handlers字段則是將對象進行操作時的處理函數保存起來。 這個結構體及對象相關的類的結構_zend_class_entry,將在第五章作詳細介紹。

PHP的弱變量容器的實現方式是兼容幷包的形式體現,針對每種類型的變量都有其對應的標記和存儲空間。 使用強類型的語言在效率上通常會比弱類型高,因爲很多信息能在運行之前就能確定,這也能幫助排除程序錯誤。 而這帶來的問題是編寫代碼相對會受制約。

PHP主要的用途是作爲Web開發語言,在普通的Web應用中瓶頸通常在業務和數據訪問這一層。不過在大型應用下語言也會是一個關鍵因素。 facebook因此就使用了自己的php實現。將PHP編譯爲C++代碼來提高性能。不過facebook的hiphop並不是完整的php實現, 由於它是直接將php編譯爲C++,有一些PHP的動態特性比如eval結構就無法實現。當然非要實現也是有方法的, hiphop不實現應該也是做了一個權衡。


哈希表(HashTable)

按圖索驥。

PHP中使用最爲頻繁的數據類型非字符串和數組莫屬,PHP比較容易上手也得益於非常靈活的數組類型。 在開始詳細介紹這些數據類型之前有必要介紹一下哈希表(HashTable)。 哈希表是PHP實現中尤爲關鍵的數據結構。

哈希表在實踐中使用的非常廣泛,例如編譯器通常會維護的一個符號表來保存標記,很多高級語言中也顯式的支持哈希表。 哈希表通常提供查找(Search),插入(Insert),刪除(Delete)等操作,這些操作在最壞的情況下和鏈表的性能一樣爲O(n)。 不過通常並不會這麼壞,合理設計的哈希算法能有效的避免這類情況,通常哈希表的這些操作時間複雜度爲O(1)。 這也是它被鍾愛的原因。

正是因爲哈希表在使用上的便利性及效率上的表現,目前大部分動態語言的實現中都使用了哈希表。

基本概念

爲了方便讀者閱讀後面的內容,這裏列舉一下HashTable實現中出現的基本概念。 哈希表是一種通過哈希函數,將特定的鍵映射到特定值的一種數據結構,它維護鍵和值之間一一對應關係。

  • 鍵(key):用於操作數據的標示,例如PHP數組中的索引,或者字符串鍵等等。
  • 槽(slot/bucket):哈希表中用於保存數據的一個單元,也就是數據真正存放的容器。
  • 哈希函數(hash function):將key映射(map)到數據應該存放的slot所在位置的函數。
  • 哈希衝突(hash collision):哈希函數將兩個不同的key映射到同一個索引的情況。

哈希表可以理解爲數組的擴展或者關聯數組,數組使用數字下標來尋址,如果關鍵字(key)的範圍較小且是數字的話, 我們可以直接使用數組來完成哈希表,而如果關鍵字範圍太大,如果直接使用數組我們需要爲所有可能的key申請空間。 很多情況下這是不現實的。即使空間足夠,空間利用率也會很低,這並不理想。同時鍵也可能並不是數字, 在PHP中尤爲如此,所以人們使用一種映射函數(哈希函數)來將key映射到特定的域中:

h(key) -> index

通過合理設計的哈希函數,我們就能將key映射到合適的範圍,因爲我們的key空間可以很大(例如字符串key), 在映射到一個較小的空間中時可能會出現兩個不同的key映射被到同一個index上的情況, 這就是我們所說的出現了衝突。 目前解決hash衝突的方法主要有兩種:鏈接法和開放尋址法。

衝突解決

鏈接法

鏈接法通過使用一個鏈表來保存slot值的方式來解決衝突,也就是當不同的key映射到一個槽中的時候使用鏈表來保存這些值。 所以使用鏈接法是在最壞的情況下,也就是所有的key都映射到同一個槽中了,這樣哈希表就退化成了一個鏈表, 這樣的話操作鏈表的時間複雜度則成了O(n),這樣哈希表的性能優勢就沒有了, 所以選擇一個合適的哈希函數是最爲關鍵的。

由於目前大部分的編程語言的哈希表實現都是開源的,大部分語言的哈希算法都是公開的算法, 雖然目前的哈希算法都能良好的將key進行比較均勻的分佈,而這個假使的前提是key是隨機的,正是由於算法的確定性, 這就導致了別有用心的黑客能利用已知算法的可確定性來構造一些特殊的key,讓這些key都映射到 同一個槽位導致哈希表退化成單鏈表,導致程序的性能急劇下降,從而造成一些應用的吞吐能力急劇下降, 尤其是對於高併發的應用影響很大,通過大量類似的請求可以讓服務器遭受DoS(服務拒絕攻擊), 這個問題一直就存在着,只是最近才被各個語言重視起來。

哈希衝突攻擊利用的哈希表最根本的弱點是:開源算法和哈希實現的確定性以及可預測性, 這樣攻擊者纔可以利用特殊構造的key來進行攻擊。要解決這個問題的方法則是讓攻擊者無法輕易構造 能夠進行攻擊的key序列。

在筆者編寫這節內容的時候PHP語言也採取了相應的措施來防止這類的攻擊,PHP採用的是一種 治標不治本的做法: 限制用戶提交數據字段數量 這樣可以避免大部分的攻擊,不過應用程序通常會有很多的數據輸入方式,比如,SOAP,REST等等, 比如很多應用都會接受用戶傳入的JSON字符串,在執行json_decode()的時候也可能會遭受攻擊。 所以最根本的解決方法是讓哈希表的碰撞key序列無法輕易的構造,目前PHP中還沒有引入不增加額外的複雜性情況下的完美解決方案。

目前PHP中HashTable的哈希衝突解決方法就是鏈接法。

開放尋址法

通常還有另外一種解決衝突的方法:開放尋址法。使用開放尋址法是槽本身直接存放數據, 在插入數據時如果key所映射到的索引已經有數據了,這說明發生了衝突,這是會尋找下一個槽, 如果該槽也被佔用了則繼續尋找下一個槽,直到尋找到沒有被佔用的槽,在查找時也使用同樣的策略來進行。

由於開放尋址法處理衝突的時候佔用的是其他槽位的空間,這可能會導致後續的key在插入的時候更加容易出現 哈希衝突,所以採用開放尋址法的哈希表的裝載因子不能太高,否則容易出現性能下降。

裝載因子是哈希表保存的元素數量和哈希表容量的比,通常採用鏈接法解決衝突的哈希表的裝載 因子最好不要大於1,而採用開放尋址法的哈希表最好不要大於0.5。

哈希表的實現

在瞭解到哈希表的原理之後要實現一個哈希表也很容易,主要需要完成的工作只有三點:

  1. 實現哈希函數
  2. 衝突的解決
  3. 操作接口的實現
數據結構

首先我們需要一個容器來保存我們的哈希表,哈希表需要保存的內容主要是保存進來的的數據, 同時爲了方便的得知哈希表中存儲的元素個數,需要保存一個大小字段, 第二個需要的就是保存數據的容器了。作爲實例,下面將實現一個簡易的哈希表。基本的數據結構主要有兩個, 一個用於保存哈希表本身,另外一個就是用於實際保存數據的單鏈表了,定義如下:

typedef struct _Bucket
{
    char *key;
    void *value;
    struct _Bucket *next;
} Bucket;
 
typedef struct _HashTable
{
    int size;
    int elem_num;
    Bucket** buckets;
} HashTable;

上面的定義和PHP中的實現類似,爲了便於理解裁剪了大部分無關的細節,在本節中爲了簡化, key的數據類型爲字符串,而存儲的數據類型可以爲任意類型。

Bucket結構體是一個單鏈表,這是爲了解決多個key哈希衝突的問題,也就是前面所提到的的鏈接法。 當多個key映射到同一個index的時候將衝突的元素鏈接起來。

哈希函數實現

哈希函數需要儘可能的將不同的key映射到不同的槽(slot或者bucket)中,首先我們採用一種最爲簡單的哈希算法實現: 將key字符串的所有字符加起來,然後以結果對哈希表的大小取模,這樣索引就能落在數組索引的範圍之內了。

static int hash_str(char *key)
{
    int hash = 0;
 
    char *cur = key;
 
    while(*cur != '\0') {
        hash += *cur;
        ++cur;
    }
 
    return hash;
}
 
// 使用這個宏來求得key在哈希表中的索引
#define HASH_INDEX(ht, key) (hash_str((key)) % (ht)->size)

這個哈希算法比較簡單,它的效果並不好,在實際場景下不會使用這種哈希算法, 例如PHP中使用的是稱爲DJBX33A算法, 這裏列舉了Mysql,OpenSSL等開源軟件使用的哈希算法, 有興趣的讀者可以前往參考。

有興趣的讀者可以運行本小節實現的哈希表實現,在輸出日誌中將看到很多的哈希衝突, 這是本例中使用的哈希算法過於簡單造成的.

操作接口的實現

爲了操作哈希表,實現瞭如下幾個操作接口函數:

int hash_init(HashTable *ht);                               // 初始化哈希表
int hash_lookup(HashTable *ht, char *key, void **result);   // 根據key查找內容
int hash_insert(HashTable *ht, char *key, void *value);     // 將內容插入到哈希表中
int hash_remove(HashTable *ht, char *key);                  // 刪除key所指向的內容
int hash_destroy(HashTable *ht);

下面以初始化、插入和獲取操作函數爲例:

int hash_init(HashTable *ht)
{
    ht->size        = HASH_TABLE_INIT_SIZE;
    ht->elem_num    = 0;
    ht->buckets     = (Bucket **)calloc(ht->size, sizeof(Bucket *));
 
    if(ht->buckets == NULL) return FAILED;
 
    LOG_MSG("[init]\tsize: %i\n", ht->size);
 
    return SUCCESS;
}

初始化的主要工作是爲哈希表申請存儲空間,函數中使用calloc函數的目的是確保 數據存儲的槽爲都初始化爲0,以便後續在插入和查找時確認該槽爲是否被佔用。

int hash_insert(HashTable *ht, char *key, void *value)
{
    // check if we need to resize the hashtable
    resize_hash_table_if_needed(ht);
 
    int index = HASH_INDEX(ht, key);
 
    Bucket *org_bucket = ht->buckets[index];
    Bucket *tmp_bucket = org_bucket;
 
    // check if the key exits already
    while(tmp_bucket)
    {
        if(strcmp(key, tmp_bucket->key) == 0)
        {
            LOG_MSG("[update]\tkey: %s\n", key);
            tmp_bucket->value = value;
 
            return SUCCESS;
        }
 
        tmp_bucket = tmp_bucket->next;
    }
 
    Bucket *bucket = (Bucket *)malloc(sizeof(Bucket));
 
    bucket->key   = key;
    bucket->value = value;
    bucket->next  = NULL;
 
    ht->elem_num += 1;
 
    if(org_bucket != NULL)
    {
        LOG_MSG("[collision]\tindex:%d key:%s\n", index, key);
        bucket->next = org_bucket;
    }
 
    ht->buckets[index]= bucket;
 
    LOG_MSG("[insert]\tindex:%d key:%s\tht(num:%d)\n",
        index, key, ht->elem_num);
 
    return SUCCESS;
}

上面這個哈希表的插入操作比較簡單,簡單的以key做哈希,找到元素應該存儲的位置,並檢查該位置是否已經有了內容, 如果發生碰撞則將新元素鏈接到原有元素鏈表頭部。

由於在插入過程中可能會導致哈希表的元素個數比較多,如果超過了哈希表的容量, 則說明肯定會出現碰撞,出現碰撞則會導致哈希表的性能下降,爲此如果出現元素容量達到容量則需要進行擴容。 由於所有的key都進行了哈希,擴容後哈希表不能簡單的擴容,而需要重新將原有已插入的預算插入到新的容器中。

static void resize_hash_table_if_needed(HashTable *ht)
{
    if(ht->size - ht->elem_num < 1)
    {
        hash_resize(ht);
    }
}
 
static int hash_resize(HashTable *ht)
{
    // double the size
    int org_size = ht->size;
    ht->size = ht->size * 2;
    ht->elem_num = 0;
 
    LOG_MSG("[resize]\torg size: %i\tnew size: %i\n", org_size, ht->size);
 
    Bucket **buckets = (Bucket **)calloc(ht->size, sizeof(Bucket *));
 
    Bucket **org_buckets = ht->buckets;
    ht->buckets = buckets;
 
    int i = 0;
    for(i=0; i < org_size; ++i)
    {
        Bucket *cur = org_buckets[i];
        Bucket *tmp;
        while(cur)
        {
            // rehash: insert again
            hash_insert(ht, cur->key, cur->value);
 
            // free the org bucket, but not the element
            tmp = cur;
            cur = cur->next;
            free(tmp);
        }
    }
    free(org_buckets);
 
    LOG_MSG("[resize] done\n");
 
    return SUCCESS;
}

哈希表的擴容首先申請一塊新的內存,大小爲原來的2倍,然後重新將元素插入到哈希表中, 讀者會發現擴容的操作的代價爲O(n),不過這個問題不大,因爲只有在到達哈希表容量的時候纔會進行。

在查找時也使用插入同樣的策略,找到元素所在的位置,如果存在元素, 則將該鏈表的所有元素的key和要查找的key依次對比, 直到找到一致的元素,否則說明該值沒有匹配的內容。

int hash_lookup(HashTable *ht, char *key, void **result)
{
    int index = HASH_INDEX(ht, key);
    Bucket *bucket = ht->buckets[index];
 
    if(bucket == NULL) goto failed;
 
    while(bucket)
    {
        if(strcmp(bucket->key, key) == 0)
        { 
            LOG_MSG("[lookup]\t found %s\tindex:%i value: %p\n",
                key, index, bucket->value);
            *result = bucket->value;
 
            return SUCCESS;
        } 
 
        bucket = bucket->next;
    }
 
failed:
    LOG_MSG("[lookup]\t key:%s\tfailed\t\n", key);
    return FAILED;
}

PHP中數組是基於哈希表實現的,依次給數組添加元素時,元素之間是有先後順序的, 而這裏的哈希表在物理位置上顯然是接近平均分佈的,這樣是無法根據插入的先後順序獲取到這些元素的, 在PHP的實現中Bucket結構體還維護了另一個指針字段來維護元素之間的關係。 具體內容在後一小節PHP中的HashTable中進行詳細說明。上面的例子就是PHP中實現的一個精簡版。

本小節的HashTable實例完整代碼可以在$TIPI_ROOT/book/sample/chapt03/03-01-01-hashtable目錄中找到。 或者在github上瀏覽: https://github.com/reeze/tipi/tree/master/book/sample/chapt03/03-01-01-hashtable

參考文獻

  • 《Data.Structures.and.Algorithm.Analysis.in.C》
  • 《算法導論: 第二版》

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,這樣比較容易能將值進行映射, 如果是普通數字進行了二進制與之後會影響哈希值的結果。那麼哈希函數計算的值的平均分佈就可能出現影響。

設置好哈希表大小之後就需要爲哈希表申請存儲數據的空間了,如上面初始化的代碼, 根據是否需要持久保存而調用了不同的內存申請方法。如前面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類型了。

Zend引擎哈希表結構和關係
Zend引擎哈希表結構和關係

上圖來源於網絡

  • Bucket結構體維護了兩個雙向鏈表,pNext和pLast指針分別指向本槽位所在的鏈表的關係。
  • 而pListNext和pListLast指針指向的則是整個哈希表所有的數據之間的鏈接關係。 HashTable結構體中的pListHead和pListTail則維護整個哈希表的頭元素指針和最後一個元素的指針。

PHP中數組的操作函數非常多,例如:array_shift()和array_pop()函數,分別從數組的頭部和尾部彈出元素。 哈希表中保存了頭部和尾部指針,這樣在執行這些操作時就能在常數時間內找到目標。 PHP中還有一些使用的相對不那麼多的數組操作函數:next(),prev()等的循環中, 哈希表的另外一個指針就能發揮作用了:pInternalPointer,這個用於保存當前哈希表內部的指針。 這在循環時就非常有用。

如圖中左下角的假設,假設依次插入了Bucket1,Bucket2,Bucket3三個元素:

  1. 插入Bucket1時,哈希表爲空,經過哈希後定位到索引爲1的槽位。此時的1槽位只有一個元素Bucket1。 其中Bucket1的pData或者pDataPtr指向的是Bucket1所存儲的數據。此時由於沒有鏈接關係。pNext, pLast,pListNext,pListLast指針均爲空。同時在HashTable結構體中也保存了整個哈希表的第一個元素指針, 和最後一個元素指針,此時HashTable的pListHead和pListTail指針均指向Bucket1。
  2. 插入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結構維護了哈希表中插入元素的先後順序,哈希表結構維護了整個哈希表的頭和尾。 在操作哈希表的過程中始終保持預算之間的關係。

哈希表的操作接口

和上一節類似,將簡單介紹PHP哈希表的操作接口實現。提供瞭如下幾類操作接口:

  • 初始化操作,例如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;
}

整個寫入或更新的操作流程如下:

  1. 生成hash值,通過與nTableMask執行與操作,獲取在arBuckets數組中的Bucket。
  2. 如果Bucket中已經存在元素,則遍歷整個Bucket,查找是否存在相同的key值元素,如果有並且是update調用,則執行update數據操作。
  3. 創建新的Bucket元素,初始化數據,並將新元素添加到當前hash值對應的Bucket鏈表的最前面(CONNECT_TO_BUCKET_DLLIST)。
  4. 將新的Bucket元素添加到數組的鏈接表的最後面(CONNECT_TO_GLOBAL_DLLIST)。
  5. 將元素個數加1,如果此時數組的容量滿了,則對其進行擴容。這裏的判斷是依據nNumOfElements和nTableSize的大小。 如果nNumOfElements > nTableSize則會調用zend_hash_do_resize以2X的方式擴容(nTableSize << 1)。

哈希表的性能

其他語言中的HashTable實現

Ruby使用的st庫,Ruby中的兩種hash實現

參考資料

http://nikic.github.com/2012/03/28/Understanding-PHPs-internal-array-implementation.html

鏈表簡介

Zend引擎中實現了很多基本的數據結構,這些接口貫穿PHP和Zend引擎的始末, 這些數據結構以及相應的操作接口都可以作爲通用的接口來使用。本小節再簡單描述一下

在Zend引擎中HashTable的使用非常頻繁,這得益於他良好的查找性能,如果讀者看過 前一小節會知道哈希表會預先分配內容以提高性能,而很多時候數據規模不會很大, 固然使用哈希表能提高查詢性能,但是某些場景下並不會對數據進行隨機查找, 這時使用哈希表就有點浪費了。

Zend引擎中的鏈表是雙鏈表, 通過雙鏈表的任意節點都能方便的對鏈表進行遍歷。

Zend引擎的哈希表實現是哈希表和雙鏈表的混合實現,這也是爲了方便哈希表的遍歷。

鏈表的實現很簡單,通常只需要三個關鍵元素:

  1. 指向上個元素的指針
  2. 指向下個元素的指針
  3. 數據容器

Zend引擎的實現也很簡單,如下兩個是核心的數據接口,第一個是元素節點,第二個是鏈表容器。

typedef struct _zend_llist_element {
    struct _zend_llist_element *next;
    struct _zend_llist_element *prev;
    char data[1]; /* Needs to always be last in the struct */
} zend_llist_element;
 
typedef struct _zend_llist {
    zend_llist_element *head;
    zend_llist_element *tail;
    size_t count;
    size_t size;
    llist_dtor_func_t dtor;
    unsigned char persistent;
    zend_llist_element *traverse_ptr;
} zend_llist;

節點元素只含有前面提到的3個元素,第三個字段data和哈希表的實現一樣, 是一個柔性結構體。

Zend zend_llist結構
Zend zend_llist結構

如上圖所示,data字段的空間並不是只有一個字節,我們先看看元素插入的實現:

ZEND_API void zend_llist_add_element(zend_llist *l, void *element)
{
    zend_llist_element *tmp = pemalloc(sizeof(zend_llist_element)+l->size-1, l->persistent);
 
    tmp->prev = l->tail;
    tmp->next = NULL;
    if (l->tail) {
        l->tail->next = tmp;
    } else {
        l->head = tmp;
    }
    l->tail = tmp;
    memcpy(tmp->data, element, l->size);
 
    ++l->count;
}

如方法第一行所示,申請空間是額外申請了l->size - 1的空間。l->size是在鏈表創建時指定的, zend_llist_element結構體最後那個字段的註釋提到這個字段必須放到最後也是這個原因, 例如curl擴展中的例子:zend_llist_init(&(*ch)->to_free->slist, sizeof(struct curl_slist), (llist_dtor_func_t) curl_free_slist, 0);size指的是要插入元素的空間大小,這樣不同的鏈表就可以插入不同大小的元素了。

爲了提高性能增加了鏈表頭和尾節點地址,以及鏈表中元素的個數。

最後的traverse_ptr 字段是爲了方便在遍歷過程中記錄當前鏈表的內部指針, 和哈希表中的:Bucket *pInternalPointer;字段一個作用。

操作接口

操作接口比較簡單,本文不打算介紹接口的使用,這裏簡單說一下PHP源代碼中的一個小的約定,

如下爲基本的鏈表遍歷操作接口:

/* traversal */
ZEND_API void *zend_llist_get_first_ex(zend_llist *l, zend_llist_position *pos);
ZEND_API void *zend_llist_get_last_ex(zend_llist *l, zend_llist_position *pos);
ZEND_API void *zend_llist_get_next_ex(zend_llist *l, zend_llist_position *pos);
ZEND_API void *zend_llist_get_prev_ex(zend_llist *l, zend_llist_position *pos);
 
#define zend_llist_get_first(l) zend_llist_get_first_ex(l, NULL)
#define zend_llist_get_last(l) zend_llist_get_last_ex(l, NULL)
#define zend_llist_get_next(l) zend_llist_get_next_ex(l, NULL)
#define zend_llist_get_prev(l) zend_llist_get_prev_ex(l, NULL)

一般情況下我們遍歷只需要使用後面的那組宏定義函數即可,如果不想要改變鏈表內部指針, 可以主動傳遞當前指針所指向的位置。

PHP中很多的函數都會有*_ex()以及不帶ex兩個版本的函數,這主要是爲了方便使用, 和上面的代碼一樣,ex版本的通常是一個功能較全或者可選參數較多的版本, 而在代碼中很多地方默認的參數值都一樣,爲了方便使用,再封裝一個普通版本。

這裏之所以使用宏而不是定義另一個函數是爲了避免函數調用帶來的消耗, 不過有的情況下還要進行其他的操作,也是會再定義一個新的函數的。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章