本文翻译自 nikic 的一篇博文。

PHP7 中的 Hashtable 的实现

简要:这篇文章我也并不是按照原文逐字逐句的都翻译过来,其中略去了一些与本文知识点无关的内容,加入了一些个人理解,不过版权还是归原作者所有。文章主要讨论的是 PHP7.x 中的 新的 HashTable 的实现 。PHP7 中 Zend 引擎的核心代码中很大部分使用了占用内存更小效率更高的数据结构进行了重写!本篇文章就新版本中 HashTable 的实现以及为什么比之前的版本更加高效了进行研究。

本文中所有的知识介绍和内容总结都基于下面的实际案例。

构造一个含有100000个不重复的整型元素的数组并且测量其占用的内存数量,实现代码如下:

// 记录开始内存状态
$startMemory = memory_get_usage();
// 生成包含1-100000为数据元素的数组
$array = range(1, 100000);
// 获取数组占用的内存空间大小(单位字节:bytes)
echo memory_get_usage() - $startMemory, ' bytes';

下面是上面的代码分别使用PHP5.6 和 PHP7 在 32位系统和64位系统下的测试对比 :

版本    |   32 bit |    64 bit
------------------------------
PHP 5.6 | 7.37 MiB | 13.97 MiB
------------------------------
PHP 7.0 | 3.00 MiB |  4.00 MiB

上面的数据显示,PHP5中的内存占用是新版本PHP7的 2.5 倍!这个数据令人印象深刻!

hashtable 简介

其实PHP中的数组就是使用 HashTable 实现的! PHP中的数组是有序的字典,换言之,PHP中的数组是使用了 hashtable 实现的有序的 key/value 对。

哈希表是一个无处不在的数据结构,主要解决计算机只能直接表示连续的整型数组的问题,而程序员经常要使用字符串或其他复杂类型作为键(key)。

其实哈希表(hashtable)背后的实现逻辑并不复杂 : 可以通过散列函数将字符串类型的键(key)和一个整型值对应起来或者说转化成一个整型值。然后用这个整型值作为一个“普通”数组的索引。 但是有一个问题,在哈希表中有可能两个或者更多的字符串通过散列函数的转化可能对应着同一个整型值,这种现象叫做“冲突”。实际上一定会产生“冲突”,因为字符串的数量是无限多的,但是散列表的长度是有限的。所以对于散列表而言就需要有能够解决冲突的一些机制。

主要有两种"冲突"解决机制 :

  • 公开地址法 : 也称为 “再散列法”,这种方法会把发生冲突的元素存储到不同索引中!
  • 链地址法 : 发生冲突的元素存储到同一个索引上,然后在该索引下建立一个链表来存储这些元素。

PHP 使用的是 链地址法。

典型的 hashtable 的元素顺序并不是明确固定的 : 元素在数组中的顺序通过散列函数计算随机确定的!这就导致了其实对于哈希表而言并不像数组一样存储在确定的位置访问也会有确定的位置!所以哈希表需要一种特殊的机制来记忆数据到底放在什么地方了!

老版本 hashtable 的实现

这里我们首先对来把版本的 hashtable 的实现做一个简单的回顾,更加详细的内容可以参考 这篇文章。下面的图片已经高度概括了 PHP5 中的 hashtable 的实现。

enter image description here

上图中 “冲突解决” 部分是链表,其中的元素称为 "buckets"。每一个 "bucket" 的空间都独立分配。图中 "buckets" 部分的展示省略了值的存储,仅仅展示了 key 的存储。 真正的值实际上要存储在 zval 结构中,32位机器会分配 16 bytes 大小,64位机器会分配 24 bytes 空间大小。

图中还略去了一个很重要的内容 : “碰撞解决” 部分的链表实际上是一个双向链表!除了依赖“碰撞解决”链表外还要用到另外一个双向链表,里面存储着有序的数组元素。正如下图展示的一样,这里存储的是有序的 "a" , "b", "c":

enter image description here

仔细观察上面的两张图,可以分析一下为什么老版本的 hashtable 效率低下?为什么内存占用高?主要的原因如下:

  • Buckets 需要单独的分配空间。分配过程效率很低并且每一个还需要 8/16 bytes 的分配头信息。而且独立的空间也意味着 buckets 需要更多的内存空间并且缓存效率也会降低!
  • Zvals 也需要独立分配空间。继续拖慢了效率,而且也需要分配头信息!更过分的是每一个 bucket 里面都要存储一个指向 zval 的指针。所以对于老版本的 hashtable 而言,要实现这些功能需要的是两个指针而不是一个!
  • 由于使用的是双向链表,所以每一个 bucket 里面就需要四个指针!光指针就消耗掉了 16/32 bytes。更糟糕的是 遍历链表本身 也是一个耗费缓存的的操作!

