第5章 数组的实现

745 阅读25分钟

大家知道,PHP中,数组是一个神奇而强大的数据结构,在实际开发中会大量使用到数组。数组既可以是连续的数组,也可以是存储K-V映射的map。而在PHP 7中,相比于PHP 5,对数组进行了比较大的修改,甚至可以说是重新设计了数组,在时间效率和空间效率上都有很大的提升。本章将首先阐述PHP数组的语义以及基本设计,然后简单介绍PHP5数组,最后会重点讲解PHP 7数组的各个细节,包括数组初始化、插入、删除、查找等操作,希望大家能够有所收获。

5.1 基本概念

在开始了解PHP数组实现细节之前,我们有必要知道,PHP数组的设计目标以及相关基本概念。本节将对PHP数组的语义以及基本概念进行说明。

5.1.1 数组的语义

无论是PHP 5还是PHP 7,在实现PHP数组的时候,首先需要明确PHP数组的设计目标,即PHP数组具有哪些语义。那么什么是PHP数组?它可以为PHP开发者提供哪些能力呢?本质上,PHP数组是一个有序的字典。它必须同时满足如下两个语义。 语义一:PHP数组是一个字典,存储着键-值(key-value)对。通过键可以快速地找到对应的值,键可以是整型,也可以是字符串。 语义二:PHP数组是有序的。这个有序指的是插入顺序,即遍历数组的时候,遍历元素的顺序应该和插入顺序一致,而不像普通字典一样是随机的。

为了实现语义一,PHP使用HashTable来存储键-值对。但是HashTable本身并不能保证语义二,为了实现语义二,PHP不同版本中都对HashTable进行了一些额外设计来保证有序,而其中尤以PHP 7的设计最为巧妙。5.3节会详细展开阐述PHP 7数组的实现,在此之前,先讨论一下数组的概念。

5.1.2 数组的概念

PHP的数组zend_array对应的是HashTable。HashTable是哈希表(也叫散列表),也是一种通过某种哈希函数将特定的键映射到特定值的一种数据结构,它维护着键和值的一一对应关系,并且可以快速地根据键检索到值,查找效率为O(1)。HashTable的示意如图5-1所示。

图5-1 HashTable示意

  1. key:键,通过它可以快速检索到对应的value。一般是数字或字符串。
  2. value:值,目标数据。可以是复杂的数据结构。
  3. bucket:桶,HashTable中存储数据的单元。用来存储key和value以及辅助信息的容器。
  4. slot:槽,HashTable有多个槽,一个bucket必须从属于具体的某一个slot,一个slot下可以有多个bucket。
  5. 哈希函数:需要自己实现,在存储的时候,会对key应用哈希函数确定所在的slot。
  6. 哈希冲突:当多个key经过哈希计算后,得出的slot的位置是同一个,那么就叫作哈希冲突。这时,一般有两种方法解决冲突——链地址法和开放地址法。PHP中采用的是链地址法,即将同一个slot中的bucket通过链表连接起来。

在具体实现过程中,PHP基于上述基本概念,对bucket以及哈希函数进行了一些补充,增加了hash1函数以生成h值,然后通过hash2函数散列到不同的slot,如图5-2所示。

图5-2 带有h值的HashTable示意图

  1. bucket里面增加h字段。
  2. 哈希函数拆分成了hash1和hash2函数。hash1将key映射为h值,hash2将h值映射为slot的索引值。
  3. bucket里面的key字段作为字符串key,不再表示数字key。

这个h值的作用是什么呢?笔者认为是出于两方面的考虑。

一方面由于HashTable中key可能是数字,也有可能是字符串,所以bucket在设计key的时候,需要做拆分,拆分成数字key和字符串key,在上图的bucket中,“h”代表数字key,“key”代表字符串key。实际上,对于数字key, hash1函数没有做任何事情,h值就是数字key。

另一方面,每一个字符串key,经过hash1函数都会计算出一个h值。这个h值可以加快字符串key之间的比较速度。如果要比较两个字符串key1和key2是否相等,会首先比较key1和key2的h值是否相等,如果相等,再去比较字符串的长度以及内容。否则,可直接判定key1和key2不相等。在大部分场景,不同字符串的h值都不会发生碰撞,这大大提高了HashTable插入、查找的速度。

讨论完数组的语义和数组的概念后,为了更好地理解PHP 7数组实现的精妙之处,我们首先来讨论一下PHP 5数组的实现。

5.2 PHP 5数组的实现

