1.什么是哈希表
哈希表(或散列表),是将键名key按指定的散列函数HASH经过HASH(key)计算后映射到表中一个记录,而这个数组就是哈希表。
这里的HASH指任意的函数,例如MD5、CRC32、SHA1或你自定义的函数实现。
1.1、HashTable性能
HashTable是一种查找性能极高的数据结构,在很多语言内部都实现了HashTable。
理想情况下HashTable的性能是O(1)的,性能消耗主要集中在散列函数HASH(key),通过HASH(key)直接定位到表中的记录。
而在实际情况下经常会发生key1 != key2,但HASH(key1) = HASH(key2),这种情况即Hash碰撞问题,碰撞的概率越低HashTable的性能越好。当然Hash算法太过复杂也会影响HashTable性能。
2.PHP实现HashTable主要是通过两个数据结构Bucket(桶)和zend_array(hashtable)。
首先结构体zval的联合体zvalue中 是zend_array *ht类型,zend_array里的Bucket结构。从PHP脚本端来看,HashTable相当于Array对象,而Bucket相当于Array对象里的某个元素。对于多维数组实际就是HashTable的某个Bucket里存储着另一个HashTable。
HashTable结构:
typedef struct _hashtable {
uint nTableSize; //表长度,并非元素个数
uint nTableMask;//表的掩码,始终等于nTableSize-1
uint nNumOfElements;//存储的元素个数
ulong nNextFreeElement;//指向下一个空的元素位置
Bucket *pInternalPointer;//foreach循环时,用来记录当前遍历到的元素位置
Bucket *pListHead;
Bucket *pListTail;
Bucket **arBuckets;//存储的元素数组
dtor_func_t pDestructor;//析构函数
zend_bool persistent;//是否持久保存。从这可以发现,PHP数组是可以实现持久保存在内存中的,而无需每次请求都重新加载。
unsigned char nApplyCount;
zend_bool bApplyProtection;
} HashTable;
Bucket结构:
typedef struct bucket {
ulong h; //数组索引
uint nKeyLength; //字符串索引的长度
void *pData; //实际数据的存储地址
void *pDataPtr; //引入的数据存储地址
struct bucket *pListNext;
struct bucket *pListLast;
struct bucket *pNext; //双向链表的下一个元素的地址
struct bucket *pLast;//双向链表的下一个元素地址
char arKey[1]; /* Must be last element */
} Bucket;
2.2、理解PHP的哈希表实现
在PHP内核也同样实现了HashTable并广泛应用,包括线程安全、全局变量、资源管理等基本上所有的地方都能看到它的身影。
不仅如此,在PHP脚本中数组(PHP的数组实质就是HashTable)也是被广泛使用的,例如数组形式的配置文件、数据库的查询结果等,可以说是无处不在。
那么既然PHP的数组使用率这么高,内部是如何实现的?它如何解决hash碰撞及实现均匀分布的?PHP脚本使用数组应该注意哪些?
PHP解决Hahs冲突时,使用的是双向链表。
3.内核中如何实现均匀分布和解决hash碰撞问题的
3.1) 均匀分布
均匀分布是指,将需要存储的各个元素均匀的分布到HashTable中。
而负责计算具体分布到表中哪个位置的函数就是散列函数做的事情,所以散列函数的实现直接关系到均匀分布的效率。
上面也提到了PHP内核中用了简单的方式实现:h & ht->nTableMask,其中h代表变量key经过hash算法得到的索引号,nTableMask等于(哈希表分配的长度-1),提高了均匀分布的效率。(均匀分布的实现方法)。
这里默认的是packed_array 分配的哈希表长度为-2 hash_array分配的哈希表长度为-8。
4.Hash碰撞是指,经过Hash算法后得到的值会出现key1 != key2, 但Hash(key1)却等于Hash(key2)的情况,这就是碰撞问题。
在PHP内核来看,就是会出现key1 != key2, 但hash(key1) & ht->nTableMask却等于 hash(key2) & ht->nTableMask的情况。
PHP内核使用双向链表的方式来存储冲突的数据。即Bucket本身也是一个双向链表,当发生冲突时,会将数据按顺序向后排列。使用bucket里的 next来标识冲突之前的元素位置。
如果不发生冲突,Bucket即是长度为1的的双向链表。
发生碰撞时,bucket转换成双向链表,指针分别为
struct bucket pNext; / 指向具有同一个hash值的桶列的后一个元素 */
struct bucket pLast; / 指向具有同一个hash值的桶列的前一个元素 */
ZEND_API int zend_hash_find(const HashTable *ht, const char *arKey, uint nKeyLength, void **pData)
{
ulong h;
uint nIndex;
Bucket *p;
IS_CONSISTENT(ht);
h = zend_inline_hash_func(arKey, nKeyLength);
nIndex = h & ht->nTableMask;
p = ht->arBuckets[nIndex];
//找到元素时,并非立即返回,而是要再对比h与nKeyLength,防止hash碰撞。此段代码就是遍历链表,直到链表尾部。
while (p != NULL) {
if ((p->h == h) && (p->nKeyLength == nKeyLength)) {
if (!memcmp(p->arKey, arKey, nKeyLength)) {
*pData = p->pData;
return SUCCESS;
}
}
p = p->pNext;
}
return FAILURE;
}
4.1在PHP中初始化一个空数组时,对应内核中是如何创建HashTable的
$array = new Array();
//省略了部分代码,提出主要的逻辑
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;
Bucket **tmp;
SET_INCONSISTENT(HT_OK);
if (nSize >= 0x80000000) {//数组的最大长度是十进制2147483648
/* prevent overflow */
ht->nTableSize = 0x80000000;
} else {
//数组的长度是向2的整次幂取圆整
//例如数组的里有10个元素,那么实际被分配的HashTable长度是16。100个元素,则被分配128的长度
//HashTable的最小长度是8,而非0。因为默认是将1向右移3位,1<<3=8
while ((1U << i) < nSize) {
i++;
}
ht->nTableSize = 1 << i;
}
ht->nTableMask = ht->nTableSize - 1;
....
return SUCCESS;
}
从上看出,即使在PHP中初始化一个空数组或不足8个元素的数组,都会被创建8个长度的HashTable。同样创建100个元素的数组,也会被分配128长度的HashTable。依次类推。
5.PHP数组中,键名可以为数字或字符串类型。而在内核中只允许数字索引,对于字符串索引,内核采用了time33算法将字符串转换为整型。
与数字索引相比,只是多了一步将字符串转换为整型。用到的算法是time33,就是对字符串的每个字符转换为ASCII码乘上33并且相加得到的结果。(索引数组与关联数组的区别) hash_array形式
6.链表散列中为什么使用双向链表?
一般的链表散列只需要按key进行操作,只需要单链表就够了。但是,Zend有时需要从链表散列中删除给定的Bucket,使用双链表可以非常高效的实现,避免遍历。O(1)操作
7.nTableMask是干什么的?
这个值用于hash值到arBuckets数组下标的转换。当初始化一个HashTable,Zend首先为arBuckets数组分配nTableSize大小的内存,nTableSize取不小于用户指定大小的最小的2^n,即二进制的10。nTableMask = nTableSize – 1,即二进制的01,此时h & nTableMask就恰好落在 [0, nTableSize – 1] 里,Zend就以其为index来访问arBuckets数组。