PHP数组的内部实现你了解吗

目录
  • 前言
  • 探究
  • zval
  • zend_array
    • 结构介绍
    • 结构体
    • 数组插入操作
    • hash函数
  • 总结

前言

这几天在翻github的时候, 碰巧看到了php的源码, 就 down 下来随便翻了翻

那么PHP中什么玩意最引人注目嘞? 一定是数组了, PHP中的数组太强大了, 于是就想着不如进去看看数组的实现部分. 这篇文章打算全程针对代码进行解读了.

以下代码基于最新 php8.1. commitId: ea62b8089acef6551d6cece5dfb2ce0b040a7b83 .感兴趣的可自行下载查看.

探究

首先, 如此强大的数组功能应该是有单独文件进行定义的. 因此搜索了array.h array.c文件, 哎, array.c文件是存在的.

查看后发现, array.c文件中定义了PHP数组的系统函数, 比如krsort count等等. 但是, array的实现又在哪里呢?

随便找一个方法array_flip, 其中第一行定义变量如下:

zval *array;

也就是说, 方法接收的参数是结构体zval. 但是, zval这个结构体看名字应该是变量而不是数组啊. 果然, 再看下面变量的使用:

拿到变量后, 判断变量的类型, 会根据不同类型进行不同的处理.

那么这里为什么不直接接数组类型呢? 因为PHP的弱类型, 所有的变量都是zval, 其实际类型定义在zval结构体中. 这里顺便看一下zval结构体的实现:

(从这里开始, 下方所有内容不再详细说明查找过程, 反正就七找八找的)

zval

zval结构体定义在zend_types.h文件中, 这就是PHP弱类型的秘密了. 对其中各个部分的个人理解, 以注释的形式添加到代码中了.

/*
* 其在 大端和小端 使用了不同的顺序定义.
* 想来是为了解决大小端环境的问题, 保证在不同的设备上可以读取到相同的位
*/
#ifdef WORDS_BIGENDIAN
# define ZEND_ENDIAN_LOHI_3(lo, mi, hi)    hi; mi; lo;
#else
# define ZEND_ENDIAN_LOHI_3(lo, mi, hi)    lo; mi; hi;
#endif
// 对不同变量类型的定义
/* Regular data types: Must be in sync with zend_variables.c. */
#define IS_UNDEF					0
#define IS_NULL						1
#define IS_FALSE					2
// ...
// 进行结构体的重命名
typedef struct _zval_struct     zval;
/*
* 变量联合体定义.
* 此联合体和保存各种类型的变量
*/
typedef union _zend_value {
    zend_long         lval; // 8B
    double            dval; // 8B
    zend_refcounted  *counted; // 引用计数. 8B
    zend_string      *str; // 字符串. 8B
    zend_array       *arr;
    zend_object      *obj;
    zend_resource    *res;
    zend_reference   *ref;
    zend_ast_ref     *ast;
    zval             *zv;
    void             *ptr;
    zend_class_entry *ce;
    zend_function    *func;
    struct {
        uint32_t w1;
        uint32_t w2;
    } ww; // 8B
} zend_value; // 综上: 8B
// 变量的结构体
struct _zval_struct {
    // 使用 zend_value 联合体保存当前元素的值. 8B
    zend_value        value;			/* value */
    /*
    * 用来保存变量类型
    * 这里为什么要使用联合体呢?
    * 众所周知, 联合体中变量是共用内存的
    * 而其中的两个内容都是4字节的.
    * 因此, 我认为是为了方便使用.
    * 在统一操作时可使用 type_info, 有可以通过结构体分别获取每一位
    * (不过这只是个人理解, 没有进行求证)
    */
    union {
        uint32_t type_info; // 4B
        struct {
            ZEND_ENDIAN_LOHI_3(
                // 用来保存当前变量的类型. 也就是上面的一批定义. 1B
                zend_uchar    type,			/* active type */
                // 当前变量的一些标志位. 如: 常量类型/不可修改 等等. 1B
                zend_uchar    type_flags,
                union { // 2B
                uint16_t  extra;        /* not further specified */
            } u)
        } v; // 4B
    } u1; // 4B
    // 上面两个结构体共占用 12B, 而内存对其需要16B, 因此有4个字节是空着的
    // 下面的联合体可以将这4B 充分利用.
    // 这里根据不同的变量类型使用不同的变量. 比如: next, 在下面介绍数组的时候有用
    union {
        uint32_t     next;                 /* hash collision chain */
        uint32_t     cache_slot;           /* cache slot (for RECV_INIT) */
        uint32_t     opline_num;           /* opline number (for FAST_CALL) */
        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 */
        uint32_t     property_guard;       /* single property guard */
        uint32_t     constant_flags;       /* constant flags */
        uint32_t     extra;                /* not further specified */
    } u2;
};