对于PHP 5的数组实现,本文以PHP-5.6.31为例进行研究。因为PHP 7相较PHP 5在数组方面的设计改动非常大,通过对比学习,我们可以更加理解PHP 7版本带来的革命性提升。限于篇幅,本章只是简单介绍,不做过多展开。

  1. PHP 5的bucket与HashTable结构

    首先看下PHP5的bucket以及HashTable结构定义:

    typedef struct bucket {
        ulong h; /* Used for numeric indexing */
        uint nKeyLength;
        void *pData;
        void *pDataPtr;
        struct bucketpListNext;
        struct bucketpListLast;
        struct bucketpNext;
        struct bucketpLast;
        const char *arKey;
    } Bucket;
    
    typedef struct _HashTable {
        uint nTableSize;
        uint nTableMask;
        uint nNumOfElements;
        ulong nNextFreeElement;
        Bucket *pInternalPointer;
        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;
    
    1. bucket中新增的三个元素

      先分析一下bucket,这里除了HashTable设计中必要的三个元素外,还增加了一些字段,如图5-3所示。

      图5-3 PHP 5的bucket结构示意图

      1. arKey:对应HashTable设计中的key,表示字符串key。

      2. h:对应HashTable设计中的h,表示数字key或者字符串key的h值。

      3. pData和pDataPtr:对应HashTable设计中的value。


        注意

        这里的pData和pDataPtr都是指针。一般地,value都存储在pData所指向的内存空间,pDataPtr是NULL,即空指针。但有一种情况例外,如果value的大小等于一个指针的大小(大部分情况是指针),那么将不会额外申请内存空间存储这个指针,而是直接存储在pDataPtr上,再让pData指向pDataPtr,这样可以减少内存碎片。


      4. nKeyLength:arKey的长度。当nKeyLength等于0时,表示数字key。之前有提到,比较字符串key是否相等时,会先比较h值,如果h值相等,则不会直接比较字符串的内容,而是先比较字符串的长度是否相等。这样可以提高比较的速度。

      5. pListLast、pListNext、pLast、pNext:4个指向bucket的指针。


        注意

        为什么会有4个指针呢?原来,为了实现数组的两个语义,PHP 5维护了两种双向链表。一种是全局链表,按插入顺序将所有的bucket全部串联起来,整个HashTable只有一个全局链表。另一种是局部链表,为了解决哈希冲突,每个slot维护着一个链表,将所有哈希冲突的bucket串联起来。也就是,每一个bucket都处在两个双向链表上。所以这4个指针的作用就很明显了:pLast和pNext分别指向局部链表的前一个和后一个bucket;pListLast和pListNext则指向全局链表的前一个和后一个bucket。


    2. HashTable的成员变量再让我们看一下HashTable的成员变量。

      1. arBuckets:是一个指针,指向一段连续的数组内存,这段数组内存并没有存储bucket,而是存储着指向bucket的指针。每一个指针代表着一个slot,并且指向slot局部链表的首元素。通过这个指针,可以遍历这个slot下的所有的bucket。

      2. nTableSize:arBuckets指向的连续内存中指针的个数,即表示slot的数量。该字段取值始终是2的n次方,最小值是8,最大值为0x80000000(2的31次方)。当bucket数量大于slot数量时,肯定会存在某一个slot至少有两个bucket,随着slot下bucket数量的增多,HashTable逐渐退化成链表,性能会有严重下降。这时PHP 5会进行扩容,将slot数量加倍,然后进行rehash,让bucket均匀分布在slot中。

      3. nTableMask:掩码。总是等于nTableSize-1,即2n-1,因此,nTableMask的每一位都是1。上文提到的哈希过程中,key经过hash1函数,转为h值,h值通过hash2函数转为slot值。这里的hash2函数就是slot = h &nTableMask,进而通过arBuckets[slot]取得当前slot链表的头指针。

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

      5. pListHead和pListTail:为了保证数组的第二个语义(有序),HashTable维护了一个全局链表,这两个指针分别指向这个全局链表的头和尾。所以在PHP 5中的遍历实现,其实是遍历了这个双向链表。

      其他字段限于篇幅不一一介绍。

  2. PHP 5数组实现示例

    下面举个例子:将4个key-value对插入数组中,按插入顺序,key分别是:"a"、"b"、"c"、"d",并且假设"a"被映射到了slot1,而"b"、"c"、"d"被映射到了slot0中(这里的slot映射只是为了举例说明哈希冲突问题),那么最终这个数组应该有4个元素,它在内存中的分布如图5-4所示(虚线表示全局链表,实线表示局部链表)。

    图5-4 元素插入数组示例

    可以看到pListHead和pListTail作为全局链表的表头和表尾,分别指向了key为"a"和key为"d"的bucket。通过pListHead遍历全局链表,就可以按插入顺序"a", "b", "c", "d"遍历完整个HashTable。同理,通过pListTail可以按插入顺序的逆序"d", "c", "b", "a"遍历完整个HashTable,实现了语义二,即HashTable是有序的。

    arBuckets指向的指针数组内存中,slot0和slot1这两个指针分别指向了各自局部链表的第一个bucket:key为"a"和key为"d"的bucket。读者可能奇怪为什么slot0指向的bucket是"d"而不是"b"。原来在哈希冲突发生的时候,会采用头插法将新加入的bucket插入到slot局部链表的头部。由于"b"最先插入,"c"紧随其后,这时会将"c"插入到slot0这个链表中第1个bucket的位置,"b"就变成了第2个bucket。因此当"d"最后插入时,反而在最前面。

    到这里,有没有想过PHP 5的数组设计在时间效率和空间效率上存在哪些问题呢?这里笔者觉得有以下问题。

    1. 每一个bucket都需要一次内存分配。尽管由于内存池的存在,不需要通过malloc函数直接申请系统内存,避免了系统调用在用户态和内核态之间的切换以及malloc函数额外开销所造成的空间浪费,但是内存申请的耗时还是存在并且不可忽略。
    2. 对于大部分场景,key-value中的value都是zval。这种情况下,每个bucket需要维护指向zval的指针pDataPtr以及指向pDataPtr的pData指针。空间效率不是很高。
    3. 为了保证数组的两个语义,每一个bucket需要维护4个指向bucket的指针。在32位/64位系统,每个bucket将为这4个指针付出16字节/32字节。想象一下,对于拥有1024个bucket的HashTable,为了实现数组的两个语义,需要额外16KB/32KB的内存。而且由于bucket内存分配是随机的,导致了CPU的cache命中率并不高,这样在遍历HashTable的时候并没有很高的性能。

PHP 7的数组,对HashTable进行了全新的设计,在性能上和节约内存方面都有了很大的提升。具体是如何设计的呢?读者这里可以先自行思考如何实现更高时间效率和空间效率的数组,然后再进入后续章节,相信会更加有收获。

5.3 PHP 7数组的实现

如何基于HashTable实现高效优雅的数组呢?有些读者可能会想,既然是HashTable,如果通过链地址法解决哈希冲突,那么链表是必然需要的。同时为了保证顺序性,的确需要再维护一个全局链表,看起来PHP 5的实现已经是无懈可击了。难道PHP 7数组采用了其他哈希冲突解决方案(比如开放地址法)?

实际上,PHP 7的思路依然是通过链地址法解决哈希冲突。不过此“链”非彼“链”。PHP 5的链表是物理上的链表,链表中bucket之间的上下游关系通过真实存在的指针来维护。而PHP 7的链表是一种逻辑上的链表,所有bucket都分配在连续的数组内存中,不再通过指针维护上下游关系,每一个bucket只维护下一个bucket在数组中的索引(因为是连续内存,通过索引可以快速定位到bucket),即可完成链表上bucket的遍历。 下面来逐步揭开PHP 7数组的神奇面纱。

5.3.1 基本结构

在PHP 7中,数组的核心结构是struct zend_array和bucket,并且为struct_zend_array起了两个别名:HashTable和zend_array。之所以存在两个别名,根据鸟哥惠新宸的描述,是为了保持兼容。在PHP 5中,使用的是HashTable,但在PHP 7的设计中,大部分zend数据类型都是以“zend”开头,所以PHP 7中推荐使用的是zend_array。笔者这里为了阐述连贯性,仍然使用HashTable这个别名。

为了理解PHP 7数组的实现,首先看看核心结构的源码:

typedef struct _zend_array zend_array;
typedef struct _zend_array HashTable;

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

struct _zend_array {
    zend_refcounted_h gc;
    union {
        struct {
            _ENDIAN_LOHI_4(
            zend_uchar flags,
            zend_uchar nApplyCount,
            zend_uchar nIteratorsCount,
            zend_uchar consistency)
        } v;
        uint32_t flags;
        } u;
        uint32_t nTableMask;
        Bucket *arData;
        uint32_t nNumUsed;
        uint32_t nNumOfElements;
        uint32_t nTableSize;
        uint32_t nInternalPointer;
        zend_long nNextFreeElement;
        dtor_func_t pDestructor;
};
  1. bucket结构分析

图5-5 PHP 7数组bucket

结构先分析一下bucket,由于不再依赖于物理指针,整个bucket变得清爽了很多,只有val、h、key 3个字段,如图5-5所示。

1)val:对应HashTable设计中的value,始终是zval类型。PHP 7将zval嵌入到bucket中,每一个zval只有16个字节。相比于PHP5的pData和pDataPtr,所占的字节数并没有增加。而且不用再额外申请保存zval的内存。有同学可能会有疑问,之前的pData和pDataPtr是void *类型的,也就是说,可以指向任何类型的数据,而zval可以这样吗?答案是肯定的,PHP7对zval进行了重大改造,当zval是IS_PTR类型时,可以通过zval.value.ptr指向任何类型的数据。对于zval的具体阐述见第3章。

2)h:对应HashTable设计中的h,表示数字key或者字符串key的h值。 3)key:对应HashTable设计中的key,表示字符串key。区别于PHP 5,这里不再是char *类型的指针,而是一个指向zend_string的指针。zend_string是一种带有字符串长度、h值、gc信息的字符数组的包装,提升了性能和空间效率。对于zend_string的阐述见第4章。

bucket从使用角度可以分为3种:未使用bucket、有效bucket、无效bucket,如图5-6所示。

图5-6 PHP 7数组bucket分类

1)未使用bucket:最初所有的bucket都是未使用的状态。

2)有效bucket:存储着有效的数据(key、val、h),当进行插入时,会选择一个未使用bucket,这样该bucket就变成了有效bucket。更新操作只能发生在有效bucket上,更新之后,仍然是有效bucket。

3)无效bucket:当bucket上存储的数据被删除时,有效bucket就会变为无效bucket。同时,对于某些场景的插入(packed array的插入,5.3.3节会提到),除了会生成一个有效bucket外,还会有副作用,生成多个无效bucket。

在内存分布上,有效bucket和无效bucket会交替分布,但都在未使用bucket的前面。插入的时候永远在未使用bucket上进行。当由于删除等操作,导致无效bucket非常多,而有效bucket很少时,会对整个bucket数组进行rehash操作。这样,稀疏的有效bucket就会变得连续而紧密,部分无效bucket会被重新利用而变为有效bucket。还有一部分有效bucket和无效bucket会被释放出来,重新变为未使用bucket。

这3种bucket的状态转换如图5-7所示。

