【PHP源码学习】剖析PHP7数组的有序性

baiyan

案例

在 PHP7中,我们往数组中插入元素的顺序,就决定了我们数组遍历元素的顺序。可以说,PHP7中的数组是有序的。这个有序就是指元素插入数组时的顺序,与遍历时顺序的一致性。为了直观地让大家了解到PHP7数组的有序性,请看下面一段PHP代码:

<?php
$a = [];
$a['insert1'] = 'baiyan1';
$a['insert2'] = 'baiyan2';
$a['insert3'] = 'baiyan3';
foreach ($a as $k => $v) {
    var_dump($k . ':' . $v);
}

我们按照1、2、3的顺序向数组中插入key-value对,然后在循环体中打印遍历的顺序,结果如下:

string(15) "insert1:baiyan1"
string(15) "insert2:baiyan2"
string(15) "insert3:baiyan3"

然后我们反转插入元素的顺序,以3、2、1的顺序插入,其余代码不变:

<?php
$a = [];
$a['insert3'] = 'baiyan3';
$a['insert2'] = 'baiyan2';
$a['insert1'] = 'baiyan1';
foreach ($a as $k => $v) {
    var_dump($k . ':' . $v);
}

同样的,打印结果如下:

string(15) "insert3:baiyan3"
string(15) "insert2:baiyan2"
string(15) "insert1:baiyan1"

观察以上两组输出结果,我们可以看到,往数组中插入元素的顺序决定了遍历的顺序,PHP数组是有序的。

普通哈希表的问题:无序性

哈希表的无序性是指元素顺序与遍历顺序的不一致性。在PHP7中,为了达到查找某个key的复杂度为O(1),其内部是以hashtable的结构来实现的。先抛开PHP的实现不说,首先我们举一个一般的例子。通常情况下,一个hashtable长这样,每个存储单元被称为一个bucket(桶):
【PHP源码学习】剖析PHP7数组的有序性
这个哈希表很普通,它的大小为8,目前还没有任何元素插入,接下来我们插入上面的三条数据,假设对其key进行哈希运算的结果分别为4、2、6,插入之后的情形如下(key和value本来应该绑定在一起的,为了简化故省略value的书写):
【PHP源码学习】剖析PHP7数组的有序性
我们想一下,这样存储的问题都有哪些:

  • 元素之间的分布很零散,在扩容或缩容的时候不好处理
  • 插入与遍历的无序性

第一条不是我们此篇文章的重点。我们在遍历这个数组的时候,单看这张图,我们是不知道插入的顺序是什么样的,只能通过insert2、insert1、insert3的顺序遍历。所以,遍历的顺序与插入的insert1、insert2、insert3的顺序并不吻合,并不能达到我们在PHP7中数组的预期。

PHP7数组:解决普通哈希表的无序性问题

为了实现插入与遍历的顺序一致性,在PHP7中,增加了一个中间映射层,它的大小与哈希表相同,存储了元素在bucket中最终存储的位置。这样说可能大家还不太明白,让我们用图解一步一步来复现上一个案例的插入过程。我们先忽略哈希冲突的问题。首先我们插入insert1这个key-value对:
【PHP源码学习】剖析PHP7数组的有序性
首先,假设对key insert1的哈希运算结果为4,由于现在哈希表中的所有bucket均为空,所以我们可以利用第一个bucket空间来存储这个insert1。为了让后续的查找等操作能够顺利找到insert1,我们在映射表中下标为4的地方记录下insert1存储的位置,即bucket的下标0。这样,在查找的时候,根据这个hash值4,通过映射表就能够顺利找到insert1在bucket中存储的位置0。
然后我们继续插入insert2这对key-value对,同理,我们直接往后找可用的bucket,下标为1的bucket就是可用的,那么我们准备把insert2存入这里,同时利用映射表记下存储的bucket下标1:
【PHP源码学习】剖析PHP7数组的有序性
假设对key insert2的哈希运算结果为2,由于下一个可用的bucket下标为1,我们需要记录下这个1,而它的哈希运算结果为2,我们就在映射表下标为2的位置记录下insert2的存储bucket位置1。
到这里,我们可以发现,我们插入新元素的时候,会直接往后寻找可用的bucket位置,而这个位置是和之前插入的元素紧紧相邻的。这样,我们在foreach循环的时候,直接对这个bucket进行遍历,其遍历结果就是有序的。
如果你还没有明白,我们继续往中插入insert3这对key-value对:
【PHP源码学习】剖析PHP7数组的有序性
假设对key insert3的哈希运算结果为6,我们直接往后寻找可用的bucket,下标为2。我们需要记录下这个2,于是在映射表下标为哈希值运算结果6的位置,存储下这个下标2即可。
这样一来,我们直接去遍历这个hashtable,从bucket下标为0开始直接遍历到末尾,就能够得到与插入时候一摸一样的顺序,即insert1、insert2、insert3了,且元素之间没有碎片,提高了hashtable的空间利用率,方便扩容与缩容。
到这里,我们应该清楚了这个映射表的作用实现PHP7数组的插入与遍历顺序一致性
在PHP7中,为了方便映射表的访问,没有将映射表的空间额外单独去分配,而是直接分配在与hashtable中前一块相邻的内存空间中,这样通过一个指针,就可以同时访问映射表每一个bucket啦:
【PHP源码学习】剖析PHP7数组的有序性
在PHP7中,由于映射表的下标为负值,为了实现相同的功能,不能用我们之前直接使用哈希值做下标来存储bucket的位置,而是需要经过一步计算:

nIndex = h | nTableMask

由此,我们最后来看一下PHP中hashtable的结构,最重要的就是这个arData指针。通过它,我们可以同时访问映射表和哈希表中的bucket:

struct _zend_array {
    zend_refcounted_h gc;
    union {
        struct {
            ZEND_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;  //映射表以及哈希表的指针,利用arData[-x]访问映射表,利用arData[+x]访问哈希表中的bucket
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    uint32_t          nTableSize;
    uint32_t          nInternalPointer;
    zend_long         nNextFreeElement;
    dtor_func_t       pDestructor;
};

typedef struct _zend_array HashTable;

由于我们这篇文章没有提到哈希冲突的问题,我们这里讲到的是最简单的插入情况。至于在 PHP中如何解决插入时产生的哈希冲突问题,实际上是使用了数组模拟链表的思想,这里不再展开,以后我会再开一篇专题来进行讲解。