zend_array

在查看zval的时候, 应该注意到其中的zend_array类型了. 不用看了, 看名字也知道, 数组就是它了.

为了在下面查看数组结构体时, 这里对PHP中数组的实现做一个简短的介绍.

结构介绍

众所周知, PHP中数组是通过hashTable实现的, 但是hashTable又是如何保证读取顺序的呢? 通过如下两个数组实现了一个有序 hash:

每次新增元素都向data 数组后面添加, 这样foreach的时候遍历data 数组, 读到的顺序就和放入的顺序是一样的了.

但是, 这不就是数组么? hash呢? 如何保证读取的高效呢? 在第二个hash 数组中, hash 数组中保存的是元素在data 数组中的索引.

从数组中读取keya元素的步骤是这样的:

1.计算ahash值为2

2.idx=indexList[2]

3.data=dataList[idx]

那么hash冲突又是如何解决的呢? 对于哈希冲突, 目前有开放寻址链表两种处理方式, 不过大部分实现都采用链表的方式. 这里也不例外.

数组中, b c d 的hash值均为4, 他们三个直接组成一个链表. 而index 数组中保存链表头的地址.

好, PHP数组的实现结构概念部分介绍完了. 接下来看一下PHP是如何实现的吧.

结构体

在介绍结构体代码之前, 还是得先上一个图. 在上方介绍中存在dataList indexList两个数组. 在PHP的实现中, 或许是为了节省空间. 将这两个数组合并成了一个, 只需要记录一个地址. 如下图:

上图的说明是为了防止你看到结构体中的union会懵. 一样的, 我将自己的理解放到注释中了.