所以基于以上问题,新版本已经(至少基本上)解决了它们!

新版本 zval 的实现。

详细研究前,先让我们看看新版本 zval 的实现以及指出与老版本的不同。新版本的 zval 结构定义如下:

struct _zval_struct {
    //变量实际的value
    zend_value value;
    union {
        struct {
            //这个是为了兼容大小字节序,小字节序就是下面的顺序,大字节序则下面4个顺序翻转
            ZEND_ENDIAN_LOHI_4( 
                //变量类型
                zend_uchar    type, 
                //类型掩码,不同的类型会有不同的几种属性,内存管理会用到
                zend_uchar    type_flags,  
                zend_uchar    const_flags,
                //call info,zend执行流程会用到
                zend_uchar    reserved)     
        } v;
        //上面4个值的组合值,可以直接根据type_info取到4个对应位置的值
        uint32_t type_info; 
    } u1;
    union {
        uint32_t     var_flags;
        uint32_t     next;                 //哈希表中解决哈希冲突时用到
        uint32_t     cache_slot;           /* literal cache slot */
        uint32_t     lineno;               /* line number (for ast nodes) */
        uint32_t     num_args;             /* arguments number for EX(This) */
        uint32_t     fe_pos;               /* foreach position */
        uint32_t     fe_iter_idx;          /* foreach iterator index */
    } u2; //一些辅助值
};

上述定义中你可以放心的忽略 ZEND_ENDIAN_LOHI_4 部分的宏定义。它是为了实现跨平台兼容大小字节序而设置的!

zval包含3大部分 :

  • value : 是 zend_value 类型, 长度是 8 bytes,可以存储不同类型的值,包含整型数据、字符串、数组等等,具体的类型由 zval 的 类型决定!
  • type_info : 是 unimt32_t 类型,长度是 4 byte,包含了预定义的类型信息值 (例如 _STRING 或者 IS_ARRAY), 也包含一系列的关于类型的附加信息。例如如果 zval 存储了一个 对象类型的值(object), 那么类型标志就会标志它是 非常量(non-constant), 引用类型(refcounted),可垃圾回收(garbage-collectible), 非复制(non-copying) 类型.
  • zval 的最后 4 个字节通常情况下并不会使用!值纯粹是个辅助值,假如zval只有:value、u1两个值,整个zval的大小也会对齐到16byte,既然不管有没有u2大小都是16byte,把多余的4byte拿出来用于一些特殊用途还是很划算的,比如next在哈希表解决哈希冲突时会用到,还有fe_pos在foreach会用到.

如果和老的实现方式对比,会发现一个明显的不同 : 新版的 hashtable 不再存储 引用计数器(refcount)信息。原因是 zval 不再单独分配空间,无论存储什么信息 zval 都会直接嵌入到里面,例如 hashtable bucket。

虽然 zval 不再记录引用计数器了,但是PHP中的复合数据类型(例如:字符串,数组,对象和资源类型)依然使用它!但是zval已经把引用计数器(以及垃圾回收器的相关信息)剔除掉并且移交给 array/object 等等这些数据结构了。这样做哟很多好处,这里列举了一些供大家参考:

  • Zval 仅存储简单的值(像 布尔值,整型值,浮点数)就不用需要请求分配独立空间了。这样就节省了配置请求头的开销, 通过避免不必要的请求和释放缓存提高了性能!
  • Zval 仅存储简单的值就不用存储引用计数器和垃圾回收收集器的缓冲信息。
  • 避免了重复引用计数。例如,以前的对象都使用变量引用计数和一个额外的对象的引用计数,这是必要的,对象之间要传递语义。
  • 所有复合类型的值现在嵌入一个引用计数器,这意味着它们可以不依赖于 zval 实现分享了!尤其是现在也可以实现字符串的共享了。这对哈希表实现意义非凡,因为它不再需要复制非限定字符串键值。

新版 hashtable 的实现

上面的内容只是一些铺垫,下面我们就可以正式开始讨论新版的 hashtable 的实现了!首先还是从 bucket 结构的实现开始!

bucket 结构定义:

typedef struct _Bucket {
    zend_ulong        h;
    zend_string      *key;
    zval              val;
} Bucket;

hashtabe 中一个 bucket 就是一个容器!里面包含了所有你想要的内容 : 一个 hash 类型的 h, 一个字符串类型的键 key,一个 zval 类型的 val。但是如果 h 中存储的是整型的键的话, key 将设为 NULL。