图5-7 PHP 7数组bucket状态迁移

  1. HashTable结构分析

接下来看看HashTable,如图5-8所示。

图5-8 PHP 7中HashTable示例

1)gc:引用计数相关,在PHP 7中,引用计数不再是zval的字段,而是被设计在zval的value字段所指向的结构体中。

2)arData:实际的存储容器。通过指针指向一段连续的内存,存储着bucket数组。

nTableSize、nNumUsed、nNumOfElements这三个字段都和数量相关,如图5-9所示。

图5-9 PHP 7 HashTable中的各种计数示例

3)nTableSize:HashTable的大小。表示arData指向的bucket数组的大小,即所有bucket的数量。该字段取值始终是2n,最小值是8,最大值在32位系统中是0x40000000(230),在64位系统中是0x80000000(231)。

4)nNumUsed:指所有已使用bucket的数量,包括有效bucket和无效bucket的数量。在bucket数组中,下标从0~(nNumUsed-1)的bucket都属于已使用bucket,而下标为nNumUsed~(nTableSize-1)的bucket都属于未使用bucket。

5)nNumOfElements:有效bucket的数量。该值总是小于或等于nNumUsed。

6)nTableMask:掩码。一般为-nTableSize。区别于PHP 5, PHP 7的掩码始终是负数,为什么是负数呢?这里先卖个关子,下文会给出明确的答案。

7)nInternalPointer:HashTable的全局默认游标。在PHP 7中reset/key/current/next/prev等函数和该字段有紧密的关系。该值是一个有符号整型,区别于PHP 5,由于所有bucket分配在连续的内存,不再需要根据指针维护正在遍历的bucket,而是只维护正在遍历的bucket在数组中的下标即可

8)nNextFreeElement:HashTable的自然key。自然key是指HashTable的应用语义是纯数组时,插入元素无须指定key, key会以nNextFreeElement的值为准。该字段初始值是0。比如$a[] = 1,实际上是插入到key等于0的bucket上,然后nNextFreeElement会递增变为1,代表下一个自然插入的元素的key是1。

9)pDestructor:析构函数。当bucket元素被更新或者被删除时,会对bucket的value调用该函数,如果value是引用计数的类型,那么会对value引用计数减1,进而引发可能的gc。

10)u:是一个联合体。占用4个字节。可以存储一个uint32_t类型的flags,也可以存储由4个unsigned char组成的结构体v,这里的宏ZEND_ENDIAN_LOHI_4是为了兼容不同操作系统的大小端,可以忽略。v中的每一个char都有特殊的意义。

11)u.v.flags:用各个bit来表达HashTable的各种标记。共有下面6种flag,分别对应u.v.flags的第1位至第6位。

#define HASH_FLAG_PERSISTENT       (1<<0) //是否使用持久化内存(不使用内存池)
#define HASH_FLAG_APPLY_PROTECTION (1<<1) //是否开启递归遍历保护
#define HASH_FLAG_PACKED           (1<<2) //是否是packed array
#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) //是否存在空的间接val

12)u.v.nApplyCount:递归遍历计数。为了解决循环引用导致的死循环问题,当对某数组进行某种递归操作时(比如递归count),在递归调用入栈之前将nApplyCount加1,递归调用出栈之后将nApplyCount减1。当循环引用出现时,递归调用会不断入栈,当nApplyCount增加到一定阈值时,不再继续递归下去,返回一个合法的值,并打印“recursion detected”之类的warning或者error日志。这个阈值一般不大于3。

13)u.v.nIteratorsCount:迭代器计数。PHP中每一个foreach语句都会在全局变量EG中创建一个迭代器,迭代器包含正在遍历的HashTable和游标信息。该字段记录了当前runtime正在迭代当前HashTable的迭代器的数量。


提示

u.flags和u.v.flags有什么区别呢?u.flags是32位的无符号整型,取值范围是0~232-1,而u.v.flags是8位的无符号字符,取值范围是0~255。C语言中联合体很巧妙,既可以通过u.flags一次性操作32位的整型,也可以根据u.v.[flags|nAppl yCount|nItertorsCount|consistency]只操作其中某一个具体的8位的char,即一段内存,多种意义。


14)u.v.consistency:成员用于调试目的,只在PHP编译成调试版本时有效,表示HashTable的状态,状态有4种。

#define HT_OK                     0x00 //正常状态,各种数据完全一致
#define HT_IS_DESTROYING          0x40 //正在删除所有的内容,包括arBuckets本身
#define HT_DESTROYED              0x80 //已删除,包括arBuckets本身
#define HT_CLEANING               0xc0 //正在清除所有的a r B u c k e t s指向的内容,但不包括arBuckets本身

看到这里,有没有发现一个问题,HashTable的slot和链表去哪了?arData指向的是bucket数组,并没有像PHP 5的arBuckets一样,指向的是bucket*指针数组。那么如何基于一个bucket数组实现多个slot以及链表呢?

3.为什么HashTable的掩码是负数

实际上PHP 7在分配bucket数组内存的时候,在bucket数组的前面额外多申请了一些内存,这段内存是一个索引数组(也叫索引表),数组里面的每个元素代表一个slot,存放着每个slot链表的第一个bucket在bucket数组中的下标。如果当前slot没有任何bucket元素,那么索引值为-1。而为了实现逻辑链表,由于bucket元素的val是zval, PHP 7通过bucket.val.u2.next表达链表中下一个元素在数组中的下标,如图5-10(n等于nTableSize)所示。

图5-10 PHP 7中HashTable实现示例

这里一个非常巧妙的设计是索引数组仍然通过HashTable.arData来引用。由于索引数组和bucket数组是连续的内存,因此arData[0...n-1]表示bucket数组元素,((uint32_t*) (arData))[-1...-n]表示索引数组元素。因此在计算bucket属于哪个slot时,要做的就是确定它在索引数组中的下标,而这个下标是从-n~-1的负数,分别代表slot1到slotN。

这回是否可以理解为什么HashTable的掩码nTableMask是负数了呢?为了得到介于[-n, -1]之间的负数的下标,PHP 7的HashTable设计中的hash2函数(根据h值取得slot值)是这样的(其中nIndex就是slot值):

nIndex = h | ht->nTableMask;

以nTableSize=8为例,nTableMask=-8,二进制表示是:11111111111111111111111111111000任何整数和它进行按位或之后的结果只有以下8种,这恰好满足[-n, -1]的取值范围:

11111111111111111111111111111000 //-8
11111111111111111111111111111001 //-7
11111111111111111111111111111010 //-6
11111111111111111111111111111011 //-5
11111111111111111111111111111100 //-4
11111111111111111111111111111101 //-3
11111111111111111111111111111110 //-2
11111111111111111111111111111111 //-1

上面概况地讲解了PHP 7中HashTable和bucket的结构及各个字段的意义,有很多细节并没有详细展开。这里读者不理解也没有关系,接下来会继续深入展开讨论。

5.3.2 初始化

前文介绍了PHP 7数组的两个核心结构HashTable和bucket。那么HashTable和bucket是什么时候分配内存并初始化的呢?初始化之后的内存布局是什么样的?本节就这些问题展开讲述。考虑下面这段代码:

<?php
$a = array();

代码很简单,只有一行,将一个空数组赋值给$a这个变量。现看看执行的opcodes:

./phpdbg -p* array.php
function name: (null)
L1-4 {main}() /root/php7array.php -0x7f1b55c79000 + 2 ops
  L2    #0     ASSIGN                   $a                    array(0)
  L4    #1     RETURN                   1
[Script ended normally]

通过phpdbg看到有两个opcode:ASSIGN和RETURN。RETURN是程序结束时执行的,而上面的赋值语句对应的opcode只有一个:ASSIGN。从operands列可以看到它有两个操作数:第一个操作数!0表示变量$a,第二个操作数表示一个数组常量。