typedef struct _zend_array      zend_array;
// 没毛病, 数组的别名就是 hashTable
typedef struct _zend_array HashTable;
// 用来保存数组中的数据
typedef struct _Bucket {
    // 当前元素值
    zval              val;
    // 当前元素的 hash
    zend_ulong        h;                /* hash value (or numeric index)   */
    // 元素的 key
    zend_string      *key;              /* string key or NULL for numerics */
} Bucket;
typedef struct _zend_array HashTable;
struct _zend_array {
    zend_refcounted_h gc; // 对数组进行引用计数. 8B
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                /*
                * 标志位. 其常量定义如下:
                * #define HASH_FLAG_CONSISTENCY      ((1<<0) | (1<<1))
                * #define HASH_FLAG_PACKED           (1<<2)
                * #define HASH_FLAG_UNINITIALIZED    (1<<3)
                * #define HASH_FLAG_STATIC_KEYS      (1<<4) // long and interned strings
                * #define HASH_FLAG_HAS_EMPTY_IND    (1<<5)
                * #define HASH_FLAG_ALLOW_COW_VIOLATION (1<<6)
                */
                zend_uchar    flags,
                zend_uchar    _unused,
                zend_uchar    nIteratorsCount,
                zend_uchar    _unused2)
        } v;
        uint32_t flags; // 4B
    } u; // 4B
    // 用来保存数组中的元素信息. 这是一个数组, 记录数组首地址.
    // 关于这里的 两个数组为什么使用 联合体记录, 在上图中解释了.
    union {
        // 用来读取上方的 hashList 8B
        uint32_t     *arHash;   /* hash table (allocated above this pointer) */
        // 用来读取上方的 dataList 8B
        Bucket       *arData;   /* array of hash buckets */
        // 当前数组中其实保存了两个数组, 但是对于key是连续数字的数组来说, arrHash 其实并不需要. 可以直接使用数组存储
        // 所以使用了 arPacked 来表示key全部为数字的, 通过标识位 HASH_FLAG_PACKED 来标识. 以节省内存占用
        // 所以, 其实对于连续数字的数组, 其底层真的是数组, 而不是 hashTable
        // 这里你可以简单的实验一下, 通过构造一个连续数字索引的数字, 然后在给其赋值一个key 为字符串的元素, 通过 memory_get_usage 函数查看内存的变化. 很明显的.
        zval         *arPacked; /* packed array of zvals */
    }; // 8B
    // 数组中存储的元素个数. 4B
    uint32_t          nNumOfElements;
    /*
    * 向数组中添加元素时, 使用的数组索引.
    * 此变量与 nNumOfElements 的区别是,
    * 当数组中元素释放的时候, 比如 unset 操作.
    * nNumOfElements 进行减一操作, 而 nNumUsed 变量不变.
    * 同时, 元素也并没有从数组中抹去, 仅仅是将其 type 修改为 IS_UNDEF
    * 等到下一次内存扩充的时候, 在将这些元素释放掉, 以保证释放的高效
    * 4B
    */
    uint32_t          nNumUsed;
    // 记录当前数组已经分配的地址大小. 2的 n 次幂(分配地址每次乘2). 4B
    uint32_t          nTableSize;
    // 计算 key 的 hash 散列值时使用(在下方具体介绍). 4B
    uint32_t          nTableMask;
    // 数组遍历是使用的游标, 如调用函数: next/prev/end/reset/current 等. 4B
    uint32_t          nInternalPointer;
    /*
    * 用来记录下一个元素插入时的默认 key.
    * 比如代码:
    * $arr = [];
    * $arr[] = 1; // 相当于 $arr[0]=1;
    * 但是, 你或许会疑惑, 这还需要单独记录么? 直接使用数组的大小计算不就行了?
    * 再看一段:
    * $arr = [];
    * $arr['a'] = 1;
    * $arr[] = 2; // 相当于 $arr[0] = 1;
    * 是不是懂了??
    * 8B
    */
    zend_long         nNextFreeElement;
    /*
    * 此方法在数组中的元素更新或删除时调用.
    * 若元素是引用计数的类型, 会更新其引用计数
    * 相当于元素的析构函数
    * 8B
    */
    dtor_func_t       pDestructor;
}; // 56B

nTableMask

nTableMask变量在计算元素的的散列值(在indexList中的索引)时使用.

首先在上面, indexListdataList大小相等, 且都等于nTableSize. 也就是说, 散列值的取值范围为: [-nTableSize, -1].

PHP中是如何处理的呢? 其计算规则为: nIndex = h | ht->nTableMask; 其中 nTableMask=-nTableSize.

这里简单证明一下, 还记得上面提到过, nTableMask的取值为2的 n 次幂. 我们假设长度为16. (为了简化逻辑, 以8byte 为例).

那么, nTableMask等于 -16, 其二进制补码表示为: 11110000. 我们分别使用两个极端值和nTableMask进行或运算.

1111000000000000进行或运算, 结果为11110000, 其值等于-16.

1111000001111111进行或运算, 结果为11111111, 其值等于 -1.

刚好与需要的取值范围相等. 既然是通过变量nTableSize计算得到的, 为什么要单独使用变量记录呢? 我想是为了效率吧. 毕竟hash取值的操作是很频繁的. 而位运算是很快的, 如果加上额外的计算操作会导致其效率下降.

数组插入操作

通过上面的介绍, 对于其插入操作应该如何实现想比心中有数了. 这里简单罗列一下:

//  判断需要时对数组进行扩容
#define ZEND_HASH_IF_FULL_DO_RESIZE(ht)				\
    if ((ht)->nNumUsed >= (ht)->nTableSize) {		\
        zend_hash_do_resize(ht);					\
    }
