(六)PHP数组源码解读和底层实现分析

HashTable

PHP数组使用HashTable实现,因此,先从HashTable开始
源码对应的PHP版本为7.2

HashTable示意图如下:
(六)PHP数组源码解读和底层实现分析_第1张图片
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值,示意图如下:
(六)PHP数组源码解读和底层实现分析_第2张图片

_zend_array

_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。

PHP7的HashTable图:
(六)PHP数组源码解读和底层实现分析_第3张图片

什么是packed array,什么是hash array,区别是什么?

packed array

  • key全是数字
  • key按照插入顺序排列,插入顺序递增
  • 每个key-value对的存储位置是确定的,都存储在bucket数组的第key个元素上
  • 索引数组的大小不变,初始化HashTable的时候,索引数组的大小为2。对于packed array,不需要用到索引数组,因此该数组的大小一直为2(下面会说到什么是索引数组)

hash array

  • 对于key是非数字的,必须用hash算法进行计算出来它所在bucket的位置,那么索引数组是必不可少的
  • key的值较大,或者间隔较大,还是会退化成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的转变,这个耗时还是较大的,需要尽量避免这种情况。 当然这两种情况都是针对“大”数组,小数组的情况下,其实差距没那么大。

hash array的查找

PHP 数组是基于哈希表实现的,而与一般哈希表不同的是 PHP 的数组还实现了元素的有序性,就是插入的元素从内存上来看是连续的而不是乱序的,为了实现这个有序性 PHP 采用了「映射表」技术。此外,PHP采用链地址法解决哈希冲突,还记得在《(四)PHP7 zval源码解读》这篇文章中zval结构体源码中的u2.next吗,这个属性就是用来实现链地址法的。

下图中映射表(即索引数组)数组是在bucket数组前面额外申请的空间,数组中的每个元素代表一个slot,存放bucket数组的下标,如果当前slot没有任何bucket元素,则其值为-1。

下面就通过图例说明我们是如何访问 PHP 数组的元素。
(六)PHP数组源码解读和底层实现分析_第4张图片
注意:因为键名到映射表下标经过了两次散列运算,为了区分本文用哈希特指第一次散列,散列即为第二次散列。

映射表和数组元素在同一片连续的内存中,映射表是一个长度与存储元素相同的整型数组,它默认值为 -1 ,有效值为 Bucket 数组的下标。而 HashTable->arData 指向的是这片内存中 Bucket 数组的首地址。

举个例子 $a[‘key’] 访问数组 $a 中键名为 key 的成员,流程介绍:首先通过 Time 33 算法计算出 key 的哈希值,然后通过散列算法计算出该哈希值对应的映射表下标,因为映射表中保存的值就是 Bucket 数组中的下标值,所以就能获取到 Bucket 数组中对应的元素。

packed array的查找

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 使用了「链地址法」解决。下图是访问发生散列冲突的元素的情况:
(六)PHP数组源码解读和底层实现分析_第5张图片
在上上个图中,根据上述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相同,则返回。

HashTable的初始化

如果数组的元素都是常量表达式,则数组的初始化发送在编译阶段,即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如下所示:
(六)PHP数组源码解读和底层实现分析_第6张图片
如果是$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如下图:
(六)PHP数组源码解读和底层实现分析_第7张图片
如果是下面这段代码的写法,数组的初始化过程又是怎样的呢

$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(rehash就是重新建索引,把IS_UNDEF标志的元素删除掉,然后重建索引表)以及rehash的实现
  • 什么时候把有删除标志的数据真正清理掉

扩容与rehash的整体流程如下:
(六)PHP数组源码解读和底层实现分析_第8张图片
问题一回答:触发扩容以及扩容的实现思路如下

  • 向bucket插入元素的时候发现nNumUsed>=nTableSizse时,判断IS_UNDEF 元素占比
  • IS_UNDEF 元素未超过 Bucket 数组的 1/33,则新的bucket大小为原来的两倍
  • 申请新的内存,调用pemalloc函数
  • 更新HashTable的nTableSize和nTableMask值
  • 将arData指向新的bucket首地址
  • 复制原数组的值到新数组并释放原数组内存
  • 重新索引

源码如下:

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
具体实现思路:

  • 重置索引索引数组值为-1
  • 初始化两个bucket类型的指针p和q,循环遍历bucket数组
  • 每次循环,p++,遇到第一个IS_UNDEF是,q=p;继续循环数组
  • 当再一次遇到一个正常数据是,把正常数据拷贝到q指向的位置,q++
  • 知道遍历完数组,更新nNumUse等

rehash不会重新申请内存,而是直接在原bucke上操作。

问题三回答:删除是在扩容和重建索引时触发。
(六)PHP数组源码解读和底层实现分析_第9张图片

查找

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

你可能感兴趣的:(#,PHP源码学习)