【PHP】每天都在用的数组,你足够了解吗?

问题引入

  PHP用的最多的数据类型就是数组了。但是你知道他的实现原理吗?PHP数组,类似于我们C语言中学到的索引数组,又类似于我们学的数据结构哈希表/字典;他底层是基于什么数据结构实现的呢?

  在讲解之前我们先看这么一个小实验:

  两种数组赋值方式,第二个数组占用内存多了4190240,数组元素数目为1000000,相当于每个元素多耗费4字节。

  同样是存储1000000个数组元素,两种数组占用内存空间不一样,说明两种数组的底层实现方式可能也是有差别的。

  其实,第一个数组就是我们所说的索引数组,也称之为packed array;第二个数组就是我们所说的关联数组,也称之为hash array。本文将详细介绍两种数组的实现原理。

谈谈 foreach

  PHP数组的foreach遍历,是有序性的;即,遍历的顺序和写入的顺序是完全一致的。如下面事例所示:

  思考一下,该特性如何实现呢:1)可以存取key-val,不就是一个哈希表么,最常用的就是数组+链表实现方式;2)遍历顺序与写入顺序一致,好像有点难办,如果每个节点再加一个指针呢?按照写入顺序再维护一个链表结构呢?如下图所示:

【PHP】每天都在用的数组,你足够了解吗?_第1张图片

  PHP5就是通过这种方式实现数组foreach遍历的有序性。功能是满足了,但是多个指针,就相当于每存储一个元素,就需要额外的8字节。PHP7数组实现方式进行了优化。

PHP7数组实现

  PHP数组可以分为索引数组和关联数组两大类;接下来我们逐步分析两类数组的实现原理:

索引数组(packed_array)

  1)索引数组,即只通过$array[] = $val方式复制,这时候数组的索引是自动递增的;数组的key也即数组的索引。遍历的有序性呢?按照索引从小到大即可。这和普通数组没有较大区别。

【PHP】每天都在用的数组,你足够了解吗?_第2张图片

  Bucket结构即为key-val实体:

typedef struct _Bucket {
    zval              val;
    zend_ulong        h;                /* hash value (or numeric index)   */
    zend_string      *key;              /* string key or NULL for numerics */
} Bucket;

  再扩展思考下,如果我们这么对数组赋值呢?

$arr[1] =1;
$arr[3] =3;
$arr[5] =5;
$arr[6] =6;

  索引不是顺序递增,而是跳跃的;这时候还能使用上面的简单存储方式呢?其实也很简单,Bucket的存储也跟着一起跳跃呗,只是这时候Bucket数组中存在一些空洞,这些空洞就有些浪费了。

image

  当然,并不是所有的跳跃索引,都能这么存储的,比如说:

$arr[1] =1;
$arr[1000] =1000;

  难道说Bucket数组的长度至少需要1001?而里面只存储2个元素,其余999都是空洞浪费?这显然是不合适的。这时候PHP数组结构需要适当调整下了。

关联数组(hash_array)

  可以看到,索引跳跃度过高的数组,上面的存储结构就不太合适了;另外,对于关联数组,还有数组的key,并不等于数组索引,还需要支持按照key查找value的功能。

  这时候你可能会说,使用哈希表呗,那不又回到PHP5的实现方式了,额外的指针形成链表。还有没有其他方式呢?比如说参照索引数组的实现,稍微改动下?

  1)有序性遍历:数组元素还是按照索引数组方式,顺序写入;

  2)按照key查找value:每一个数组元素的Bucket索引是确定的(写入顺序决定);那么可以额外提供数组(长度等于Bucket数组长度),存储key-Bucket索引的映射关系。key计算hash值,按照Bucket数组长度取模计算索引,在该位置存储Bucket索引的值;

  3)hash冲突解决:Bucket的值结构为zval,而zval本身就有一个额外字段
next,通过该字段多个Bucket可以形成一个隐式的链表。

struct _zval_struct {
    zend_value        value;            /* value */
    union {
        ……
        uint32_t type_info;
    } u1;
    union {
        uint32_t     next;                 /* hash collision chain */
        ……
    } u2;
};

  这样说是不是还有点模糊,请看下面的示意图:

【PHP】每天都在用的数组,你足够了解吗?_第3张图片

  按照key=a,b,c,d,e,f顺序写入。可以看到,上面的数组存储的是Bucket索引值,通过该值可以在Bucket数组中找到对应的key-value对。另外,key=a/c/e,产生了hash冲突,最终存储的是key=e对应的Bucket索引;通过e.next可以查找到key=c;通过c.next可以查找到key=a。

unset

  如果我们通过unset回收数组元素呢?比如:

$arr[0] =0;
$arr[1] =1;
$arr[2] =2;
$arr[3] =3;

unset($arr[1])

  unset之后,Bucket数组中间存在了一个空洞;显然我们可以通过平移来重新利用这个空洞(空洞后面元素全部向前平移一个位置)。但是想想,有必要这么做吗?复杂度是不是有点高呀,没有必要为了这么一个空洞位置引入这么高的时间复杂度吧?

  那我要是一直unset,Bucket数组中的空洞越来越多,内存空间浪费越来越严重,这时候就值得回收利用这些空洞了,牺牲一点时间复杂度也是值得的。

源码实现

  下面带领大家学习一下PHP7数组的主要实现源码。

基本结构

  PHP7的数组由结构体_zend_array表示,定义如下:

typedef struct _zend_array HashTable;

