PHP数组使用HashTable实现,因此,先从HashTable开始
源码对应的PHP版本为7.2
HashTable示意图如下:
bucket:桶,HashTable中存储数据的单元,用来存储Key和Value以及其他辅助信息
slot:槽,HashTable有很多槽,通过散列函数,每个Key都能映射到一个槽中
一个bucket必须从属某个slot,一个slot下可以有多个bucket,即php数组不同的key可能会被映射到相同的slot下,这被称为哈希冲突,php采用链地址法解决哈希冲突,即将同一个slot中的bucket通过链表连接起来。
php对原始的HashTable结构进行了封装,通过两次散列将key映射到一个slot下,并且在bucket中处理存储key-value外,还存储了key经过第一次散列后的h值,示意图如下:
_zend_array结构体源码位于Zend/zend_types.h
数组API位于Zend/zend_hash.h
//Zend/zend_types.h
typedef struct _zend_array HashTable;
typedef struct _zend_refcounted_h {
uint32_t refcount; /* reference counter 32-bit */
union {
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type,
zend_uchar flags, /* used for strings & objects */
uint16_t gc_info) /* keeps GC root number (or 0) and color */
} v;
uint32_t type_info;
} u;
} zend_refcounted_h;
struct _zend_array {
zend_refcounted_h gc; //引用计数相关
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags, //标志
zend_ucharn ApplyCount, //用于递归遍历计数
zend_uchar nIteratorsCount, //迭代器计数
zend_uchar consistency) //用于调试,只在调试版本中有效
} v;
uint32_t flags;
} u;
uint32_t nTableMask; //掩码,用于索引数组,一般为-nTableSize
Bucket *arData; //指向储存元素的数组第一个Bucket地址(即数组首地址)
uint32_t nNumUsed; //已使用Bucket数
uint32_t nNumOfElements; //真正使用的数组空间数,如$ [1]=100,nNumUsed值为2,nNumOfElements值为1
uint32_t nTableSize; //Bucket的数量,初始值为8,扩容的时候,每次增长为原空间的2倍。改值始终是2的n次方,在64位系统上,最大值是2的31次方
uint32_t nInternalPointer; //HashTable全局默认游标,维护正在遍历的bucket数组中的下标
zend_long nNextFreeElement; //下一个可用数字索引,用于直接赋值没有k的数组中k的计算,即$a[]=100这种赋值方式
dtor_func_t pDestructor; //析构函数,当bucket元素被更新或者被删除时,会调用该函数
};
/*
* HashTable Data Layout
* =====================
*
* +=============================+
* | HT_HASH(ht, ht->nTableMask) |
* | ... |
* | HT_HASH(ht, -1) |
* +-----------------------------+
* ht->arData ---> | Bucket[0] |
* | ... |
* | Bucket[ht->nTableSize-1]|
* +=============================+
*/
/*
//u.flags
#define HASH_FLAG_PERSISTENT (1<<0) //永久存储,直接向系统申请内存
#define HASH_FLAG_APPLY_PROTECTION (1<<1) //递归保护
#define HASH_FLAG_PACKED (1<<2) //数值索引数组
#define HASH_FLAG_INITIALIZED (1<<3) //已初始化
#define HASH_FLAG_STATIC_KEYS (1<<4) //标记HashTable的Key是否为long key或者内部字符串key
#define HASH_FLAG_HAS_EMPTY_IND (1<<5) //是否有空内部字符串
#define HASH_FLAG_ALLOW_COW_VIOLATION (1<<6) //debug用于哈希表的断言检测(PHP7.2新增)
*/
Bucket结构体:
typedef struct _Bucket {
// 数组元素的值
zval val;
// key 通过 Time 33 算法计算得到的哈希值或数字索引
zend_ulong h;
// 字符键名,数字索引则为 NULL
zend_string *key;
} Bucket;
nNumUsed 和 nNumOfElements 的区别:
nNumUsed 指的是 arData 数组中已使用的 Bucket 数,因为数组在删除元素后只是将该元素 Bucket 对应值的类型设置为 IS_UNDEF (因为如果每次删除元素都要将数组移动并重新索引太浪费时间),而 nNumOfElements 对应的是数组中真正的元素个数。
nTableSize 数组的容量,该值为 2 的幂次方。PHP 的数组是不定长度但 C 语言的数组定长的,为了实现 PHP 的不定长数组的功能,采用了「扩容」的机制,就是在每次插入元素的时候判断 nTableSize 是否足以储存。如果不足则重新申请 2 倍 nTableSize 大小的新数组,并将原数组复制过来(此时正是清除原数组中类型为 IS_UNDEF 元素的时机)并且重新索引。
nNextFreeElement 保存下一个可用数字索引,例如在 PHP 中 $a[] = 1; 这种用法将插入一个索引为 nNextFreeElement 的元素,然后 nNextFreeElement 自增 1。
packed array
hash array
举例
//packet array
$a = array(1=>"a", 3=>"b", 5=>"c");
//hash array,键非递增
$b = array(1=>"a", 5=>"c", 3=>"b");
//键之间的差值大,浪费空间
$c = array(1=>"a", 8=>"c");
这是底层的实现,对于我们写php代码,需要关注的点是对于业务中的大数组,有没有可能设计一些算法,让它满足packed array的性质,这样可以节省内存; 另外一方面就是要关注在大数组的情况下,可能会发声packed array向 hash array的转变,这个耗时还是较大的,需要尽量避免这种情况。 当然这两种情况都是针对“大”数组,小数组的情况下,其实差距没那么大。
PHP 数组是基于哈希表实现的,而与一般哈希表不同的是 PHP 的数组还实现了元素的有序性,就是插入的元素从内存上来看是连续的而不是乱序的,为了实现这个有序性 PHP 采用了「映射表」技术。此外,PHP采用链地址法解决哈希冲突,还记得在《(四)PHP7 zval源码解读》这篇文章中zval结构体源码中的u2.next吗,这个属性就是用来实现链地址法的。
下图中映射表(即索引数组)数组是在bucket数组前面额外申请的空间,数组中的每个元素代表一个slot,存放bucket数组的下标,如果当前slot没有任何bucket元素,则其值为-1。
下面就通过图例说明我们是如何访问 PHP 数组的元素。
注意:因为键名到映射表下标经过了两次散列运算,为了区分本文用哈希特指第一次散列,散列即为第二次散列。
映射表和数组元素在同一片连续的内存中,映射表是一个长度与存储元素相同的整型数组,它默认值为 -1 ,有效值为 Bucket 数组的下标。而 HashTable->arData 指向的是这片内存中 Bucket 数组的首地址。
举个例子 $a[‘key’] 访问数组 $a 中键名为 key 的成员,流程介绍:首先通过 Time 33 算法计算出 key 的哈希值,然后通过散列算法计算出该哈希值对应的映射表下标,因为映射表中保存的值就是 Bucket 数组中的下标值,所以就能获取到 Bucket 数组中对应的元素。
packed array的查找和插入都无需用到索引数组,直接计算出地址值取对于的元素,因为packed array $a[0],0就代表了元素在bucket数组中的下标,此外,0就存储在bucket.h上,不用再存到bucket.key上,这点和hash array一样,bucket.key只存储字符串的key,key我整数时不会冗余存储。
nIndex = h | ht->nTableMask;
将哈希值和 nTableMask 进行或运算即可得出映射表的下标,其中 nTableMask 数值为 nTableSize 的负数。并且由于 nTableSize 的值为 2 的幂次方,所以 h | ht->nTableMask 的取值范围在 [-nTableSize, -1] 之间,正好在映射表的下标范围内。至于为何不用简单的「取余」运算而是费尽周折的采用「按位或」运算?因为「按位或」运算的速度要比「取余」运算要快很多。
不同键名的哈希值通过散列计算得到的「映射表」下标有可能相同,此时便发生了散列冲突。对于这种情况 PHP 使用了「链地址法」解决。下图是访问发生散列冲突的元素的情况:
在上上个图中,根据上述Time 33算法和散列算法之后,得到映射表下标为-2,bucket中当前可用的下标为0,因此将索引表-2位置的值赋为0,bucket[0]就是新插入的元素;
现在,在上个图中,如果又插入一个新元素,根据key计算出的映射表下标还是-2,这里解决冲突的办法是:bucket当前可用的下标为1,因此把映射表-2位置的值改为1,把新的元素放入bucket [ 1 ] 中,同时把新元素在bucket中的zval里面的u2.next的值置为0。关于next值戳这里 《(四)PHP7 zval源码解读》
因此,访问数组的时候,首先通过散列运算得出映射表下标为 -2 ,取到bucket[1],校验key不同,会取u2.next对应的0,然后取bucket[0],校验key相同,则返回。
如果数组的元素都是常量表达式,则数组的初始化发送在编译阶段,即opcode转换为机器码的过程中。初始化之后的数组在执行阶段作为数组常量被赋值给其他变量。
如果数组的元素不是常量表达式,则数组的初始化过程是在执行阶段才进行的。
以$a = array();举例简述数组初始化的过程:
第1步:
申请内存
HashTable *new_ht = emalloc(sizeof(HashTable));
第2步:
调用zend_hash_init()函数对HashTable结构体中的一些元素进行初始化,
/**
* @description: 初始化哈希表结构体
* @param HashTable* ht 哈希表指针
* @param uint32_t nSize 哈希表大小
* @param dtor_func_t pDestructor 哈希表析构函数
* @zend_bool persistent 是否永久存储
* @return:
*/
ZEND_API void ZEND_FASTCALL _zend_hash_init(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, zend_bool persistent ZEND_FILE_LINE_DC)
{
GC_REFCOUNT(ht) = 1; //设置引用计数
GC_TYPE_INFO(ht) = IS_ARRAY | (persistent ? 0 : (GC_COLLECTABLE << GC_FLAGS_SHIFT)); //类别设置为数组(7)
//persistent 是否经过内存池分配内存
ht->u.flags = (persistent ? HASH_FLAG_PERSISTENT : 0) | HASH_FLAG_APPLY_PROTECTION | HASH_FLAG_STATIC_KEYS;
//掩码,默认值是-2,即默认为packed array
ht->nTableMask = HT_MIN_MASK;
//ptr偏移到arrData的首地址
HT_SET_DATA_ADDR(ht, &uninitialized_bucket);
ht->nNumUsed = 0;
ht->nNumOfElements = 0;
ht->nInternalPointer = HT_INVALID_IDX;
ht->nNextFreeElement = 0;
ht->pDestructor = pDestructor;
//能包含nSize的最小2^n的数字最小值
ht->nTableSize = zend_hash_check_size(nSize);
}
这里重点把HT_SET_DATA_ADDR函数定义贴出来,可以看到并没有为bucket分配内存,只是将arData指针指向了索引表的尾地址处,也刚好是bucket数组的首地址处。因为PHP7采用了惰性思想,只有当真正使用bucket时才去申请内存
//哈希表数据指针指向数据区第一个位置
#define HT_SET_DATA_ADDR(ht, ptr) do { \
(ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)->nTableMask)); \
} while (0)
初始化后的packed array的HashTable如下所示:
如果是$arr[ ] = ‘foo’;这样的写法,数组的初始化过程又是怎样的呢?
第1步:
申请内存,调用zend_hash_init()函数对HashTable结构体中的一些元素进行初始化(和上例相同)
第2步:
调用_zend_hash_next_index_insert函数:
/**
* @description 向数值索引哈希表的尾部插入数据
* @param HashTable* ht 待操作的哈希表
* @param zval* pData 待保存的数据
* @return: zval*
*/
ZEND_API zval* ZEND_FASTCALL _zend_hash_next_index_insert(HashTable *ht, zval *pData ZEND_FILE_LINE_DC)
{
return _zend_hash_index_add_or_update_i(ht, ht->nNextFreeElement, pData, HASH_ADD | HASH_ADD_NEXT ZEND_FILE_LINE_RELAY_CC);
}
第2.1步:
在_zend_hash_index_add_or_update_i函数中,会检查哈希表是否初始化,对于我们的例子中,哈希表是未初始化的,因此会转到zend_hash_real_init_ex函数对哈希表进行初始化。
第2.2步:
调用zend_hash_real_init_ex函数初始化bucket:
/**
* @description: 哈希表真正的初始化操作,初始化bucket
* @param HashTable* ht 哈希表指针
* @param int packed 是否为packed array
* @return: void
*/
static zend_always_inline void zend_hash_real_init_ex(HashTable *ht, int packed)
{
HT_ASSERT_RC1(ht);
ZEND_ASSERT(!((ht)->u.flags & HASH_FLAG_INITIALIZED)); //断言是否初始化
//索引数组
if (packed) {
HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT)); //初始化bucket,并将位置指向第一个bucket
(ht)->u.flags |= HASH_FLAG_INITIALIZED | HASH_FLAG_PACKED; //标记哈希表已初始化并且为索引数组
HT_HASH_RESET_PACKED(ht); //将索引数组的值设置为-1(这里索引数组的大小为2)
} else {
//枚举数组
(ht)->nTableMask = -(ht)->nTableSize; //掩码的值总是哈希表大小的相反数,即 -(ht)->nTableSize
HT_SET_DATA_ADDR(ht, pemalloc(HT_SIZE(ht), (ht)->u.flags & HASH_FLAG_PERSISTENT)); //初始化bucket,并将位置指向第一个bucket
(ht)->u.flags |= HASH_FLAG_INITIALIZED; //标记哈希表为已经初始化
//如果nTableMask = -8,则将bucket的索引值设置为-1
if (EXPECTED(ht->nTableMask == (uint32_t)-8)) {
Bucket *arData = ht->arData;
HT_HASH_EX(arData, -8) = -1;
HT_HASH_EX(arData, -7) = -1;
HT_HASH_EX(arData, -6) = -1;
HT_HASH_EX(arData, -5) = -1;
HT_HASH_EX(arData, -4) = -1;
HT_HASH_EX(arData, -3) = -1;
HT_HASH_EX(arData, -2) = -1;
HT_HASH_EX(arData, -1) = -1;
} else {
//如果不等于-8,将所有索引值设置为-1
HT_HASH_RESET(ht);
}
}
}
第2.3步:
回到_zend_hash_index_add_or_update_i函数继续进行后续处理,即插入数据到bucket中。
初始化后的HashTable如下图:
如果是下面这段代码的写法,数组的初始化过程又是怎样的呢:
$b = 1;
$a = array($b);
最开始提到过:如果数组的元素不是常量表达式,则数组的初始化过程是在执行阶段才进行的。
第1步:
分配HashTable结构体内存,并初始化各个字段(和第一个例子的第1步、第2步相同)
第2步:
分配bucket数组内存,修改一些字段(包括插入元素)
上面说到,nTableSize为数组的大小,因为c语言中数组定于的时候必须指定大小,因此php采用不断动态扩容的方式实现数组存储。nTableSize的初始值为8,新的大小为旧nTableSize值的两倍。
此外,删除数组元素时并不会真正触发删除操作,只是做一个标识(将bucket中的val.u1.v.type值设置为IS_UNDEF),删除是在扩容和重建索引时触发。
这里将讨论三个问题:
扩容与rehash的整体流程如下:
问题一回答:触发扩容以及扩容的实现思路如下:
源码如下:
static void ZEND_FASTCALL zend_hash_do_resize(HashTable *ht)
{
IS_CONSISTENT(ht);
HT_ASSERT_RC1(ht);
if (ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)) { // IS_UNDEF 元素超过 Bucket 数组的 1/33,则触发rehash
zend_hash_rehash(ht);
} else if (ht->nTableSize < HT_MAX_SIZE) { // 数组大小 < 最大限制
void *new_data, *old_data = HT_GET_DATA_ADDR(ht);
// 新的内存大小为原来的两倍,采用加法是因为加法快于乘法
uint32_t nSize = ht->nTableSize + ht->nTableSize;
Bucket *old_buckets = ht->arData;
// 申请新数组内存
new_data = pemalloc(HT_SIZE_EX(nSize, -nSize), ht->u.flags & HASH_FLAG_PERSISTENT);
// 更新数组结构体成员值
ht->nTableSize = nSize;
ht->nTableMask = -ht->nTableSize;
//将arData指针指向新的Bucket首地址
HT_SET_DATA_ADDR(ht, new_data);
// 复制原数组到新数组
memcpy(ht->arData, old_buckets, sizeof(Bucket) * ht->nNumUsed);
// 释放原数组内存
pefree(old_data, ht->u.flags & HASH_FLAG_PERSISTENT);
// 重新索引
zend_hash_rehash(ht);
} else { // 数组大小超出内存限制
zend_error_noreturn(E_ERROR, "Possible integer overflow in memory allocation (%u * %zu + %zu)", ht->nTableSize * 2, sizeof(Bucket) + sizeof(uint32_t), sizeof(Bucket));
}
}
问题二回答:当移除有IS_UNDEF标志的元素的时候,就会触发rehash
具体实现思路:
rehash不会重新申请内存,而是直接在原bucke上操作。
//zend_hash.c
/**
* @description: 根据key查找值
* @param HashTable* ht
* @param zend_string* key
* @return Bucket|null 查找到返回Bucket 否则返回NULL
*/
static zend_always_inline Bucket *zend_hash_find_bucket(const HashTable *ht, zend_string *key)
{
zend_ulong h;
uint32_t nIndex;
uint32_t idx;
Bucket *p, *arData;
h = zend_string_hash_val(key); //获取字符串哈希值
arData = ht->arData;
nIndex = h | ht->nTableMask; //计算索引区下标(solt下标)
idx = HT_HASH_EX(arData, nIndex); //根据索引区下标获取到存储区的下标(bucket下标)
while (EXPECTED(idx != HT_INVALID_IDX)) {
p = HT_HASH_TO_BUCKET_EX(arData, idx); //获取对应bucket的首地址
if (EXPECTED(p->key == key)) { // 比较key和bucket中存储的key,相等即找到,返回对应bucket的首地址
return p;
} else if (EXPECTED(p->h == h) && //否则对比哈希值,对比长度,对比内存,如果都匹配成功,则找到并返回bucket的首地址
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;
}
idx = Z_NEXT(p->val); //根据zval的.u2.next查找下一个存储位置的数据,这里用于哈希冲突时查找
}
return NULL;
}