PHP-数组实现

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数组是有序的。有序是指元素插入顺序,遍历数组的时候,遍历元素的顺序应该和插入顺序一致,而不是像普通字典一样是随机的。

数组的概念

image-20210125151550425

  • 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,如下图

image-20210125173911220

这个中间h值的作用如下:

  1. HashTable中key可能是数字,也可能是字符串。所以bucket在设计key的时候,需要做拆分,拆分数字key和字符串key。在上图bucket中,"h"代表数字Key,“Key”代表字符串key。实际上,对于数字Key,hash1函数不做任何处理
  2. 每个字符串都有一个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数组痛点

  1. 每创建一个bucket都需要一次内存分配
  2. Key-Value中的value都是zval。这种情况下,每个bucket需要维护指向zval的指针pDataPtr以及指向pDataPtr的指针pData。
  3. 为了实现有序、字典的特性,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/

image-20210127192614454

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

  • nNumUsednNumOfElements 的区别: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数组的有序性则是通过在 散列函数 和 散列表 之间添加一层【映射表】来实现。

这个映射表也是一个数组,大小与存储元素的数组大小一致,存储元素的类型为整形,用于保存元素在实际存储的散列表的下标,即元素按照插入的先后顺序插入散列表,然后将其下标按照散列函数计算出来的位置存储在映射表中。

image-20210127211121257

​ PHP 数组底层结构中并没有显式标识这个中间映射表,而是与 arData 放到了一起,在数组初始化的时候并不仅仅分配用于存储 Bucket 的内存,还会分配相同数量的 uint32_t 大小的空间,这两块空间是一起分配(连续空间)的,然后将 arData 偏移到存储元素数组的位置,而这个中间映射表就可以通过 arData 向前访问到。

随机读
  1. 使用 time 33 算法对 key 值计算得到 hash code

  2. 使用散列函数计算 hash code 得到散列值 nIndex,即元素在中间映射表的下标

  3. 通过 nIndex 从中间映射表中取出元素在 Bucket 中的下标 idx

  4. 通过 idx 访问 Bucket 中对应的数组元素,该元素同时也是一个静态链表的头结点

  5. 遍历链表,分别判断每个元素中的 key 值是否与我们要查找的 key 值相同

  6. 如果相同,终止遍历

最优情况下,时间复杂度为 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);
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值