数组是PHP中非常强大、灵活的一种数据类型,它的底层实现为散列表(HashTable,也称作:哈希表)
散列表是根据关键码值(Key value)而直接进行访问的数据结构,它的key - value之间存在一个映射函数,可以根据key通过映射函数直接索引到对应的value值,它不以关键字的比较为基本操作,采用直接寻址技术(就是说,它是直接通过key映射到内存地址上去的),从而加快查找速度,在理想情况下,无须任何比较就可以找到待查关键字,查找的期望时间为O(1)。
存放记录的数组称做散列表,这个数组用来存储value,而value具体在数组中的存储位置由映射函数根据key计算确定,映射函数可以采用取模的方式,key可以通过一些譬如“times 33”的算法得到一个整形值,然后与数组总大小取模得到在散列表中的存储位置。这是一个普通散列表的实现,PHP散列表的实现整体也是这个思路,只是有几个特殊的地方,下面就是PHP中HashTable的数据结构:
1 struct _zend_array { 2 zend_refcounted_h gc; //引用计数 3 union { 4 struct { 5 ZEND_ENDIAN_LOHI_4( 6 zend_uchar flags, 7 zend_uchar nApplyCount, 8 zend_uchar nIteratorsCount, 9 zend_uchar consistency) 10 } v; 11 uint32_t flags; 12 } u; 13 uint32_t nTableMask; //哈希值计算掩码,等于nTableSize的负值(nTableMask = -nTableSize) 14 Bucket *arData; //存储元素数组,指向第一个Bucket 15 uint32_t nNumUsed; //已用Bucket数 16 uint32_t nNumOfElements; //哈希表有效元素数 17 uint32_t nTableSize; //哈希表总大小,为2的n次方 18 uint32_t nInternalPointer; 19 zend_long nNextFreeElement; //下一个可用的数值索引,如:arr[] = 1;arr["a"] = 2;arr[] = 3; 则nNextFreeElement = 2; 20 dtor_func_t pDestructor; 21 };
HashTable中有两个非常相近的值:nNumUsed
、nNumOfElements
,nNumOfElements
表示哈希表已有元素数,那这个值不跟nNumUsed
一样吗?为什么要定义两个呢?实际上它们有不同的含义,当将一个元素从哈希表删除时并不会将对应的Bucket移除,而是将Bucket存储的zval修改为IS_UNDEF
,只有扩容时发现nNumOfElements与nNumUsed相差达到一定数量(这个数量是:ht->nNumUsed - ht->nNumOfElements > (ht->nNumOfElements >> 5)
)时才会将已删除的元素全部移除,重新构建哈希表。所以nNumUsed
>=nNumOfElements
HashTable中另外一个非常重要的值arData
,这个值指向存储元素数组的第一个Bucket,插入元素时按顺序 依次插入 数组,比如第一个元素在arData[0]、第二个在arData[1]...arData[nNumUsed]。PHP数组的有序性正是通过arData
保证的,这是第一个与普通散列表实现不同的地方。
既然arData并不是按key映射的散列表,那么映射函数是如何将key与arData中的value建立映射关系的呢?
实际上这个散列表也在arData
中,比较特别的是散列表在ht->arData内存之前,分配内存时这个散列表与Bucket数组一起分配,arData向后移动到了Bucket数组的起始位置,并不是申请内存的起始位置,这样散列表可以由arData指针向前移动访问到,即arData[-1]、arData[-2]、arData[-3]......散列表的结构是uint32_t
,它保存的是value在Bucket数组中的位置。
所以,整体来看HashTable主要依赖arData实现元素的存储、索引。插入一个元素时先将元素按先后顺序插入Bucket数组,位置是idx,再根据key的哈希值映射到散列表中的某个位置nIndex,将idx存入这个位置;查找时先在散列表中映射到nIndex,得到value在Bucket数组的位置idx,再从Bucket数组中取出元素。
映射函数(即:散列函数)是散列表的关键部分,它将key与value建立映射关系,一般映射函数可以根据key的哈希值与Bucket数组大小取模得到,即key->h % ht->nTableSize
,但是PHP却不是这么做的:
nIndex = key->h | ht->nTableMask;
显然位运算要比取模更快。
nTableMask
为nTableSize
的负数,即:nTableMask = -nTableSize
,因为nTableSize
等于2^n,所以nTableMask
二进制位右侧全部为0,也就保证了nIndex落在数组索引的范围之内(|nIndex| <= nTableSize
):
哈希碰撞是指不同的key可能计算得到相同的哈希值(数值索引的哈希值直接就是数值本身),但是这些值又需要插入同一个散列表。一般解决方法是将Bucket串成链表,查找时遍历链表比较key。
PHP的实现也是如此,只是将链表的指针指向转化为了数值指向,即:指向冲突元素的指针并没有直接存在Bucket中,而是保存到了value的zval
中:
1 struct _zval_struct { 2 zend_value value; /* value */ 3 ... 4 union { 5 uint32_t var_flags; 6 uint32_t next; /* hash collision chain(哈希碰撞链) */ 7 uint32_t cache_slot; /* literal cache slot */ 8 uint32_t lineno; /* line number (for ast nodes) */ 9 uint32_t num_args; /* arguments number for EX(This) */ 10 uint32_t fe_pos; /* foreach position */ 11 uint32_t fe_iter_idx; /* foreach iterator index */ 12 } u2; 13 };
当出现冲突时将原value的位置保存到新value的zval.u2.next
中,然后将新插入的value的位置更新到散列表,也就是后面冲突的value始终插入header
数组中存储元素的结构
1 typedef struct _Bucket { 2 zval val; //存储的具体value,这里嵌入了一个zval,而不是一个指针 3 zend_ulong h; //key根据times 33计算得到的哈希值,或者是数值索引编号 4 zend_string *key; //存储元素的key 5 } Bucket;