PHP数组底层实现
PHP数组在初始化的时候,不必指定大小和存储数据的类型,在赋值的时候可以通过数字索引,也可以通过字符串索引的方式。基于 PHP 数组的强大特性,我们可以轻易实现更加复杂的数据结构,比如栈、队列、列表、集合、字典等。PHP 数组功能之所以如此强大,得益于底层基于散列表实现。
https://www.cnblogs.com/mzhaox/p/11295445.html
https://blog.csdn.net/weixin_34362875/article/details/91465117
https://blog.csdn.net/weixin_43885417/article/details/101118471
数组的语义
本质上,PHP的数组是一个有序的字典,它需要满足以下两个语义:
- PHP数组是一个字典,存储着Key-Value对,通过Key可以快速找到对应的值,键可以是整型的,也可是字符串
- PHP数组是有序的。有序是指元素插入顺序,遍历数组的时候,遍历元素的顺序应该和插入顺序一致,而不是像普通字典一样是随机的。
数组的概念
- Key:键,通过它可以快速检索到对应的value,一般为字符串或数字
- value:值,目标数据。可以是复杂的数据结构。
- bucket:桶,HashTable中用于存储数据的单元,用来存储Key和Value。
- slot:槽,HashTable中有多个槽,一个bucket必须从属于某个slot,一个slot下可以有多个bucket
- hash函数:需要自己实现,计算出某个key的hash值,并根据这个hash值确定所在的slot
- hash冲突:当多个key经过哈希计算后,得出的slot的位置是同一个,那么就叫作哈希冲突。一般解决冲突的方法是链地址法和开放地址法。PHP采用链地址法,将同一个slot中的bucket通过链表链接起来。
在具体实现中,PHP基于上述概念对bucket以及哈希函数进行了补充,增加了hash1函数生成h值,然后通过hash2函数散列到不到的slot,如下图
这个中间h值的作用如下:
- HashTable中key可能是数字,也可能是字符串。所以bucket在设计key的时候,需要做拆分,拆分数字key和字符串key。在上图bucket中,"h"代表数字Key,“Key”代表字符串key。实际上,对于数字Key,hash1函数不做任何处理
- 每个字符串都有一个h值,这个h值可以加快字符串的比较速度,当比较两个字符串是否相等,先比较key1和key2的h值是否相等,如果相等,再去比较字符串的长度以及内容。否则直接判定不相等。
PHP5数组实现
主要有两个结构:bucket(哈希桶)和HashTable(哈希表)
为了实现php数组的两个语义(有序、字典),bucket与HashTable中维护了两种双向链表。
- 全局链表,按插入顺序将所有bucket全部串联起来,整个HashTable中只有一个全局链表
- 局部链表,为了解决哈希冲突,每个slot维护这一个链表,将有哈希冲突的bucket都串联起来
每个bucket都必然在双向链表上
typedef struct bucket {
ulong h; /* 4字节 对char *key进行hash后的值,或者是用户指定的数字索引值/* Used for numeric indexing */
uint nKeyLength; /* 4字节 字符串索引长度,如果是数字索引,则值为0 */
void *pData; /* 4字节 实际数据的存储地址,指向value,一般是用户数据的副本,如果是指针数据,则指向pDataPtr,这里又是个指针,zval存放在别的地方*/
void *pDataPtr; /* 4字节 引用数据的存储地址,如果是指针数据,此值会指向真正的value,同时上面pData会指向此值 */
struct bucket *pListNext; /* 4字节 整个哈希表的该元素的下一个元素*/
struct bucket *pListLast; /* 4字节 整个哈希表的该元素的上一个元素*/
struct bucket *pNext; /* 4字节 同一个槽,双向链表的下一个元素的地址 */
struct bucket *pLast; /* 4字节 同一个槽,双向链表的上一个元素的地址*/
char arKey[1]; /* 1字节 保存当前值所对应的key字符串,这个字段只能定义在最后,实现变长结构体*/
} Bucket;
-
arKey,对应HashTable设计中的key,表示字符串Key
-
pData和pDataptr,pDataptr指向zval结构,pData指向pDataptr
-
pNext和pLast,记录当前bucket的前后bucket
-
pListNext和pListLast,记录全局链表的前一个和后一个bucket
typedef struct _hashtable {
uint nTableSize; /*4 哈希表中slot的槽的数量,初始值为8,每次resize时以2倍速度增长*/
uint nTableMask; /*4 nTableSize-1 ,索引取值的优化 */
uint nNumOfElements; /*4 哈希表中Bucket中当前存在的元素个数,count()函数会直接返回此值*/
ulong nNextFreeElement; /*4 下一个数字索引的位置 */
Bucket *pInternalPointer; /*4 当前遍历的指针(foreach比for快的原因之一) 用于元素遍历*/
Bucket *pListHead; /*4 存储数组头元素指针 */
Bucket *pListTail; /*4 存储数组尾元素指针 */
Bucket **arBuckets; /*4 指针数组,数组中每个元素都是指针,存储hash数组 */
dtor_func_t pDestructor; /*4 在删除元素时执行的回调函数,用于资源的释放 /* persistent 指出了Bucket内存分配的方式。如果persisient为TRUE,则使用操作系统本身的内存分配函数为Bucket分配内存,否则使用PHP的内存分配函数。*/
zend_bool persistent; /*1 */
unsigned char nApplyCount; /*1 标记当前hash Bucket被递归访问的次数(防止多次递归)*/
zend_bool bApplyProtection;/*1 标记当前hash桶允许不允许多次访问,不允许时,最多只能递归3次 */
#if ZEND_DEBUG
int inconsistent; /*4 */
#endif
} HashTable;
-
nTableMask, 总是等于nTableSize - 1, 即2^n - 1,所以nTableSize中每一位都是1。上面提到的hash1函数,转化为h,h值通过hash2函数转化为slot值。 这里的
slot = h & nTableMask
, 进而通过arBuckets[slot]定位到对应的slot的头指针。 -
pListHead, 全局链表的头指针
-
pListTail, 全局链表的尾指针
q
PHP5数组痛点
- 每创建一个bucket都需要一次内存分配
- Key-Value中的value都是zval。这种情况下,每个bucket需要维护指向zval的指针pDataPtr以及指向pDataPtr的指针pData。
- 为了实现有序、字典的特性,bucket需要维护四个指针
这些大量的指针占据了内存空间,使得php5的性能还有上升的空间
但是为了实现有序的hashtable,必然要使用到hashTable + 全局链表,而且使用的是链地址法来解决冲突。
- php5: HashTable + 全局链表 + 局部链表
- php7: HashTable + 数组(内存连续)
PHP7中采用了内存连续的数组来代替链表,这样节省了大量的上下游指针,bucket中只维护下一个bucket在数组中的索引,所以可以快速定位到某个bucket的位置。
PHP7
https://www.cnblogs.com/mzhaox/p/11295445.html
https://liruoning.cn/2020/03/28/60-php%E6%95%B0%E7%BB%84%E7%9A%84%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0/
https://liruoning.cn/2020/03/28/60-php%E6%95%B0%E7%BB%84%E7%9A%84%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0/
Bucket结构
//Bucket:散列表中存储的元素
typedef struct _Bucket {
zval val; //存储的具体value,这里嵌入了一个zval,而不是一个指针
zend_ulong h; //key根据times 33计算得到的哈希值,或者是数值索引编号
zend_string *key; //存储元素的key
} Bucket;
-
zval结构,保存具体的value
-
h,若key是数值类型,则其值就是h的值;若为字符串索引,则其值是key通过 time 33 算法计算得到的散列值。h值用于映射元素在HashTable中arData数组中的位置
-
key,元素的key值
Bucket有三个状态(未使用,有效,无效):
-
未使用:当HashTable初始化时,所有的bucket的默认初始状态
-
有效:存储有效的数据,数据插入时,优先从未使用的bucket中选取
-
无效:当bucket上的数据被删除时,有效bucket就会变为无效bucket。
当bucket中数据被删除时,只是将该元素 Bucket 对应值的类型设为
IS_UNDEF
, 因为如果每次删除元素都要移动数组并重新索引就太浪费时间了。
Zend_Array结构
// 定义结构体别名为 HashTable
typedef struct _zend_array HashTable;
struct _zend_array {
// gc 保存引用计数,内存管理相关;本文不涉及
zend_refcounted_h gc;
// u 储存辅助信息;本文不涉及
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar nApplyCount,
zend_uchar nIteratorsCount,
zend_uchar consistency)
} v;
uint32_t flags;
} u;
// 用于散列函数, 与PHP5不同的是,值为 nTableSize 的负数
uint32_t nTableMask;
// arData 指向储存元素的数组第一个 Bucket,Bucket 为统一的数组元素类型
Bucket *arData;
// 已使用 Bucket 数
uint32_t nNumUsed;
// 数组内有效元素个数
uint32_t nNumOfElements;
// 数组总容量
uint32_t nTableSize;
// 内部指针,用于遍历
uint32_t nInternalPointer;
// 下一个可用数字索引
zend_long nNextFreeElement;
// 析构函数
dtor_func_t pDestructor;
};
-
nNumUsed
和nNumOfElements
的区别:nNumUsed
指的是arData
数组中有效的、无效的Bucket数,而nNumOfElements
对应的是数组中真正的元素个数,即调用 count() 的返回值 -
nTableSize
数组的容量,该值为 2 的幂次方。PHP 的数组是不定长度但 C 语言的数组定长的,为了实现 PHP 的不定长数组的功能,采用了「扩容」的机制,就是在每次插入元素的时候判断nTableSize
是否足以储存。如果不足则重新申请 2 倍nTableSize
大小的新数组,并将原数组复制过来(此时正是清除原数组中类型为IS_UNDEF
元素的时机)并且重新索引。 -
nNextFreeElement
保存下一个可用数字索引,例如在 PHP 中$a[] = 1;
这种用法将插入一个索引为nNextFreeElement
的元素,然后nNextFreeElement
自增 1。 -
pDestructor:当删除或覆盖数组中的某个元素时,如果提供了这个函数句柄,则在删除或覆盖时调用此函数,对旧元素进行清理;
数组访问
哈希表很好的实现了PHP数组的字典特性,而php数组的有序性则是通过在 散列函数 和 散列表 之间添加一层【映射表】来实现。
这个映射表也是一个数组,大小与存储元素的数组大小一致,存储元素的类型为整形,用于保存元素在实际存储的散列表的下标,即元素按照插入的先后顺序插入散列表,然后将其下标按照散列函数计算出来的位置存储在映射表中。
PHP 数组底层结构中并没有显式标识这个中间映射表,而是与 arData 放到了一起,在数组初始化的时候并不仅仅分配用于存储 Bucket 的内存,还会分配相同数量的 uint32_t 大小的空间,这两块空间是一起分配(连续空间)的,然后将 arData 偏移到存储元素数组的位置,而这个中间映射表就可以通过 arData 向前访问到。
随机读
-
使用
time 33
算法对 key 值计算得到hash code
-
使用散列函数计算 hash code 得到散列值
nIndex
,即元素在中间映射表的下标 -
通过 nIndex 从中间映射表中取出元素在 Bucket 中的下标
idx
-
通过 idx 访问 Bucket 中对应的数组元素,该元素同时也是一个
静态链表
的头结点 -
遍历链表,分别判断每个元素中的 key 值是否与我们要查找的 key 值相同
-
如果相同,终止遍历
最优情况下,时间复杂度为 O(1)
顺序读
数组的循环,或者通过current()等一些列函数访问时,就是数组的顺序读。就是直接通过 访问 nInternalPointer
指针来访问Bucket数组
散列函数
PHP 中采用如下方式对 hash code 进行散列:
nIndex = key->h | nTableMask;
因为散列表的大小恒为 2 的幂次方,所以散列后的值会位于 [nTableMask, -1] 之间,即中间映射表之中。
至于为何不用简单的「取余」运算而是费尽周折的采用「按位或」运算?因为「按位或」运算的速度要比「取余」运算要快很多,我觉得对于这种频繁使用的操作来说,复杂一点的实现带来的时间上的优化是值得的。
散列冲突
任何hash函数都会出现哈希冲突的问题,常见的解决哈希冲突的方法有:
- 开放定址法:若有冲突发生,则直接寻找下一个空的散列地址
- 链地址法:哈希表节点中设置有next指针,最终形成一个链表
- 重哈希法:有多个不同的hash函数,当发生冲突时,按顺序调用不同的hash函数,直至不冲突
- 溢出表:建立公共的溢出表,当发生冲突时,直接把冲突元素放入溢出表
PHP采用的是链地址法,将冲突的Bucket连成一个链表。中间映射表映射的不一定是某个元素,而是一个Bucket链表。
PHP将链表的指针转为数值指向,即:指向冲突元素的指针并没有直接存在Bucket中,而是保存到了value的zval
中:
struct _zval_struct {
zend_value value; /* value */
...
union {
uint32_t var_flags;
uint32_t next; /* hash collision chain */
uint32_t cache_slot; /* literal cache slot */
uint32_t lineno; /* line number (for ast nodes) */
uint32_t num_args; /* arguments number for EX(This) */
uint32_t fe_pos; /* foreach position */
uint32_t fe_iter_idx; /* foreach iterator index */
} u2;
};
当出现冲突时将原value的位置保存到新value的zval.u2.next
中,然后将新插入的value的位置更新到散列表,也就是后面冲突的value始终插入header。
拓容
散列表中可存储的value是固定的,当空间不够用的时候就要进行拓容。当插入时如果容量不够的时候,则首先检查已删除元素的比例。
-
如果达到阈值(ht->nNumUsed - ht->nNumOfElements > (ht->nNumOfElements >> 5) 即
所有Bucket - 有效Bucket > 有效Bucket / 32
,则将已删除元素移除,重建索引,以此来节省内存的空间 -
如果未到阈值则进行扩容操作,扩大为当前大小的2倍,将当前Bucket数组复制到新的空间,然后重建索引。
//zend_hash.c
static void ZEND_FASTCALL zend_hash_do_resize(HashTable *ht)
{
if (ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)) {
//只有到一定阈值才进行rehash操作
zend_hash_rehash(ht); //重建索引数组
} else if (ht->nTableSize < HT_MAX_SIZE) {
//扩容
void *new_data, *old_data = HT_GET_DATA_ADDR(ht);
//扩大为2倍,加法要比乘法快,小的优化点无处不在...
uint32_t nSize = ht->nTableSize + ht->nTableSize;
Bucket *old_buckets = ht->arData;
//新分配arData空间,大小为:(sizeof(Bucket) + sizeof(uint32_t)) * nSize
new_data = pemalloc(HT_SIZE_EX(nSize, -nSize), ...);
ht->nTableSize = nSize;
ht->nTableMask = -ht->nTableSize;
//将arData指针偏移到Bucket数组起始位置
HT_SET_DATA_ADDR(ht, new_data);
//将旧的Bucket数组拷到新空间
memcpy(ht->arData, old_buckets, sizeof(Bucket) * ht->nNumUsed);
//释放旧空间
pefree(old_data, ht->u.flags & HASH_FLAG_PERSISTENT);
//重建索引数组:散列表
zend_hash_rehash(ht);
...
}
...
}
#define HT_SET_DATA_ADDR(ht, ptr) do { \
(ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
} while (0)
hash表重建
当删除的元素达到一定的数量或者扩容后都需要重建散列表,因为value在Bucket位置移动了或哈希数组nTableSize变化了导致key与value的映射关系改变,重建过程实际就是遍历Bucket数组中的value,然后重新计算映射值更新到散列表,当然我们也需要移除已删除的Bucket
具体的删除方法就是:将后面未删除的value依次前移。
//zend_hash.c
ZEND_API int ZEND_FASTCALL zend_hash_rehash(HashTable *ht)
{
Bucket *p;
uint32_t nIndex, i;
...
i = 0;
p = ht->arData;
if (ht->nNumUsed == ht->nNumOfElements) { //没有已删除的直接遍历Bucket数组重新插入索引数组即可
do {
// 无须再用Time33再计算一次hash值,直接复用h值即可
nIndex = p->h | ht->nTableMask;
Z_NEXT(p->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
p++;
} while (++i < ht->nNumUsed);
} else {
do {
if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {
//有已删除元素则将后面的value依次前移,压实Bucket数组
......
while (++i < ht->nNumUsed) {
p++;
if (EXPECTED(Z_TYPE_INFO(p->val) != IS_UNDEF)) {
ZVAL_COPY_VALUE(&q->val, &p->val);
q->h = p->h;
nIndex = q->h | ht->nTableMask;
q->key = p->key;
Z_NEXT(q->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(j);
if (UNEXPECTED(ht->nInternalPointer == i)) {
ht->nInternalPointer = j;
}
q++;
j++;
}
}
......
ht->nNumUsed = j;
break;
}
nIndex = p->h | ht->nTableMask;
Z_NEXT(p->val) = HT_HASH(ht, nIndex);
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(i);
p++;
}while(++i < ht->nNumUsed);
}
}