并且可以发现 zval 被直接嵌入到了 bucket 中,所以就不用单独分配空间了也节约了分配的开销。

主 hashtable 结构更有意思 :

typedef struct _HashTable {
    uint32_t          nTableSize;
    uint32_t          nTableMask;
    uint32_t          nNumUsed;
    uint32_t          nNumOfElements;
    zend_long         nNextFreeElement;
    Bucket           *arData;           // 数组元素的排序
    uint32_t         *arHash;           // hashtable 查找
    dtor_func_t       pDestructor;
    uint32_t          nInternalPointer;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    flags,
                zend_uchar    nApplyCount,
                uint16_t      reserve)
        } v;
        uint32_t flags;
    } u;
} HashTable;

buckets (也就是数组的元素) 被存储在一个 arData 数组中。 数组包含了两个强大的部分!第一个部分是 nTableSize (最小值是 8)存储了数组的容量。另一部分是 nNumOfElements 存储了真实的数据。 再就是要注意,数组中已经直接包含了 Bucket 结构。这就意味着,在我们使用指针数组分别分配 bucket 空间之前,我们需要更多的做分配/释放空间的事,需要跟多的开销和额外的指针。

元素排序

arData 数组按照元素插入的顺序存储元素,所以第一个素组元素要插入到 arData[0] , 第二个元素插入到 arData1 ,依次排序。再次强调的是这个不是由使用顺序决定的,仅仅由插入顺序决定!

所以如果你插入 hashtable 内五个元素,那么就意味着从 arData[0] 到 arData[4] 都会被使用,下一个空位置是 arData[5]. 我们需要知道的是空位置会存储在 nNumUsed 中。你可能会想:为什么非要单独存储这个值呢?难道和 nNumOfElements 不一样吗?

如果只有插入操做的话这两个是一样的!但是如果一个元素从 hashtable 中删除的话,我们总不能把 arData 中所有的元素都删掉再重新索引一个有序数组吧?所以,我们分别存储 nNumUsed 和 nNumOfElements,并且使用一个 avzl类型的 IS_UNDEF 来标记删除元素!

空说无凭,举例说明:

$array = [
    'foo' => 0,
    'bar' => 1,
    0     => 2,
    'xyz' => 3,
    2     => 4
];
unset($array[0]);
unset($array['xyz']);

arData 的结果如下:

nTableSize     = 8
nNumOfElements = 3
nNumUsed       = 5

[0]: key="foo", val=int(0)
[1]: key="bar", val=int(1)
[2]: val=UNDEF
[3]: val=UNDEF
[4]: h=2, val=int(4)
[5]: NOT INITIALIZED
[6]: NOT INITIALIZED
[7]: NOT INITIALIZED

上面的数组中的前五个元素被使用了,但是第二个位置(下标为 0) 和 第三个位置(下标是 'xyz') 的元素由于被删除了,所以已经打上了 UNDEF 的标记。那么这些空间现在就只剩下浪费空间了!然而,一旦 nNumUsed里面的值达到 nTableSize里面的值 PHP 就会尝试干掉所有打了 UNDEF 标记的元素来压缩 arData 数组。只有所有的 buckets 包含的数据真的达到了临界点,arData 才会真的把数组的容量扩展成原来的两倍。

这种维护数组元素顺序的新方法比起 PHP5.x 中使用双向链表的方法有几个优点。一个明显的优点是每个 bucket 中存储两个指针,对应着 8/16 bytes。另外我们又多了一种遍历数组的新方法:

uint32_t i;
for (i = 0; i < ht->nNumUsed; ++i) {
    Bucket *b = &ht->arData[i];
    if (Z_ISUNDEF(b->val)) continue;
               // do stuff with bucket
}

这相当于一个内存的线性扫描,它比链表遍历效率高得多 (在相对随机的内存地址之间来回往返)。

但是新的实现方法也有缺点,其中一个问题是 arData 从不自动释放空间(除非显示声明)。所以假如你首先创建了一个包含百万条元素的数组并且之后将这些元素释放掉,数组仍然占用大量的内存。所以我们如果数组的利用率低于某一水平我们应该减少一半的 arData 容量。

哈希表的查找实现

目前为止我一已经讨论了 PHP 数组排序的实现。下面我们一起来讨论一下 hashtable 查找的相关实现。hashtable 的查找要用到 HashTable 结构中的 unit32_t 类型的 arHash。arHash 数组的大小和 arData 一样,并且两个都在内存中分配一整块空间。