struct _zend_array {
    ……
    uint32_t          nTableMask;
    Bucket           *arData;
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    uint32_t          nTableSize;
    uint32_t          nInternalPointer;
    zend_long         nNextFreeElement;
};
  • nTableMask:掩码,通常等于-nTableSize;为什么是负数呢?因为存储索引的数组和Bucket数组是一起分配的,Bucket数组从arData地址处,向高地址增长;存储索引的数组从arData地址处,向低地址增长,相当于存储索引的数组访问下标都是负数;
  • arData:Bucket数组;
  • nTableSize:Bucket数组长度;
  • nNumUsed:Bucket数组已使用数目(包括元素被unset造成的空洞),下一个元素写入的位置默认为nNumUsed+1;
  • nNumOfElements:元素数目,不包括unset的元素;
  • nInternalPointer:游标,迭代时使用;
  • nNextFreeElement:用于生成自增的索引,$array[]=$val赋值方式使用;

负掩码

  上一节我们提到nTableMask是掩码,且等于-nTableSize;因为,存储索引的数组和Bucket数组是一起分配的,Bucket数组从arData地址处,向高地址增长;存储索引的数组从arData地址处,向低地址增长,相当于存储索引的数组访问下标都是负数。

  为了方便描述,我们将存储索引的数组成为索引数组,存储真正数据内容的成为Bucket数组。

  PHP数组的初始化函数为zend_hash_real_init_ex,实现如下:

static zend_always_inline void zend_hash_real_init_ex(HashTable *ht, int packed)
{
    (ht)->nTableMask = -(ht)->nTableSize;
    
    //pemalloc分配内存(索引数组+Bucket数组)
    //HT_SET_DATA_ADDR将arData指向首地址偏移(-nTableMask)* sizeof(uint32_t) 位置
    HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT));
    
    //索引数组全部初始化为-1
    HT_HASH_RESET(ht);

}

//arData指向索引数组与Bucket数组临界点
#define HT_SET_DATA_ADDR(ht, ptr) do { \
        (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
    } while (0)
    
//索引数组大小
#define HT_HASH_SIZE(nTableMask) \
    (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))

#define HT_HASH_RESET(ht) \
    memset(&HT_HASH(ht, (ht)->nTableMask), HT_INVALID_IDX, HT_HASH_SIZE((ht)->nTableMask))
    
#define HT_HASH(ht, idx) \
    HT_HASH_EX((ht)->arData, idx)

  PHP内核代码里有非常多的宏定义,这点需要习惯。可以看到,一次性分配内存空间包括hash数组和Bucket数组;而arData指向的是两个数组的临界点;并且hash数组是从高地址到低地址,所以索引只能是负数。

  当往数组添加一个元素时,怎么根据掩码计算索引数组索引值呢?其实很简单,就一行代码:nIndex = h | ht->nTableMask;其中h为哈希值。

  我们以nTableSize=8为例,nTableMask=-8;内存中二进制掩码(不了解的可以去学习下原码,反码,补码的概念)为:

11111111 11111111 11111111 11111000

  任何整数与其进行或运算后,结果正好满足[nTableMask, -1]:

11111111 11111111 11111111 11111000 //-8
……
11111111 11111111 11111111 11111111 //-1

添加/更新元素

  数组元素的添加或者更新需要注意packed_array和hash_array的区别,packed_array并没有前面的索引数组,只有一个Bucket数组,且数组元素的键都是整数。

  packed_array更新逻辑由函数_zend_hash_index_add_or_update_i实现,其实就是存储数据到下一个Bucket数组即可。这里不做过多介绍。

  hash_array更新逻辑由函数_zend_hash_add_or_update_i实现;添加元素之前,首先需要校验数组中是否已经存在当前key:

static zend_always_inline Bucket *zend_hash_find_bucket(const HashTable *ht, zend_string *key)
{
        h = zend_string_hash_val(key);
        arData = ht->arData;
        nIndex = h | ht->nTableMask;
        
        //从索引数组中获取Bucket的下标
        idx = HT_HASH_EX(arData, nIndex);

        //值不等于-1,说明对应Bucket存储有数据
        while (EXPECTED(idx != HT_INVALID_IDX)) {
                //Bucket数组获取元素
                p = HT_HASH_TO_BUCKET_EX(arData, idx);
                if (EXPECTED(p->key == key)) { /* check for the same interned string */
                        return p;
                } else if (EXPECTED(p->h == h) &&
                     EXPECTED(p->key) &&
                     EXPECTED(ZSTR_LEN(p->key) == ZSTR_LEN(key)) &&
                     EXPECTED(memcmp(ZSTR_VAL(p->key), ZSTR_VAL(key), ZSTR_LEN(key)) == 0)) {
                        return p;
                }

                //获取下一个Bucket索引,zval.u2.next
                idx = Z_NEXT(p->val);
        }
        return NULL;
}

  如果查找到key,则需要更新对应的值;否则新添加key-value对;新添加逻辑如下:

idx = ht->nNumUsed++;
ht->nNumOfElements++;

//直接顺序存储再下一个位置
p = ht->arData + idx;
p->key = key;
p->h = h = ZSTR_H(key);
ZVAL_COPY_VALUE(&p->val, pData);

//索引数组当前nIndex处可能已经有值
nIndex = h | ht->nTableMask;
//通过zval.u2.next形成链表
Z_NEXT(p->val) = HT_HASH(ht, nIndex);

//设置索引数组当前nIndex处值为idx位置
HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);

你可能感兴趣的:(php)