PHP7源码分析之php5数组实现简析

752 阅读5分钟

本文是跟着《php7底层设计与源码实现》一书进行的梳理和个人理解。

在分析之前先来了解一下HashTable这个数据结构:

php的数组其实是非常强大的,既可以表示普通数组,又可以表示字典,然后再结合zval,什么数据类型都能存储。

了解完了“哈希表”的概念之后,我们先来画一个简单的HashTable示意图。

上图是一个基本的HashTable结构示意图,下面简单介绍下途中每个元素的含义:

  • key:键名,通过该键名可以快速检索到对应的value
  • value:键值
  • bucket:Bucket原意为桶,是用户用来管理所存储对象的存储空间,即存储数据的容器单元,即存储key和value等
  • slot:槽,一个槽可以有多个bucket,而一个bucket必须从属于某个slot,可以理解为槽就是一个一个的宿舍楼,而bucket就是宿舍房间,key和value这些就是宿舍里面的人
  • hash函数:通过hash函数可以将key映射到某个slot槽,可以理解为通过你的专业,可以查到你在哪一栋宿舍楼,进而就可以知道你在哪个宿舍了
  • 哈希冲突:slot0后面的bucket后面连着另一个bucket,这是因为key通过hash函数计算后映射到的slot槽位置一致,即哈希冲突,而为了解决存储问题,上图中使用的方法就是链地址法

而实际上,php5在实现自己的HashTable结构时是有自己的考量的,下图就是实际php5中的HashTable结构示意图:

从上图中可以看出的显著变化主要有两个地方,第一给时bucket存储单元中增加了一个h值,另一个是key不是经过一个hash函数计算slot槽位置了,而是先经过hash1函数计算出h值,然后再经过hash2函数对h值再进行hash计算才得到slot的位置。
第一个变化,新增的h值:

  • php数组的键名不仅可以是字符串还可以是数字,所以为了兼容这两种情况,“h”就代表数字key,“key”就代表字符串key
  • 有了h值,在比较两个key字符串是否相等时,会首先比较两个key字符串的h值,然后再去比较字符串的长度以及内容,如果h值就不相等,就可以认为两个字符串不相等,这样可以加快两个字符串比较的速度

先来看php5的源码来分析下字段含义,然后再讨论下php5的HashTable的实现原理。

typedef struct bucket {
	ulong h;						/* Used for numeric indexing */
	uint nKeyLength;
	void *pData;
	void *pDataPtr;
	struct bucket *pListNext;
	struct bucket *pListLast;
	struct bucket *pNext;
	struct bucket *pLast;
	const char *arKey;
} Bucket;

typedef struct _hashtable {
	uint nTableSize;
	uint nTableMask;
	uint nNumOfElements;
	ulong nNextFreeElement;
	Bucket *pInternalPointer;	/* Used for element traversal */
	Bucket *pListHead;
	Bucket *pListTail;
	Bucket **arBuckets;
	dtor_func_t pDestructor;
	zend_bool persistent;
	unsigned char nApplyCount;
	zend_bool bApplyProtection;
#if ZEND_DEBUG
	int inconsistent;
#endif
} HashTable;

首先看bucket结构体

  • h:表示数字key或字符串key的h值。
  • arKey:即字符串key
  • pData和pDataPtr:即value值
  • nKeyLength:arKey的长度。当nKeyLength=0时,表示是数字key
  • pListLast、pListNext、pLast、pNext:4个指向bucket的指针 pListLast和pListNext指向的是全局链表的前一个和后一个bucket,为了实现数组的有序性,而pLast和pNext则是指向局部链表的前一个和后一个bucket,为了解决哈希冲突

HashTable结构体

  • arBuckets:是一个指针,指向一段连续的内存空间,这段内存存储的是指向bucket的指针,每一个内存空间代表着一个slot,并且该slot存储的指针是局部链表的第一个bucket
  • nTableSize:arBuckets指向的连续数组内存的长度,即slot的数量。最大值为2的31次方(因为nTableSize是int类型,而slot实际上就是数组,索引下标从0开始)。
  • nTableMask:掩码。总是等于nTableSize - 1,即2的n次方-1,由于nTableMask的每一位都会是1,所以key在经过hash1函数计算出h值,h值再经过hash2函数映射到对应的slot,这个hash函数的算法其实就是slot = h & nTableMask(这里的掩码的作用实质上就是对过大超过nTableSize的值进行矫正),参考:按位与

  • nNumOfElements:bucket元素的个数。在php5中删除一个bucket元素会将其从全局链表和局部链表中真正删除掉,并释放bucket本身以及value占用的内存

  • pListHead和pListTail:这两个指针分别指向全局链表的头和尾,这样可以保证php数组的有序性

举个例子:将4个key-value对插入数组中,按插入顺序,key分别为"a"、"b"、"c"、"d",并且假设"a"被映射到了slot1,其余三个被映射到了slot0。

这张图和书中的图是一样的,可以很好的说明php5数组的表现形式。虚线表示全局链表,实线表示局部链表。而根究插入顺序,最新插入的会在链表头部。

php5数组设计上存在的问题:

  • 每一个bucket都需要一次内存分配。因为bucket之间是通过指针相连的,链表的数据结构,元素在内存中是不连续的,内存分配也较为随机。
  • 为了保证数组的元素有序性,每个bucket都要维护4个指针,在64位操作系统中,每个bucket就要有32个指针字节。如果有1024个bucket,就会额外多出32KB的内存,并且bucket的内存分配也是随机的,导致了CPU的cache命中率并不高,这样在遍历HashTable时,并没有很高的性能。
  • 还有一个书中有提到,对于大部分场景,key-value中的value都是zval,这样的话,每个bucket需要维护指向zval的指针pDataPtr以及指向pDataPtr的pDataPtr指针,空间效率不高。(这点我还时不是太明白)

以上就是对于php5中数组的分析,大部分都是书中的内容,自己从头捋一遍会更加清晰。