PHP中 hashtable 使用哈希算法(例如著名的 DJBX33A 算法)得到的哈希值是一个 32位 或者 64位 的无符号整型数值,这样的数值直接作为 hash 数组的索引实在太大了! 所以我们首先要把它变小!小到 nTableSize 的长度!可以通过 hash & (ht->nTableSize - 1) 来求到值, 并把 ht->nTableSize - 1 存入 ht->nTableMask。

接下来我们要在 hashtable 中查找索引 :

idx = ht->arHash[hash & ht->nTableMask] 

该索引对应着冲突解决链表的头信息。所以 ht->arData[idx] 是我们要检查的第一个元素。如果我们恰好是我们要找的键,那就完成了查找。

否则,我们要继续检测 冲突检测链表的下一个元素。下一个元素的索引存储在 bucket->val.u2.next 中。我们要一直遍历单钱链表,但是会有两种结果,要么可以找到正确的 结果bucket,要么一直走到最后碰到 INVALID_IDX 结束查找,也就是意味着没有我们要找的内容。

代码实现如下:

zend_ulong h = zend_string_hash_val(key);
uint32_t idx = ht->arHash[h & ht->nTableMask];
while (idx != INVALID_IDX) {
    Bucket *b = &ht->arData[idx];
    if (b->h == h && zend_string_equals(b->key, key)) {
        return b;
    }
    idx = Z_NEXT(b->val); // b->val.u2.next
}
return NULL;

当然,我们一起看一下这种实现方式到底比之前的好在哪儿 : PHP5.x 中的冲突解决使用了双向链表,新版本中使用了 uint32_t 索引来代替指针,这样在64位系统中可以节约一半的空间。另外 uint32_t 索引大小是4个字节,这就意味着可以直接存入到未使用的 zval slot 中,所以基本上我们就可以“免费”使用它了。

我们现在使用了单链表,也就没有了指向前一个元素的 “pre” 指针!但是知道前一个元素对于删除操作而言非常的重要,因为在链表中要删除一个元素我们必须调整删除元素的前一个元素的 “next” 指针!但是其实还可以按照键值删除,如果按照键值删除,在遍历链表的过程中我们也可以知道上一个元素是谁!当然也有一些特殊的情况有可能会发生,例如删除某个元素前所有的元素,那么就有可能要产生重复的遍历,不过这种情况并不多,所以还是优先考虑内存节约吧!

包装化哈希表 : Packed hashtables

PHP的数组是使用PHP内置的 hashtable 实现的。但是对于类似C语言的纯整型数组--PHP中的索引数组--而言,hashtable 并没有什么作用。所以PHP7 提出了 “packed hashtables” 的概念!我们就暂且翻译成 “包装化哈希表”。

在 经过包装的 hashtable 里 arHash 数组的会被设定为 NULL,并且搜索会直接索引到 arData。所以,如果arData[5]不为空,那么如果你想查找下标是 5 的值会直接定位到 arData[5] 而不是要去遍历冲突检测的链表结构。

不过需要注意的是即使是整型数组也需要按照插入顺序保持有序!相信大家都懂得 [0 => 1, 1 => 2] 和 [1 => 2, 0 => 1] 并不相同! 所以,包装化哈希表仅发生在默认是升序的整型数组中!当然,允许下标有间隔,但是必须是升序的!

还有一点就是 包装化的 hashtable 会存储很多没有的信息!例如我们可以通过内存信息确定一个 bucket 的索引,但是 bucket->h 仍然会被使用!再比如 bucket->key 的值是 NULL,会浪费大力量内存!

但是不论是否使用 包装化哈希表,为了使 bucket 的结构保持一致便于统一遍历还是要保留这些没用的值。当然,如果可能的话,有可能会使用纯粹的 zval 数组。

空hashtable

无论是PHP5.x 还是 PHP7.x 对于 空 hashtable 的处理都有些特殊。如果你仅仅创建了一个空数组但没有插入任何元素,arData/arHash 是不会被分配空间的!只有当 hashtable 插入第一个元素时它们才会被分配空间!

当然,PHP提供了更好的标记空hashtabel的方法,不用处处来检测一个hashtable是不是空的。这里用了一个小小的技巧 : 当 nTableSize 内的值设定为一个预定义好的标志值或者默认值8时,nTableMask 内的值会被设定为 0。那么有意思的事儿就发生了 : 如果hashtable是空的,那么 hash & ht->nTableMask 的值也就永远是 0。