有没有和笔者一样很奇怪为什么没有数组创建、数组初始化之类的opcode?其实对于array()这种写法,PHP 7会在编译阶段(将AST抽象语法树编译成opcode时,具体内容会在第12章具体阐述)就创建一个数组常量。这个数组常量和数字常量、字符串常量一样,是在编译阶段就确定并分配内存的。因此对于上面的代码,数组的初始化发生在编译阶段。初始化的过程如下。

第1步:申请一块内存。

(ht) = (HashTable *) emalloc(sizeof(HashTable))
//注:HashTable就是_zend_array结构体,typedef struct _zend_array HashTable;

第2步:调用_zend_hash_init方法。

GC_REFCOUNT(ht) = 1; //设置引用计数
GC_TYPE_INFO(ht) = IS_ARRAY; //7 类别设置成数组
//persistent是否经过内存池分配内存
ht->u.flags = (persistent ? HASH_FLAG_PERSISTENT : 0) | HASH_FLAG_APPLY_PROTECTION |
    HASH_FLAG_STATIC_KEYS;
ht->nTableSize = zend_hash_check_size(nSize); //能包含nSize的最小2n的数字最小值 8
ht->nTableMask = HT_MIN_MASK; //-2, 默认是packed array
HT_SET_DATA_ADDR(ht, &uninitialized_bucket); //prt偏移到arrData地址
ht->nNumUsed = 0;
ht->nNumOfElements = 0;
ht->nInternalPointer = HT_INVALID_IDX; //-1
ht->nNextFreeElement = 0;
ht->pDestructor = pDestructor;

初始化结束后,这个数组常量如图5-11所示。

图5-11 初始化后的HashTable示例图


说明:

nTableSize=8,因为HashTable内部的arBuckets的大小是2的n次方,并且最小值是8,最大值为0x80000000。


u.v.flags=18。在PHP 7中,定义了6个flag,如下:

#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) /* long and interned strings */
#define HASH_FLAG_HAS_EMPTY_IND    (1<<5)

flags = 18 = HASH_FLAG_STATIC_KEYS |HASH_FLAG_APPLY_PROTECTION。而flag & HASH_FLAG_INITIALIZED等于0说明,该数组尚未完成真正的初始化,即尚未为arData分配内存。

nTableMask=-2,表示索引表的大小为2。packed array的索引表未使用到,即nTableMask永远等于-2。

nInternalPointer=-1,由于尚未初始化arData, nInternalPointer等于-1,表示无效的遍历下标。

以上了解了初始化一个空数组的过程,那么如果不是空数组,数组的初始化是否仍然发生在编译阶段呢?

实际上如果数组的元素都是常量表达式,那么这个数组的初始化仍然会在编译阶段完成,初始化之后的数组在执行阶段作为数组常量被赋值给其他的变量。例如下面的代码,将一个非空数组赋值给$a这个变量。

<?php
$arr[] = 'foo';

查看执行的opcodes:

./phpdbg -p* array.php
function name: (null)
L1-4 {main}() /root/php7/array.php -0x7f6f9e07a000 + 3 ops
  L2    #0     ASSIGN_DIM               $a             NEXT
  L2    #1     OP_DATA                  "foo"
  L4    #2     RETURN                   1
[Script ended normally]

赋值语句对应的那行代码经过编译之后,也会生成两个opcode指令:ASSIGN_DIM和OP_DATA。执行到opcode的ASSIGN_DIM时候,才会真正初始化哈希表,具体步骤如下。

第1步:调用ZEND_ASSIGN_DIM_SPEC_CV_CONST_OP_DATA_CONST_HANDLER(具体内容会在第11章中阐述)。

第2步:调用zend_hash_next_index_insert函数。

variable_ptr = zend_hash_next_index_insert(Z_ARRVAL_P(object_ptr), &EG(uninitialized_zval));

第3步:调用zend_hash_real_init_ex初始化arData,具体代码如下。