static zend_always_inline zval *_zend_hash_add_or_update_i(HashTable *ht, zend_string *key, zval *pData, uint32_t flag)
{
    // 一些额外处理...
    // 需要时对数组进行扩充
    ZEND_HASH_IF_FULL_DO_RESIZE(ht);		/* If the Hash table is full, resize it */
add_to_hash:
    // INTERNED 字符串不会被销毁, 用来实现相同字符串共用内存
    // 当数组中所有key 都是 INTERNED 字符串
    // 那么数组释放的时候就不需要释放 key 了, 同时数组 copy 的时候也不需要增加字符串引用计数
    // HASH_FLAG_STATIC_KEYS 标记位就是用来标记数组中所有 key 均为 INTERNED 字符串
    // 若当前字符串不是 INTERNED 的, 则修改数组的标记位
    if (!ZSTR_IS_INTERNED(key)) {
        zend_string_addref(key);
        HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
    }
    // 获取当前元素的 dataList index
    idx = ht->nNumUsed++;
    // 数组中元素内容增加
    ht->nNumOfElements++;
    // 元素赋值
    arData = ht->arData;
    p = arData + idx;
    p->key = key;
    p->h = h = ZSTR_H(key);
    // 计算 hashList index
    nIndex = h | ht->nTableMask;
    // 这一步就是用来处理 hash 冲突的
    // 将当前元素的 next 指向原来 hashList 中的值
    Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);
    // 更新 hashList
    HT_HASH_EX(arData, nIndex) = HT_IDX_TO_HASH(idx);
    // 对 val 进行赋值.
    // 这里判断标志位 HASH_LOOKUP, 然后将 val 置为 null. 这里看了半天没看懂其作用, 如果有知道的还望不吝赐教
    if (flag & HASH_LOOKUP) {
        ZVAL_NULL(&p->val);
    } else {
        ZVAL_COPY_VALUE(&p->val, pData);
    }
    return &p->val;
}

其他的数组操作函数这里就不再罗列了, 感兴趣的下载源码自己看一下吧.

hash 函数

在上面查看函数zend_hash_do_resize的时候, 突然想到了一个有意思的事情, 函数每次扩容都是乘2的操作. 如果说, 有一个长度为65536的数组, 每一个 key 的散列值计算后均为0, 那么hashTable不就退化为链表了么?

具体是什么思路呢? 第一个元素 key 为0, 那么根据长度取模, 第二个元素就是 65536, 第三个元素就是 65536*2, 这样每次插入的时候都需要遍历链表, 导致插入效率变慢. 整个demo 试一下.

<?php
// 统计函数的耗时
function echoCallCostTime($msg, $call){
    $startTime = microtime(true) * 1000;
    $call();
    $endTime = microtime(true) * 1000;
    $diffTime = $endTime - $startTime;
    echo "$msg 耗时 $diffTime", PHP_EOL;
}
$size = 2**16;
$array = [];
echoCallCostTime('异常数组-构造', function () use ($size, &$array){
    $array = array();
    for ($i = 0; $i <= $size; $i++) {
        $key = $size * $i;
        $array[$key] = 0;
    }
});
echoCallCostTime('异常数组-首个元素访问', function () use ($array){
    $b = $array[0];
});
echoCallCostTime('异常数组-最后元素访问', function () use ($array, $size){
    $b = $array[$size * $size];
});
echoCallCostTime('普通数组-构造', function () use ($size, &$array){
    $array = array();
    for ($i = 0; $i <= $size; $i++) {
        $array[$i] = 0;
    }
});
echoCallCostTime('普通数组-首个元素访问', function () use ($array){
    $b = $array[0];
});
echoCallCostTime('普通数组-最后元素访问', function () use ($array, $size){
    $b = $array[$size];
});

我们先按照这个逻辑推理一下, 异常数组的构造一定比普通数组耗时要久, 因为每次插入都要遍历链表嘛.

而且, 异常数组的首个元素访问时间要更新, 因为它现在出在链表的末尾, 要想访问它就要将链表遍历一遍. 看下结果:

和之前的推论丝毫不差, 而且性能相差很多倍哦. 不过这里hash算法的具体实现我没有看

