共计 5806 个字符,预计需要花费 15 分钟才能阅读完成。
问题引入
PHP 用的最多的数据类型就是数组了。然而你晓得他的实现原理吗?PHP 数组,相似于咱们 C 语言中学到的索引数组,又相似于咱们学的数据结构哈希表 / 字典;他底层是基于什么数据结构实现的呢?
在解说之前咱们先看这么一个小试验:
<?php
$count = 1000000;
$first = memory_get_usage();
for($i = 0; $i < $count; $i ++) {$packed_array[] = $i;
}
$second = memory_get_usage();
$use1 = $second - $first;
echo "1. memory use: $use1\r\n";
for($i = 0; $i < $count; $i ++) {$hash_array[$count - 1 - $i] = $i;
}
$third = memory_get_usage();
$use2 = $third - $second;
echo "2. memory use: $use2\r\n";
echo "more memory:" . ($use2 - $use1) . "\r\n";
// 试验后果
//1. memory use: 33558608
//2. memory use: 37748848
//more memory: 4190240
两种数组赋值形式,第二个数组占用内存多了 4190240,数组元素数目为 1000000,相当于每个元素多消耗 4 字节。
同样是存储 1000000 个数组元素,两种数组占用内存空间不一样,阐明两种数组的底层实现形式可能也是有差异的。
其实,第一个数组就是咱们所说的索引数组,也称之为 packed array;第二个数组就是咱们所说的关联数组,也称之为 hash array。本文将具体介绍两种数组的实现原理。
谈谈 foreach
PHP 数组的 foreach 遍历,是有序性的;即,遍历的程序和写入的程序是完全一致的。如上面事例所示:
<?php
$arr['a'] = 'a';
$arr['b'] = 'b';
$arr['c'] = 'c';
$arr['d'] = 'd';
foreach ($arr as $val){echo $val;}
思考一下,该个性如何实现呢:1)能够存取 key-val,不就是一个哈希表么,最罕用的就是数组 + 链表实现形式;2)遍历程序与写入程序统一,如同有点难办,如果每个节点再加一个指针呢?依照写入程序再保护一个链表构造呢?如下图所示:
PHP5 就是通过这种形式实现数组 foreach 遍历的有序性。性能是满足了,然而多个指针,就相当于每存储一个元素,就须要额定的 8 字节。PHP7 数组实现形式进行了优化。
PHP7 数组实现
PHP 数组能够分为索引数组和关联数组两大类;接下来咱们逐渐剖析两类数组的实现原理:
索引数组(packed_array)
1)索引数组,即只通过 $array[] = $val 形式复制,这时候数组的索引是主动递增的;数组的 key 也即数组的索引。遍历的有序性呢?依照索引从小到大即可。这和一般数组没有较大区别。
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 数组中存在一些空洞,这些空洞就有些节约了。
当然,并不是所有的跳跃索引,都能这么存储的,比如说:
$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;
};
这样说是不是还有点含糊,请看上面的示意图:
依照 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);