PHP內核分析(五)--(哈希表(HashTable))

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

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

哈希表是一種通過哈希函數,將特定的鍵映射到特定值的一種數據結構,它維護鍵和值之間一一對應關係。
鍵(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採用的是一種 治標不治本的做法: 限制用戶提交數據字段數量 這樣可以避免大部分的攻擊,不過應用程序通常會有很多的數據輸入方式,比如,SOAP,REST等等, 比如很多應用都會接受用戶傳入的JSON字符串,在執行json_decode()的時候也可能會遭受攻擊。 所以最根本的解決方法是讓哈希表的碰撞key序列無法輕易的構造,目前PHP中還沒有引入不增加額外的複雜性情況下的完美解決方案。


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

開放尋址法

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


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

哈希表的實現

基本的數據結構主要有兩個, 一個用於保存哈希表本身,另外一個就是用於實際保存數據的單鏈表了,定義如下:

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等開源軟件使用的哈希算法, 有興趣的讀者可以前往參考。


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

哈希表算法

#ifndef _HASH_TABLE_H_
#define _HASH_TABLE_H_ 1

#define HASH_TABLE_INIT_SIZE 6
#define HASH_INDEX(ht, key) (hash_str((key)) % (ht)->size)

#if defined(DEBUG)
#  define LOG_MSG printf
#else
#  define LOG_MSG(...)
#endif

#define SUCCESS 0
#define FAILED -1

typedef struct _Bucket
{
	char *key;
	void *value;
	struct _Bucket *next;
} Bucket;

typedef struct _HashTable
{
	int size;		// 哈希表的大小
	int elem_num;	// 已經保存元素的個數
	Bucket **buckets;
} HashTable;

int hash_init(HashTable *ht);
int hash_lookup(HashTable *ht, char *key, void **result);
int hash_insert(HashTable *ht, char *key, void *value);
int hash_remove(HashTable *ht, char *key);
int hash_destroy(HashTable *ht);
#endif

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "hashtable.h"

static void resize_hash_table_if_needed(HashTable *ht);
static int hash_str(char *key);

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

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

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

int hash_remove(HashTable *ht, char *key)
{
	int index = HASH_INDEX(ht, key);
	Bucket *bucket  = ht->buckets[index];
	Bucket *prev	= NULL;

	if(bucket == NULL) return FAILED;

	// find the right bucket from the link list 
	while(bucket)
	{
		if(strcmp(bucket->key, key) == 0)
		{
			LOG_MSG("[remove]\tkey:(%s) index: %d\n", key, index);

			if(prev == NULL)
			{
				ht->buckets[index] = bucket->next;
			}
			else
			{
				prev->next = bucket->next;
			}
			free(bucket);

			return SUCCESS;
		}

		prev   = bucket;
		bucket = bucket->next;
	}

	LOG_MSG("[remove]\t key:%s not found remove \tfailed\t\n", key);
	return FAILED;
}

int hash_destroy(HashTable *ht)
{
	int i;
	Bucket *cur = NULL;
	Bucket *tmp = NULL;

	for(i=0; i < ht->size; ++i)
	{
		cur = ht->buckets[i];
		while(cur)
		{
			tmp = cur;
			cur = cur->next;
			free(tmp);
		}
	}
	free(ht->buckets);

	return SUCCESS;
}

static int hash_str(char *key)
{
	int hash = 0;

	char *cur = key;

	while(*cur != '\0')
	{
		hash +=	*cur;
		++cur;
	}

	return hash;
}

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

// if the elem_num is almost as large as the capacity of the hashtable
// we need to resize the hashtable to contain enough elements
static void resize_hash_table_if_needed(HashTable *ht)
{
	if(ht->size - ht->elem_num < 1)
	{
		hash_resize(ht);	
	}
}

測試

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include "hashtable.h"

#define TEST(tcase) printf(">>> [START CASE] " tcase "<<<\n")
#define PASS(tcase) printf(">>> [PASSED] " tcase " <<<\n")

int main(int argc, char **argv)
{
	HashTable *ht = (HashTable *)malloc(sizeof(HashTable));
	int result = hash_init(ht);

	assert(result == SUCCESS);

	/* Data */
	int  int1 = 10;
	int  int2 = 20;
	char str1[] = "Hello TIPI";
	char str2[] = "Value";
	/* to find data container */
	int *j = NULL;
	char *find_str = NULL;

	/* Test Key insert */
	TEST("Key insert");
	hash_insert(ht, "KeyInt", &int1);
	hash_insert(ht, "asdfKeyStrass", str1);
	hash_insert(ht, "K13eyStras", str1);
	hash_insert(ht, "KeyStr5", str1);
	hash_insert(ht, "KeyStr", str1);
	PASS("Key insert");

	/* Test key lookup */
	TEST("Key lookup");
	hash_lookup(ht, "KeyInt", (void **)&j);
	hash_lookup(ht, "KeyStr", (void **)&find_str);

	assert(strcmp(find_str, str1) == 0);
	assert(*j = int1);
	PASS("Key lookup");

	/* Test Key update */
	TEST("Test key update");
	hash_insert(ht, "KeyInt", &int2);
	hash_lookup(ht, "KeyInt", (void **)&j);
	assert(*j = int2);
	PASS("Test key update");

	TEST(">>>	 Test key not found		<<< ");
	result = hash_lookup(ht, "non-exits-key", (void **)&j);
	assert(result == FAILED);
	PASS("non-exist-key lookup");

	TEST("Test key not found after remove");
	char strMyKey[] = "My-Key-Value";
	find_str = NULL;
	hash_insert(ht, "My-Key", &strMyKey);
	result = hash_remove(ht, "My-Key");
	assert(result == SUCCESS);

	result = hash_lookup(ht, "My-Key", (void **)&find_str);
	assert(find_str == NULL);
	assert(result == FAILED);
	PASS("Test key not found after remove");

	PASS(">>>	 Test key not found		<<< ");

	TEST("Add many elements and make hashtable rehash");
	hash_insert(ht, "a1", &int2);
	hash_insert(ht, "a2", &int1);
	hash_insert(ht, "a3", &int1);
	hash_insert(ht, "a4", &int1);
	hash_insert(ht, "a5", &int1);
	hash_insert(ht, "a6", &int1);
	hash_insert(ht, "a7", &int1);
	hash_insert(ht, "a8", str2);
	hash_insert(ht, "a9", &int1);
	hash_insert(ht, "a10", &int1);
	hash_insert(ht, "a11", &int1);
	hash_insert(ht, "a12", &int1);
	hash_insert(ht, "a13", &int1);
	hash_insert(ht, "a14", &int1);
	hash_insert(ht, "a15", &int1);
	hash_insert(ht, "a16", &int1);
	hash_insert(ht, "a17", &int1);
	hash_insert(ht, "a18", &int1);
	hash_insert(ht, "a19", &int1);
	hash_insert(ht, "a20", &int1);
	hash_insert(ht, "a21", &int1);
	hash_insert(ht, "a22", &int1);
	hash_insert(ht, "a23", &int1);
	hash_insert(ht, "a24", &int1);
	hash_insert(ht, "a24", &int1);
	hash_insert(ht, "a24", &int1);
	hash_insert(ht, "a25", &int1);
	hash_insert(ht, "a26", &int1);
	hash_insert(ht, "a27", &int1);
	hash_insert(ht, "a28", &int1);
	hash_insert(ht, "a29", &int1);
	hash_insert(ht, "a30", &int1);
	hash_insert(ht, "a31", &int1);
	hash_insert(ht, "a32", &int1);
	hash_insert(ht, "a33", &int1);

	hash_lookup(ht, "a23", (void **)&j);
	assert(*j = int1);
	hash_lookup(ht, "a30", (void **)&j);
	assert(*j = int1);
	PASS("Add many elements and make hashtable rehash");

	hash_destroy(ht);
	free(ht);

	printf("Woohoo, It looks like HashTable works properly\n");

	return 0;
}

PHP的哈希表實現

PHP內核中的哈希表是十分重要的數據結構,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。 這裏使用&操作而不是使用取模,這是因爲是相對來說取模操作的消耗和按位與的操作大很多。

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




發佈了13 篇原創文章 · 獲贊 14 · 訪問量 7萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章