static zend_always_inline void zend_hash_real_init_ex(HashTable *ht, int packed){
    /* packed:h < ht->nTableSize , h=0 , ht->nTableSize默认为8*/
    if (packed) {//packed array初始化
        /*为arData申请分配内存,并把arData的指针偏移指向buckets数组的首地址*/
          HT_SET_DATA_ADDR(ht,  pemalloc(HT_SIZE(ht),  (ht)->u.flags  &  HASH_FLAG_
              PERSISTENT));
        /*修改flags为 已经初始化并且为packed array*/
          (ht)->u.flags |= HASH_FLAG_INITIALIZED | HASH_FLAG_PACKED; /
        /*nIndex置为无效标识-1, arData[-1]=-1 , arData[-2]=-1*/
        HT_HASH_RESET_PACKED(ht); //-1-2 置为-1
    } else {//普通哈希表的初始化
        /*掩码nTableMask为nTableSize的负数,即nTableMask  =  -nTableSize,因为
          nTableSize等于2

ASSIGN_DIM:对数组或者对象的某一个元素或者字段进行赋值。如果是数组,第一个操作数op1表示数组,第二个操作数op2表示index。op2可以省略,代表按自然顺序来赋值,执行完这一句之后,HashTable才真正地被初始化完毕,还默认会把第一个bucket的val设置成空(也就是值&EG(uninitialized_zval)), key=null, h=0(自然序增长的第一个值0),这时候的HashTable的结构如图5-12所示。

图5-12 HashTable初始化示意图(ASSIGN_DIM执行完后)

OP_DATA:被赋值的数据,其实存储在这条opcode中。该opcode紧跟着ASSIGN_DIM出现,并且不会单独执行,而是在ASSIGN_DIM执行的时候,一块执行。执行的代码如下:

// 从zend_execute_data的*literals去获取临时变量的值,op1存的是偏移量,这里取到的value实际值为 "foo"
value = EX_CONSTANT((opline+1)->op1); 
// 赋值给前一个bucket的val
value = zend_assign_to_variable(variable_ptr, value, IS_CONST); 

执行之后,内存中的HashTable变成了什么样呢?如图5-13所示。

图5-13 HashTable初始化示意图(OP_DATA执行完后)

  • HashTable的arData被真正地分配内存,并且按最小值8分配了8个bucket的存储空间。
  • flags=30=HASH_FLAG_STATIC_KEYS |HASH_FLAG_APPLY_PROTECTION|HASH_FLAG_PACKED|HASH_FLAG_INITIALIZED说明当前HashTable.arData已经被初始化完毕,并且当前HashTable是packed array。
  • nTableMask仍然是-2,因为是packed array。
  • $a[]对于首次插入,h值等于0。对于packed array插入到了bucket数组的第一个位置(下标为0)。
  • bucket里面内嵌了zval。
  • nNumUsed=1,由于bucket数组是连续分配的内存,nNumUsed=1代表已经使用了1个bucket,那就是arData[0]这个bucket。
  • nNumOfElements=1,表示当前HashTable中有一个有效元素arData[0]。
  • nInternalPointer=0,遍历下标,表示遍历HashTable时从arData[0]开始。
  • nNextFreeElement=1,自然下标,下次自然序插入时,h值为1。

如果数组的元素不是常量表达式呢?例如下面的代码,将变量$b作为数组的元素:

<?php
$b = 1;
$a = array($b)

查看执行的opcodes:

./phpdbg -p* array.php
function name: (null)
L1-5 {main}() /root/php7/array.php -0x7f512b879000 + 4 ops
  L2    #0     ASSIGN                   $b                  1
  L3    #1     INIT_ARRAY               $b                  NEXT                ~1
  L3    #2     ASSIGN                   $a                  ~1
  L5    #3     RETURN                   1
[Script ended normally]

第一个opcode是ASSIGN,将1赋值给b,对应第一行代码。第二行代码被解析成了两个opcode:INIT_ARRAY和ASSIGN。INIT_ARRAY,顾名思义,该opcode表示数组的初始化,它的操作数是!0,即b,并返回~3这个临时变量。紧接着通过ASSIGN这个opcode,将~3赋值给!1,即$a。对于数组元素不是常量的数组,数组的初始化是在执行阶段(执行INIT_ARRAY时)才进行的。

了解了数组初始化的时机后,数组初始化时都做了哪些事情呢?让我们先自己思考一下,初始化要做的事情无非就是两件:分配内存和设定初始值。分配内存首先肯定要申请HashTable这个结构体的内存,但是存储bucket的连续内存是否也要一块申请呢?在此,PHP 7使用了懒惰(lazy)的思想,按需分配bucket数组内存。也就是说,只有当真正需要使用bucket时才去申请。

因此,在PHP 7中,数组的初始化其实是分两步的。

第1步:分配HashTable结构体内存,并初始化各个字段。

第2步:分配bucket数组内存,修改一些字段值。

对于第2步,不是每次初始化都会进行。比如像“a = array()”这种写法,由于数组为空,PHP 7不会额外申请bucket数组内存。而对于“a = array(1,2, 3)”这种写法,由于数组非空,因此PHP 7需要执行第2步的初始化,分配bucket数组内存。

只完成了第1步的初始化,数组是没法直接使用的,这时候如果需要对数组进行插入、更新操作,会首先进行第2步的初始化,再做后续的操作。不同场景初始化流程如图5-14所示。

图5-14 PHP 7数组初始化流程图

接下来看看具体的源码细节。在PHP 7中,数组可以依赖于zval而存在,也可以单独存在。对应HashTable内存分配的宏分别是ZVAL_NEW_ARR和ALLOC_HASHTABLE,源代码如下:

#define ZVAL_NEW_ARR(z) do {                        \
    zval *__z = (z);                                 \
    zend_array *_arr =                               \
    (zend_array *) emalloc(sizeof(zend_array));     \
    Z_ARR_P(__z) = _arr;                             \
    Z_TYPE_INFO_P(__z) = IS_ARRAY_EX;                \
} while (0)

#define ALLOC_HASHTABLE(ht)        \
        (ht) = (HashTable *) emalloc(sizeof(HashTable))

我们看到,核心调用都是一样的,都是通过emalloc从内存池申请sizeof(zend_array)大小的内存空间(假设环境变量USE_ZEND_ALLOC不等于0)。为HashTable分配内存之后,会调用_zend_hash_init函数初始化HashTable的各个字段:

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;
        ht->u.flags = (persistent ? HASH_FLAG_PERSISTENT : 0) | HASH_FLAG_APPLY_
            PROTECTION | HASH_FLAG_STATIC_KEYS;
        ht->nTableMask = HT_MIN_MASK;
        HT_SET_DATA_ADDR(ht, &uninitialized_bucket);
        ht->nNumUsed = 0;
        ht->nNumOfElements = 0;
        ht->nInternalPointer = HT_INVALID_IDX;
        ht->nNextFreeElement = 0;
        ht->pDestructor = pDestructor;
        ht->nTableSize = zend_hash_check_size(nSize);
}

_zend_hash_init函数的入参有4个(ZEND_FILE_LINE_DC记录文件名字和行号,在debug开启的时候才有效,可以忽略)。其中ht正是上一步分配内存之后返回的指针,nSize表示希望申请的HashTable的大小。pDestructor是析构函数指针,对于使用ZVAL_NEW_ARR宏分配内存的场景,一般传过来的pDestructor是ZVAL_PTR_DTOR宏。最后一个参数persistent表示分配bucket数组的内存时是否使用永久内存(不使用内存池)。

再看看_zend_hash_init都干了些什么。

  • 设置ht的引用计数为1,引用计数类型为IS_ARRAY(数组类型)。
  • 设置ht->u.flags为HASH_FLAG_APPLY_PROTECTION|HASH_FLAG_STATIC_KEYS(18= 2 | 16),即HashTable默认是递归遍历保护的和静态key的(关于这两个flag后面会详细介绍)。如果入参persistent不为0,那么再增加一个f lag: HASH_FLAG_PERSISTENT,即在永久内存上分配bucket。
  • 设置nNumUsed、nNumOfElements为0,因为现在还没有使用任何数组元素。
  • 设置nInternalPointer为-1,表示尚未设置全局遍历游标。
  • 设置nNextFreeElement为0,表示数组的自然key从0开始。
  • 设置析构函数指针为pDestructor。
  • 设置nTableSize,如果传递的nSize不是2n,会通过zend_hash_check_size函数计算大于等于nSize的最小的2n,例如nSize=10,那么最终ht->nTableSize取值为16。
  • 设置ht->arData指向uninitialized_bucket的尾部
  • 设置nTableMask是HT_MIN_MASK,即-2(uninitialized_bucket数组的大小)。

uninitialized_bucket是一个全局索引数组,size为2,默认索引值都是-1,源码如下:

static const uint32_t uninitialized_bucket[-HT_MIN_MASK] =
        {HT_INVALID_IDX, HT_INVALID_IDX};

其中,HT_MIN_MASK为-2, HT_INVALID_IDX为-1。前文提到过ht->arData指向bucket数组的起始位置,而在它前面还有一个索引数组。ht->arData指向uninitialized_bucket的尾部,反过来想uninitialized_bucket正是ht的索引数组,实际上,作为一个全局数组,所有的未进行第2步初始化的HashTable的索引数组都是它(注意:这时ht->arData所引用的bucket数组是无效的、非法的)。

另外,这个数组的大小为什么是2呢?我们知道nTableSize的最小值是8,如果按nTableMask = -nTableSize的算法,这个索引数组的大小应该是8才对。笔者认为这样设计有两方面原因:一方面由于lazy初始化,能节省点空间就节省点空间,所以这里取2更加合适。另一方面,则是下一节要讨论的主题,对于某些场景(packed array),索引数组是多余的,nTableMask并不总是等于-nTableSize,因此这里在尚未确定数组的使用方式时,为了节省内存,使用了大小为2的数组作为默认索引数组。

经过上面的初始化之后,HashTable在内存中的示意如图5-15所示。

图5-15 PHP 7中HashTable在内存中的示意图

关于第2步初始化,不同的场景执行的逻辑略有不同。这种差异化源自PHP7对数组的分类packed array和hash array之间的差别。为了更顺畅地说明问题,下一节将为大家讲解packed array和hash array,并继续阐述第2步初始化的细节。

5.3.3 packed array和hash array的区别

本章在开始的时候提到过PHP数组的两种用法,一种是纯数组,另一种是基于key-value的map。例如下面的代码:

$a = array(1,2,3); //纯数组
$b = array('x'=>1, 'y'=>2, 'z'=>3); //map

对于这两种用法,PHP 7引申出了packed array和hash array的概念。当HashTable的u.v.flags & HASH_FLAG_PACKED > 0时,表示当前数组是packed array,否则当前数组是hash array。

1.内存的本质区别

packed array和hash array的区别在哪里呢?先看一段曾经令笔者奇怪的代码:

//文件一:a.php
<?php
$memory_start = memory_get_usage();
$test = array();
for($i=0; $i<=200000 ; $i++){
            $test[$i] = 1;
}
echo memory_get_usage() - $memory_start, " bytes\n";

//文件二:b.php
<?php
$memory_start = memory_get_usage();
$test = array();
for($i=200000; $i>=0; $i--){
            $test[$i] = 1;
}
echo memory_get_usage() - $memory_start, " bytes\n";

这两个脚本都是使用数组存放20万个相同的元素,第一个PHP文件是从小到大进行插入,第二个PHP文件则相反,是从大到小进行插入。最终执行的结果如下:

[root@docker100327~]# php a.php
8392784 bytes
[root@docker100327~]# php b.php
9437264 bytes

我们看到,两个脚本使用的内存并不一样,b脚本会比a脚本大概多使用1MB左右的内存。这是为什么呢?原因就在于这两种写法,test数组的内存结构是有区别的,一种是packed array,另一种是hash array。是不是很神奇?

  1. packed array

packed array具有以下约束和特性。

1)key全是数字key。

2)key按插入顺序排列,仍然是递增的。

3)每一个key-value对的存储位置都是确定的,都存储在bucket数组的第key个元素上。

4)packed array不需要索引数组。

它实际上利用了bucket数组的连续性特点,对于某些只有数字key的场景进行的优化。由于不再需要索引数组,从内存空间上节省了(nTableSize-2 )*sizeof(uint32_t) 个字节。另外,由于存取bucket是直接操作bucket数组,在性能上也有所提升。

对于本节开始的例子,a的key都是数字key,并且key插入的顺序分别是0、1、2,满足递增的特性,所以a是packed array。

PHP 7中packed array的实现示意图如图5-16所示。

图5-16 PHP 7中packed array的实现示意图

而hash array则相反,如前面所讲,它依赖索引数组来维护每一个slot链表中首元素在bucket数组中的下标。对于本节开始的例子,b的key都是字符串,因此b不是packed array,而是hash array。

显然无法像packed array一样,直接根据key定位到在bucket数组的下标,这时索引数组就派上用场了。拿key为x举例,字符串x的h值是9223372036854953501,它与nTableMask(-8)做位或运算之后,结果是-3,然后我们索引数组去查询-3这个slot的值,得出该slot链表首元素在bucekt数组的下标为0。因此按照这个下标找下去,肯定会找到key为x的元素,目前看,其实正是bucket数组的第0个元素。同理,key为y和z的元素挂在了slot值为-2和-1这两个逻辑链表上,如图5-17所示。

图5-17 PHP 7中hash array的实现示意图

关于packed array,读者可能还有一些误区,看下面几个例子:

$a = array(1=>'a',3=>'b',5=>'c'); //例子1,仍然是packed array
$b = array(1=>'a',5=>'c',3=>'b'); //例子2,不再是packed array,而是hash array
$c = array(1=>'a',8=>'b'); //例子3,不再是packed array,而是hash array

例子1, packed array并不是说数组的key一定要连续递增,因此$a是packed array。

例子2, key按插入顺序排列是1、5、3,非递增,因此b不是packed array。为什么packed array要求key的顺序是递增的呢?假设仍然按packed array的方式,将b数组中的各个元素放入bucket数组中,看看插入后的结果,如图5-18所示。

图5-18 PHP 7中packed array插入示意图(例子2)

b数组相应的3个元素分别被插入到了bucket数组中的第1、5、3这3个下标中。我们发现,这和a数组的内存是一模一样的。但a和b在PHP中是两个不同意义的数组,因为它们拥有不同的插入顺序,所以$b是packedarray则不成立了。

还记得5.1.1节提到的数组的语义吗?第二个语义提到PHP数组是有序的,那么PHP 7中是如何实现这个语义的呢?实际上,PHP 7通过bucket数组本身就实现了有序性,它保证在插入元素的时候,始终在bucket数组的最后一个有效元素的后面插入。因此从头开始遍历bucket数组,遍历的结果正是插入的顺序(关于元素的插入后面会详细讲解)。

例子3, key按插入顺序排列是1、8,是递增有序的,但$c为什么不是packed array呢?其实理论上是可以的,但如果按packed array插入的话,会比较浪费空间,如图5-19所示。

图5-19 PHP 7中packed array插入示意图(例子3)

bucket数组中下标为2~7的6个bucket为了保持packed array特性,无法再插入元素,成为浪费的空间。因此,PHP 7会在packed array的空间效率以及时间效率优化与空间浪费之间做一个平衡,当空间浪费比较多的时候,空间效率反而不如hash array,这时PHP 7会将packed array转化为hasharray,而变成下面的样子,可看到bucket数组的大小仍然维持在8,如图5-20所示。

图5-20 PHP 7中的packed array转化为hash array

清楚了packed array和hash array之间的区别后,再回过头来继续看看数组的初始化。

上一节讲到数组的初始化分两步,第2步的初始化只有在真正需要使用bucket数组的时候,才会进行。它会调用zend_hash_real_init函数,该函数有两个入参,ht指向要初始化的HashTable, packed表示是否将HashTable初始化为packed array。在内部,它会调用zend_hash_real_init_ex函数,执行真正的初始化逻辑,源码如下:

ZEND_API void ZEND_FASTCALL zend_hash_real_init(HashTable *ht, zend_bool packed)
{
    IS_CONSISTENT(ht);

    HT_ASSERT(GC_REFCOUNT(ht) == 1);
    zend_hash_real_init_ex(ht, packed);
}

static zend_always_inline void zend_hash_real_init_ex(HashTable *ht, int packed)
{
    HT_ASSERT(GC_REFCOUNT(ht) == 1);
    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));
    (ht)->u.flags |= HASH_FLAG_INITIALIZED | HASH_FLAG_PACKED;
    HT_HASH_RESET_PACKED(ht);
    } else {
    (ht)->nTableMask = -(ht)->nTableSize;
    HT_SET_DATA_ADDR(ht,  pemalloc(HT_SIZE(ht),  (ht)->u.flags  &  HASH_FLAG_
        PERSISTENT));
    (ht)->u.flags |= HASH_FLAG_INITIALIZED;
    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 {
            HT_HASH_RESET(ht);
        }
    }
}

这里使用到了两个宏HT_SIZE和HT_SET_DATA_ADDR。

HT_SIZE宏用来计算ht索引数组和bucket数组的大小总和。索引数组的大小等于-nTableMask * sizeof(uint32_t), bucket数组的大小等于nTableSize *sizeof(Bucket)。

#define HT_HASH_SIZE(nTableMask) \
        (((size_t)(uint32_t)-(int32_t)(nTableMask)) * sizeof(uint32_t))
#define HT_DATA_SIZE(nTableSize) \
        ((size_t)(nTableSize) * sizeof(Bucket))
#define HT_SIZE_EX(nTableSize, nTableMask) \
        (HT_DATA_SIZE((nTableSize)) + HT_HASH_SIZE((nTableMask)))
#define HT_SIZE(ht) \
        HT_SIZE_EX((ht)->nTableSize, (ht)->nTableMask)

申请bucket相关内存时,会通过pemalloc一次性申请HT_SIZE大小的内存,并返回这段内存的指针ptr,当作HT_SET_DATA_ADDR宏的第二个参数。

HT_SET_DATA_ADDR宏将ht->arData指向ptr这段内存中bucket数组的起始位置。从宏代码可以看到,就是将ptr之后-nTableMask *sizeof(uint32_t)个字节的位置指针赋值给ht->arData,即让arData指向bucket数组的起始位置。

#define HT_SET_DATA_ADDR(ht, ptr) do { \
    (ht)->arData = (Bucket*)(((char*)(ptr)) + HT_HASH_SIZE((ht)-> nTableMask)); \
} while (0)

整个zend_hash_real_init_ex函数做的事情比较简单,分为4小步。

1)申请bucket内存。

如果是packed array,由于这时nTableMask等于-2,所以会通过pemalloc申请大小为2 * sizeof(uint32_t) + nTableSize * sizeof(Bucket)的内存。

如果是hash array,会先将nTableMask置为-nTableSize,然后通过pemalloc申请大小为nTableSize * sizeof(uint32_t) + nTableSize *sizeof(Bucket)的内存。


注意

内存申请时,如果ht->u.flags & HASH_FLAG_PERSISTENT > 0,那么不会在内存池上申请内存,而是直接通过malloc申请系统内存。一般情况下,ht->u.flags &HASH_FLAG_PERSISTENT都为0,即会优先使用内存池来分配bucket相关内存。


2)通过HT_SET_DATA_ADDR设置ht->arData指向bucket数组内存。

3)修改ht->u.flags。

如果是packed array,设置ht->u.flags |= HASH_FLAG_INITIALIZED |HASH_FLAG_PACKED,表示该数组已经完成初始化,并且是packed array。

如果是hash array,设置ht->u.flags |= HASH_FLAG_INITIALIZED,表示该数组已经完成初始化,但不是packed array。

4)初始化索引数组。

如果是packed array,使用HT_HASH_RESET_PACKED宏,设置索引数组中的各索引值为-1。

如果是hash array,若nTableSize等于8,那么直接修改索引数组中的索引值为-1。否则使用HT_HASH_RESET宏设置索引数组中的各索引值为-1。

最终,经过真正初始化后,packed array内存结构如图5-21所示。

图5-21 PHP 7中packed array初始化完成后的示意图

hash array内存结构如图5-22所示。

图5-22 PHP 7中hash array初始化完成后的示意图

为了让读者能直观地区分packed array与hash array,下面举个例子,分别画出存储的结构图,对于普通hash array以如下代码为例:

for($i=0; $i<=4 ; $i++){
    $demo['a'.$i] = 1; //hash array
}

执行后的数组结构如图5-23所示。

图5-23 PHP 7中hash array赋值后的示意图

说明:

  • hash array的nTableMask(掩码)始终等于-nTableSize。
  • hash array的arData由nIndex数组和bucket数组组成。nIndex数组与bucket数组实际是共享一块连续的内存,两个数组的长度一致,分配内存时nIndex数组与bucket数组一起分配,arData向后移动到了bucket数组的首地址。
  • hash array的哈希索引值存储在nIndex数组中,访问arData[-1]、arData[-2]、arData[-3]……结构是uint32_t。
  • hash array实际的key->value存储在bucket数组中,访问arData[0]、arData[1]、arData[2]……结构是_Bucket。
  • 插入操作时,每一个value具体在bucket数组中的存储位置(也就是arData的idx)idx = ht->nNumUsed++,可理解为就是按顺序递增插入,如第一个元素对应于arData[0]、第二个对应于arData[1]...arData[nNumUsed],插入后再把对应的idx值更新在nIndex索引数组中。
  • PHP数组的有序性正是通过arData的顺序插入保证的。每一个idx存储在索引数组中的具体位置则由映射函数根据key的hash值计算来确定。当key为字符串类型时,可以通过“h =zend_string_hash_val(key); ”的算法得到一个hash整型值;当key为数字时,则h直接等于key,然后与数组的掩码取“|”得到其在索引数组中的存储位置(nIndex = h | ht->nTableMask),再把idx值更新存储进去,arData[nIndex] = HT_IDX_TO_HASH(idx)。这样就建立了一个索引关系,当要根据一个key去取value的时候,过程一样,先得到key的hash值h,根据h得到nIndex,取出value在arData存储的位置idx,再取出bucket的val值。
  • 哈希冲突,指的是不同的key经过哈希函数得到相同的值,但这些值需要同时插入nIndex数组,当出现冲突时将原有arData[nIndex]存储的位置信息保存到新插入value的zval.u2.next中,图5-23中idx为3就是遇到哈希冲突时的存储示意图。
  • 当插入的数组容量不够时,会进行扩容操作,新申请的arData的容量是当前数组容量的两倍,所以nTableSize始终为2的n次方。

对于packed array,以如下代码为例:

for($i=0; $i<=4 ; $i++){
    $arr[$i] = 1; //packed array
}

执行后的数组结构如图5-24所示。

图5-24 PHP 7中packed array赋值后的示意图

说明:

  • packed array的nTableMask默认为-2。
  • packed array不会用到arData索引,因此省去了相关的索引内存。
  • arData和普通HashTable一样,都指向bucket数组的首地址。
  • bucket结构中的key默认为NULL,这主要是因为packed数组arr索引“i”的值的类型都是整数,“i”对应的hash值就是本身,所以arr的“i”直接存储在bucket.h上,无须冗余再去存储一个key,这点和普通的HashTable一致,bucket.key只存储字符串的key,为整数的时不再冗余存储。
  • 插入操作时,无须去计算hash值,也无须去维护索引数组,直接插入到bucket数组中去,插入的位置为idx=bucket.h=$i,非常简单,查找时也无须根据索引去找对应的存储位置,而是直接根据key值取到对应的bucket数组的val,效率极高。

5.3.4 插入、更新、查找和删除

前文已经讲述了数组初始化,普通的hash array和packed array的概念与区别,本节将讲述数组的插入、更新、删除和查找,其实这几个操作相对来说都比较简单,基本就是定位到元素所在bucket中的位置后进行写入、删除、查找。 PHP 7在zend_hash.h中提供了丰富的API来操作HashTable,包括前面提到的初始化以及接下来要讲到的插入、更新、删除、查询、遍历、拷贝、合并、排序、销毁等。本节将对数组元素的插入和更新操作进行详细讲解。

在zend_hash.h中,PHP 7定义了如下zend api,来对数组进行插入和更新操作。

/* additions/updates/changes */
ZEND_API zval* ZEND_FASTCALL _zend_hash_add_or_update(HashTable *ht, zend_string *key,
    zval *pData, uint32_t flag ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_update(HashTable *ht, zend_string *key, zval
    *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_update_ind(HashTable *ht, zend_string *key, zval
    *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_add(HashTable *ht, zend_string *key, zval
    *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_add_new(HashTable *ht, zend_string *key, zval
    *pData ZEND_FILE_LINE_DC);

ZEND_API zval* ZEND_FASTCALL _zend_hash_str_add_or_update(HashTable *ht, const char
    *key, size_t len, zval *pData, uint32_t flag ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_str_update(HashTable *ht, const char *key,
    size_t len, zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_str_update_ind(HashTable *ht, const char *key,
    size_t len, zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_str_add(HashTable *ht, const char *key,
    size_t len, zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_str_add_new(HashTable *ht, const char *key,
    size_t len, zval *pData ZEND_FILE_LINE_DC);

ZEND_API zval* ZEND_FASTCALL _zend_hash_index_add_or_update(HashTable *ht, zend_
    ulong h, zval *pData, uint32_t flag ZEND_FILE_LINE_DC);
ZEND_API  zval*  ZEND_FASTCALL  _zend_hash_index_add(HashTable  *ht,  zend_ulong  h,
    zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_index_add_new(HashTable *ht, zend_ulong h,
    zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_index_update(HashTable *ht, zend_ulong h,
    zval *pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_next_index_insert(HashTable *ht, zval
    * pData ZEND_FILE_LINE_DC);
ZEND_API zval* ZEND_FASTCALL _zend_hash_next_index_insert_new(HashTable *ht, zval
    * pData ZEND_FILE_LINE_DC);

从命名风格上可以分为3种API。

1)_zend_hash_xxx,用来插入或者更新字符串key,并且字符串key是指向zend_string的指针。

2)_zend_hash_str_xxx,同样用来插入或者更新字符串key,不过字符串key是指向字符的指针,同时还需要一个len表示字符串的长度。

3)_zend_hash_index_xxx,用来插入或者更新数字key。以如下代码为例:

$arr[] = 'foo'; //packed array,默认为packed array
$arr['a'] = 'bar'; //自定义key, packed_to_hash
$arr[2] = 'abc'; //自定义整数key
$arr[] = 'xyz'; //key取ht->nNextFreeElement
$arr['a'] = 'foo'; //自定义整数key
echo $arr['a']; //查找
unset($arr['a']); //删除

上述代码会首先执行zend_hash_init函数,对HashTable进行初始化,初始化后的结构如图5-25所示。

图5-25 代码执行初始化后HashTable示例图

此时,因为是packed array,所以nTableMask为-2,数组的大小nTableSize为8, nInternalPointer为-1, Bucket *arData对应的是未初始化的8个bucket。调用_zend_hash_next_index_insert函数将uninitialized_zval插入到HashTable中,然后将字符串foo(zend_string类型)拷贝到对应的zval中,完成以后如图5-26所示。

图5-26 将字符串foo插入HashTable后示例图

此时(ht)->u.flags |= HASH_FLAG_INITIALIZED | HASH_FLAG_PACKED修改为30, nNumUsed修改为1, nNumOfElements修改为1,同时nNextFreeElement修改为1,同时将foo对应的zend_string拷贝到第一个bucket中的zval中;

对于$arr['a'] = 'bar',首先调用zend_hash_find根据key='a’查找,查找不到对应的key,然后通过zend_hash_add_new把uninitialized_zval插入到HashTable中,此时因为之前是packed array,所以需要调用zend_hash_packed_to_hash进行转换。

① ht->u.flags &= ~HASH_FLAG_PACKED = 26,生成一个新的arData,调用memcpy拷贝过去,然后释放掉老的arData。

② 调用zend_hash_rehash进行rehash操作,生成的新的HashTable,如图5-27所示。

图5-27 将K-V:a=>bar插入HashTable后的示例图

其中,对于第1个位置的h值为9223372036854953478,与nTableMask按位或以后的值为-2:

(gdb) p  (int)(9223372036854953478 | 4294967288)
$1 = -2

nIndex索引部分-2对应的值为1,计算方法使用:

#define HT_HASH_EX(data, idx) \
    ((uint32_t*)(data))[(int32_t)(idx)]

对于$arr[2] = 'abc';,首先使用_zend_hash_index_find函数根据h=2来查找,查找不到的话,调用zend_hash_index_add_new将其插入HashTable中去,插入后的结果如图5-28所示。

图5-28 将K-V:2=>abc插入HashTable后的示例图

对于$arr[] = 'xyz',调用_zend_hash_next_index_insert;对于h,使用的是ht->nNext-FreeElement,此ht->nNextFreeElement==3,同样传入h=3调用zend_hash_index_find_bucket查找,查找不到的话,进行插入,插入后的结果如图5-29所示。

图5-29 $a[]=xyz插入HashTable后的示例图

对于$arr['a'] = 'foo',首先调用zend_hash_find_bucket,通过key=a查找,通过zend_string_hash_val可以计算h= 9223372036854953478,与图5-30中第1个bucket中的key=a的h值相同,然后计算出nIndex=h|ht->nTableMask, nIndex=-2,而-2位置对应1,找到arData的第1个位置,判断key是否等于’a',然后将对应的值改为’foo',并做优化,与第0个位置指向的zend_string是同一个位置,对应的内容是’foo',如图5-30所示。

图5-30 将K-V:a=>foo更新HashTable后的示例图

1)对于echo $arr['a'];,与数组查找类似,暂不赘述。

2)对于unset($arr['a']);,调用zend_hash_del进行删除,首先通过key=a调用zend_string_hash_val(key)查找到结果为第1个bucket。

从图5-31中可以看出,arData[-2]对应值改为-1, arData[1]对应的bucket的u1.v.type=IS_UNDF, nNumUsed并没有修改,还是为4,但是nNumOfElements减1,改为3。

图5-31 unused删除HashTable后的示例图

到此,从上面的例子中,我们完成了插入、更新、查找,以及删除的操作。

5.3.5 哈希冲突的解决

数据在插入HashTable时,不同的key经过哈希函数得到的值可能相同,导致插入索引数组冲突,理论上需要在索引数组外再加一个链表把所有冲突的value以双链表的形式关联起来,然后读取的时候去遍历这个双链表中的数据,比较对应的key。

PHP 7的hash array的做法是,不单独维护一个双链表,而是把每个冲突的idx存储在bucket的zval.u2.next中,插入的时候把老的value存储的地址(idx)放到新value的next中,再把新value的存储地址更新到索引数组中。

举个例子,假如第1、2、3个bucket发生哈希冲突,那么解决方法如图5-32所示。

图5-32 哈希冲突解决示意图

如图5-32所示,假设步骤如下。

1)插入第1个bucket,对应nIndex为-3,那么此时nIndex=-3的位置值为1。

2)若此时插入第2个bucket,与第1个冲突,也就是对应的nIndex也为-3,那么此时PHP 7是怎么做的呢?令nIndex=-3的位置值为2,同时将第2个bucket中zval里面的u2.next值置为1。这样,在查找第1个bucket的key对应的nIndex时,找到第2个bucket,校验key值不同,会取u2.next对应的1,取第1个bucket中的内容,与key校验,一致,则返回。

3)若此时插入第3个bucket,与第1个和第2个冲突,那么用同样的方式,令nIndex=-3的位置值为3,同时将第3个bucket中zval里面的u2.next值置为2。

通过1~3步,其实维护了一个隐形的链表,并用头插法插入新值。这样就实现了用链地址法解决哈希冲突。

5.3.6 扩容和rehash操作

前文已经说到,hash array在重置一个key时并不会真正触发删除操作,只做一个标识,删除是在扩容和重建索引时触发,本节将讲解什么时候触发扩容及重建索引,何时把已删除的数据清除掉。下面了解一下扩容和rehash的实现。

插入时触发扩容及rehash的整体流程如图5-33所示。

图5-33 扩容与rehash的整体流程

说明:

  • hash array的容量分配是固定的,初始化时每次申请的是2n的容量,容量的最小值为23,最大值为0x80000000。
  • 当容量足够时直接执行插入操作。
  • 当容量不够时(nNumUsed >=nTableSize),检查已删除元素所占的比例,假如达到阈值(ht->nNumUsed - ht->nNumOfElements >(ht->nNumOfElements >> 5),则将已删除元素从HashTable中移除,并重建索引。如果未到阈值,则要进行扩容操作(见图5-34),新的容量扩大到当前大小的2倍(即2*nTableSize),将当前bucket数组复制到新的空间,然后重建索引。

图5-34 rehash示例图

  • 重建完索引后,有足够的空余空间后再执行插入操作。重建索引的过程如图5-35所示。

图5-35 rehash索引示例图

说明:

  • rehash对应源码中的zend_hash_rehash(ht)方法。
  • rehash的主要功能就是把HashTable bucket数组中标识为IS_UNDEF的数据剔除,把有效数据重新聚合到bucket数组并更新插入索引表。
  • rehash不重新申请存内存,整个过程是在原有结构上做聚合调整。

具体实现步骤:

1)重置所有nIndex数组为-1;

2)初始化两个bucket类型的指针p、q,循环遍历bucket数组;

3)每次循环,p++,遇到第一个IS_UNDEF时,q=p;继续循环数组;

4)当再一次遇到一个正常数据时,把正常数据拷贝到q指向的位置,q++;

5)直到遍历完数组,更新nNumUsed等计数。

5.3.7 数组的递归保护

递归保护就是PHP 7在对HashTable进行递归操作时,防止引用次数太多而采取的一种保护机制。通过前面了解HashTable的基本结构,知道了u.flags是一个联合体,并且第2位nApplyCount用于记录该HashTable递归的次数。在需要采用递归保护时,HashTable会带有HASH_FLAG_APPLY_PROTECTION标记,然后会先将nApplyCount位加1,并在处理完成后将该标志减1,代码如下:

$a = [1,2,3];
$a[] = &$a;

对于数组$a,会通过对u.v.nApplyCount进行加1判断,如果大于1,判断代码为:

myht = Z_ARRVAL_P(struc);
if (level > 1 && ZEND_HASH_APPLY_PROTECTION(myht) && ++myht->u.v.nApplyCount > 1)
{
    PUTS("*RECURSION*\n");
    --myht->u.v.nApplyCount;
    return;
}

从代码中可以看到,对于$a,在循环调用时,nApplyCount会加1。一旦存在递归,则nApplyCount会大于1,这样就很容易判断递归的存在。

5.4 本章小结

本章详细探讨了PHP 7中数组的结构、数组的初始化,以及数组的插入、更新、查找和删除。另外,探讨了哈希冲突的解决,扩容和rehash的实现,以及数组循环引用的检测等。希望广大读者通过本章的学习,能够对PHP 7的核心数据结构HashTable有更深刻的认识。