所以当 hashtable 是空的时,整个 arHash 数组只需要包含一个索引为0的元素并且值是 INVALID_IDX。所以如果要查找某个值时,一旦找到 INVALID_IDX 值,就意味着当前 hashtable 是空的。

内存利用率

这些内容将涵盖PHP7的hashtable实现中非常重要的部分!首先让我们总结一下为什么PHP7的hashtable可以大幅降低内存占用!当然,我们仅讨论 64位系统下的每个元素数据,整个过程也忽略掉 hashtable的结构!

在PHP5.x 中一个元素的大小是144 bytes,而PHP7每个元素只占36 bytes,如果对hashtable进行包装化的话每个元素仅占34 bytes。下面列出来早就如此巨大差异的主要原因:

  • Zvals 不再独立分配空间,节约了 16 bytes 空间开销.
  • Buckets 不再独立分配空间, 再次节约了 16 bytes 空间.
  • 对于普通值,Zvals 减小了 16 bytes.
  • 隐含有序,不再需要双向列表保持有序,节约了 16 bytes.
  • 冲突解决使用单链表代替双向链表,省下了 8 bytes. 再就是它现在是一个索引列表并且下标直接内嵌入zval中,再次节约了 8 bytes.
  • zval 直接存入 bucket, 所以不再需要指向zval的指针. 节约了 16 bytes.
  • 键的长度不再需要存入到 bucket, 节约了 8 bytes. 但是如果键值的类型是字符串类型的,长度仍然要存储到 zend_string 结构中。所以这里的存并不能确切的统计出来,因为 zend_string 结构是共用的 。
  • 存储 冲突解决列表 的数组现在是索引类型的,所以每个元素可以节约 4 bytes。如果是包装化的数组连这个也不要了,又节约了 4 bytes。

需要说明的是我们在下面的总结比实际结果可能要更理想一些。

首先新版本的 hashtable 的实现中使用了更多的嵌入结构代替了需要独立分配内存的结构,这样做也会带来一些坏处。

如果你仔细观察了本文开头所展示的数字,你会发现在64位机器上新版本的PHP7实现的包含100000个元素的数组占用了4MB 内存空间。但是如果按照理想状态,这个数组应该占用 32 * 100000 = 3.05 MB 大小内存。多出来的这部分内存原因就出在我们处理的任何元素都包含了两种能力 : 主要功能即自身能力和辅助功能例如索引等。这会使 nTableSize 的大小为 2^17 = 131072 , 所以我们就有了 32 * 131072 bytes 即 is 4.00 MB 内存空间.

当然老版本的 hashtable 也会有两种能力,但是仅仅是回声明一个数组的 bucket 指针。其他东西都按需分配。所以在 PHP7 中我们浪费了 32 * 31072 (0.95 MB) 空间,在 5.x 只能够仅仅浪费了 8 * 31072 (0.24 MB) 空间。

另一个要考虑的事儿是假设数组中所有元素都存储一样的值会有什么结果。所以我们可以把开头的案例变成如下所示:

$startMemory = memory_get_usage();
$array = array_fill(0, 100000, 42);
echo memory_get_usage() - $startMemory, " bytes\n";

结果如下:

版本    |   32 bit |    64 bit
------------------------------
PHP 5.6 | 4.70 MiB |  9.39 MiB
------------------------------
PHP 7.0 | 3.00 MiB |  4.00 MiB

从上面的结果可以看出,PHP7中无论存储的是不重复的值还是相同的值,占用内存大小不会变化。其实这不奇怪,毕竟在PHP7中所有的 zval 都是独立的!但是在PHP5中,内存占用明显下降了,原因是所有的元素都会公用一个 zval !所以在这种情况下,新老版本的差异就小一些。

如果我们继续更加深入考虑其它更多信息例如字符串键或者更复杂的值情况就变得更复杂了。但无论如何,PHP7 总是比 PHP5 占用更少的内存,只不过不同情况它们的差距也会不同。

性能 我们已经讨论了很多内存相关的内容,让我继续研究下一个关键内容:性能。新版本PHP的目标是提高性能而不是仅仅降低内存使用率。当然正因为降低了内存,从而提高了CPU 缓存使用率,进而提高了性能。

当然性能的提高还应该归功于其它一些原因 :

  • 首先是内存分配操作减少了。其实内存分配操分配相当的耗费资源!然而我们每一个元素都可以节约两次内存的分配操作。
  • 数据遍历过程对缓存更友好。因为新版本中内存遍历不再是随机的而是线性的。
  • ......

当然还有更多的因素和理由,但是我们本篇文章重点是“内存”,所以在此对于性能问题就不多聊了。