总结

本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注我们的更多内容!

(0)

相关推荐

  • 关于PHP5和PHP7中数组实现方式的比较总结

    目录 ⒈ 数据结构 ⒉ 添加/修改元素 ⒊ 删除元素 ⒋ 数组遍历 ⒌ hash 碰撞 ⒍ 扩容 ⒎ PHP 7 中的 packed hashtable 总结 从 PHP 5 到 PHP 7 ,PHP 通过对 hashtable 数据结构和实现方式的修改,使得数组在内存占用和性能上有了很大的提升. ⒈ 数据结构 // PHP 5 中 hashtable 的数据结构定义 typedef struct bucket { ulong h; /*对于索引数组,存储 key 的原始值:对于关联数组,存储

  • PHP二维数组分页2种实现方法解析

    方法一: <?php $arr_click = array( array( 'clicks' => 3, 'clickDate' =>'2010-10-11' ), array( 'clicks' => 2, 'clickDate' =>'2010-10-11' ), array( 'clicks' => 3, 'clickDate' =>'2010-10-09' ), array( 'clicks' => 1, 'clickDate' =>'2010

  • php实现的数组转xml案例分析

    本文实例讲述了php实现的数组转xml.分享给大家供大家参考,具体如下: 0x00 需求 最近要做百度.360.神马搜索的网站sitemap,三家的格式都是xml,然而具体的细节还有有差别的. 一开始用的是dom,没有使用sax,写了几段便觉得太傻了,想到有没有数组转xml的库呢? 0x01 array2xml 搜索了一下,还真有地址为git,于是开始撸起袖子开始干. 示例如下: THE CODE: $xml = new ArrayToXML(); print $xml->buildXML($i

  • PHP7数组的底层实现示例

    PHP 数组具有的特性 PHP 的数组是一种非常强大灵活的数据类型,在讲它的底层实现之前,先看一下 PHP 的数组都具有哪些特性. 可以使用数字或字符串作为数组健值 $arr = [1 => 'ok', 'one' => 'hello']; 可按顺序读取数组 foreach($arr as $key => $value){ echo $arr[$key]; } 可随机读取数组中的元素 $arr = [1 => 'ok', 'one' => 'hello', 'a' =>

  • PHP 实现数组分页

    目录 array_slice array_chunk LimitIterator 参数错误时的表现 总结 今天,我们就来学习一下可以实现这个能力的一些函数技巧. 首先,我们还是准备好测试数据. $data = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', ]; // $p = $_GET['p']; $p = 2; $currentPage = $p <= 1 ? 0 : $p - 1; $pageSize = 3; $offs

  • php数组函数序列之each() - 获取数组当前内部指针所指向元素的键名和键值,并将指针移到下一位

    each()定义和用法 each() 函数生成一个由数组当前内部指针所指向的元素的键名和键值组成的数组,并把内部指针向前移动. 返回的数组中包括的四个元素:键名为 0,1,key 和 value.单元 0 和 key 包含有数组单元的键名,1 和 value 包含有数据. 如果内部指针越过了数组范围,本函数将返回 FALSE. 语法 each(array)参数 描述 array 必需.规定要使用的数组. 例子 1 复制代码 代码如下: <?php $people = array("Pete

  • PHP数组的内部实现你了解吗

    目录 前言 探究 zval zend_array 结构介绍 结构体 数组插入操作 hash函数 总结 前言 这几天在翻github的时候, 碰巧看到了php的源码, 就 down 下来随便翻了翻 那么PHP中什么玩意最引人注目嘞? 一定是数组了, PHP中的数组太强大了, 于是就想着不如进去看看数组的实现部分. 这篇文章打算全程针对代码进行解读了. 以下代码基于最新 php8.1. commitId: ea62b8089acef6551d6cece5dfb2ce0b040a7b83 .感兴趣的可

  • 约瑟夫环问题的PHP实现 使用PHP数组内部指针操作函数

    来看看这个问题的详细描述: view sourceprint?一群猴子排成一圈,按 1,2,...,n 依次编号.然后从第 1 只开始数,数到第 m 只,把它踢出圈,从它后面再开始数, 再数到第 m 只,在把它踢出去...,如此不停的进行下去, 直到最后只剩下一只猴子为止,那只猴子就叫做大王.要求编程模拟此过程,输入 m.n, 输出最后那个大王的编号. 刚开始构思的时候想使用 PHP 数组来实现(当然最后还是使用的数组),然后模拟一个数组的内部指针,结果发现想模拟一个"数组指针"不是那

  • Perl中的列表和数组学习笔记

    一.列表 列表是包含在括号里的一序列的值,可以为任何数值,也可为空,如:(1, 5.3 , "hello" , 2),空列表:(). 注:只含有一个数值的列表(如:(43.2) )与该数值本身(即:43.2 )是不同的,但它们可以互相转化或赋值.列表例: 复制代码 代码如下: (17, $var, "a string")     (17, 26 << 2)     (17, $var1 + $var2) ($value, "The answer

  • PHP循环遍历数组的3种方法list()、each()和while总结

    ①each()函数 each()函数需要传递一个数组作为参数,返回数组中当前元素的键/值对,并向后移动数组指针到下一个元素的位置.键/值对被返回带有4个元素的关联和索引混合的数组,键名分别为0.1.key和value.其中键名0和key对应的值是一样的,是数组元素的键名,1和value则包含有数组元素的值.如果内部指针越过了数组的末端,则each()返回FALSE.each()函数的使用如下所示: 复制代码 代码如下: <?php $contact = array("ID" =&

  • PHP 数组遍历顺序理解

    比如: <?php$arr['laruence'] = 'huixinchen';$arr['yahoo']    = 2007;$arr['baidu']    = 2008;foreach ($arr as $key => $val) {//结果是什么?} 又比如: <?php$arr[2] = 'huixinchen';$arr[1]  = 2007;$arr[0]  = 2008;foreach ($arr as $key => $val) {//现在结果又是什么?} 要完

  • 基于php常用函数总结(数组,字符串,时间,文件操作)

    数组:[重点1]implode(分隔,arr) 把数组值数据按指定字符连接起来例如:$arr=array('1','2','3','4');$str=implode('-',$arr);explode([分隔],arr)按指定规则对一个字符串进行分割,返回值为数组 别名joinarray_merge()合并一个或多个数组array_combine(array keys, array values) 创建一个数组,用一个数组的值作为其键名,另一个数组的值作为其值例如:$a = array('gre

  • 深入理解PHP之数组(遍历顺序) Laruence原创

    经常会有人问我, PHP的数组, 如果用foreach来访问, 遍历的顺序是固定的么? 以什么顺序遍历呢? 比如: 复制代码 代码如下: <?php $arr['laruence'] = 'huixinchen'; $arr['yahoo'] = 2007; $arr['baidu'] = 2008; foreach ($arr as $key => $val) { //结果是什么? } 又比如: 复制代码 代码如下: <?php $arr[2] = 'huixinchen'; $arr

  • php中的数组操作函数整理

    Array([key =>] value, [key =>] value, [key =>] value, [key =>] value) // key 可以是 integer 或者 string // value 可以是任何值 array_change_key_case -- 返回字符串键名全为小写或大写的数组 array_chunk -- 将一个数组分割成多个 array_combine -- 创建一个数组,用一个数组的值作为其键名,另一个数组的值作为其值 array_coun

  • php数组声明、遍历、数组全局变量使用小结

    php教程:数组声明,遍历,数组全局变量 复制代码 代码如下: <? /* * 一.数组的概述 * 1.数组的本质:管理和操作一组变量,成批处理 * 2.数组时复合类型(可以存储多个) * 3.数组中可以存储任意长度的数据,也可以存储任意类型的数据 * 4.数组可以完成其他语言数据结构的功能(链表,队列,栈,集合类) * * * * 二.数组的分类 * 数组中有多个单元,(单元称为元素) * 每个元素(下标[键]和值) * 单访问元素的时候,都是通过下标(键)来访问元素 * 1.一维数组,二维数

随机推荐