PHP 数组的内部实现

前言

这几天在翻github的时候, 碰巧看到了php的源码, 就 down 下来随便翻了翻. 地址: https://github.com/php/php-src

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

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

探究

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

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

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

zval *array;

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

image-20220306202851666

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

那么这里为什么不直接接数组类型呢? 因为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; // 8Bdouble            dval; // 8Bzend_refcounted  *counted; // 引用计数. 8Bzend_string      *str; // 字符串. 8Bzend_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 联合体保存当前元素的值. 8Bzend_value        value;			/* value *//** 用来保存变量类型* 这里为什么要使用联合体呢?* 众所周知, 联合体中变量是共用内存的* 而其中的两个内容都是4字节的.* 因此, 我认为是为了方便使用.* 在统一操作时可使用 type_info, 有可以通过结构体分别获取每一位* (不过这只是个人理解, 没有进行求证)*/union {uint32_t type_info; // 4Bstruct {ZEND_ENDIAN_LOHI_3(// 用来保存当前变量的类型. 也就是上面的一批定义. 1Bzend_uchar    type,			/* active type */// 当前变量的一些标志位. 如: 常量类型/不可修改 等等. 1Bzend_uchar    type_flags,union { // 2Buint16_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:

image-20220307205048722

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

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

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

  1. 计算ahash值为2
  2. idx=indexList[2]
  3. data=dataList[idx]

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

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

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

结构体

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

image-20220307212124533

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

typedef struct _zend_array      zend_array;
// 没毛病, 数组的别名就是 hashTable
typedef struct _zend_array HashTable;// 用来保存数组中的数据
typedef struct _Bucket {// 当前元素值zval              val;// 当前元素的 hashzend_ulong        h;                /* hash value (or numeric index)   */// 元素的 keyzend_string      *key;              /* string key or NULL for numerics */
} Bucket;typedef struct _zend_array HashTable;struct _zend_array {zend_refcounted_h gc; // 对数组进行引用计数. 8Bunion {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 8Buint32_t     *arHash;   /* hash table (allocated above this pointer) */// 用来读取上方的 dataList 8BBucket       *arData;   /* array of hash buckets */// 当前数组中其实保存了两个数组, 但是对于key是连续数字的数组来说, arrHash 其实并不需要. 可以直接使用数组存储// 所以使用了 arPacked 来表示key全部为数字的, 通过标识位 HASH_FLAG_PACKED 来标识. 以节省内存占用// 所以, 其实对于连续数字的数组, 其底层真的是数组, 而不是 hashTable// 这里你可以简单的实验一下, 通过构造一个连续数字索引的数字, 然后在给其赋值一个key 为字符串的元素, 通过 memory_get_usage 函数查看内存的变化. 很明显的. zval         *arPacked; /* packed array of zvals */}; // 8B// 数组中存储的元素个数. 4Buint32_t          nNumOfElements;/** 向数组中添加元素时, 使用的数组索引. * 此变量与 nNumOfElements 的区别是,* 当数组中元素释放的时候, 比如 unset 操作.* nNumOfElements 进行减一操作, 而 nNumUsed 变量不变.* 同时, 元素也并没有从数组中抹去, 仅仅是将其 type 修改为 IS_UNDEF* 等到下一次内存扩充的时候, 在将这些元素释放掉, 以保证释放的高效* 4B*/uint32_t          nNumUsed;// 记录当前数组已经分配的地址大小. 2的 n 次幂(分配地址每次乘2). 4Buint32_t          nTableSize;// 计算 key 的 hash 散列值时使用(在下方具体介绍). 4Buint32_t          nTableMask;// 数组遍历是使用的游标, 如调用函数: next/prev/end/reset/current 等. 4Buint32_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 indexidx = ht->nNumUsed++;// 数组中元素内容增加ht->nNumOfElements++;// 元素赋值arData = ht->arData;p = arData + idx;p->key = key;p->h = h = ZSTR_H(key);// 计算 hashList indexnIndex = h | ht->nTableMask;// 这一步就是用来处理 hash 冲突的// 将当前元素的 next 指向原来 hashList 中的值Z_NEXT(p->val) = HT_HASH_EX(arData, nIndex);// 更新 hashListHT_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];
});

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

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

image-20220307225236844

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

原文链接: https://hujingnb.com/archives/746

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/508355.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

go1.18新特性

前言 最近突然发现golang更新版本1.18了, 于是迫不及待的来看看这个版本加了些什么新特性. 没准就有之前困扰很久的问题, 在新版本被官方解决了呢. 先简单概述一下都有些什么变化, 后面再细说: 增加泛型的支持系统库方法增加修复 bug 另外, 像"系统内核更新"这种…

base64编码原理

引出 众所周知, ASICC编码共127个, 使用了7个bit进行编码. 而文件在存储的时候是以 字节为单位, 也就是8bit. 这就难免导致有一部分编码是没有定义在ASICC编码中的. 而在网络中传输二进制数据的时候(字符串本质上也是二进制数据嘛), 如果直接传输比特流, 倒也不是不可以, 只是…

页面加载速度-合并资源文件

前言 一直觉得自己的博客站点页面加载很慢, 就想着去优化一下. 呐, 下图是一次文章页面的加载, 需要2.5s. 其中 js 文件就有18个. 众所周知, 浏览器对资源文件的并行下载数量是有限制的(不同浏览器限制不同). 也就是说, 这18个 js 文件是无法同时下载的, 再说了, 页面中还有其…

hbase/thrift/go连接失败

问题 在通过Go连接hbase的过程中, 发现 get操作可以查到数据, 但是scanner命令访问数据失败, 也没有报错, 就是单纯的查不到数据. 而且Python PHP都一切正常. 这里简单复述一下我出现问题的情况, 安装过程和网上大部分内容一致, 这里简单列一下, 只是为了查询问题时参考安装过…

常用搜索引擎及语法

在平常需要进行搜索的时候是不是只知道Google Baidu ?? 他们其实是全文搜索引擎, 还有一些特定领域的搜索引擎. 而且, 搜索时可以添加特定语法, 让你的搜索事半功倍. 本文整理各种场景下使用的搜索引擎, 以及各个搜索引擎支持的语法, 不定期进行更新. 如果你知道其他搜索引…

自旋锁与互斥锁

前言 在编程中经常需要使用到互斥. 互斥就是, 这个事情只能有一个人干, 我正在做着的时候, 别人要想做这件事就得等我做完了. 互斥的实现是通过锁的机制, 也就是我把这块锁上了, 别人就进不来了, 等我做完再把锁释放掉. 但是, 前辈们已经证明了, 要想单纯的在软件层面上实现…

printf缓冲区踩坑

问题 碰到了这样一段代码(经过简化的): #include "stdio.h" #include "unistd.h" #include "sys/wait.h"int main(){fork();printf("1\n");fork();printf("1\n");wait(NULL);return 0; }这里我们简单算一下, 结果会打印几…

进程切换时是如何保存上下文的

前言 当前操作系统大部分采用分时的进程调度, 既每个进程运行一小段时间, 然后切换到下一个进程运行, 依次往复. 当进程运行的时候是独占CPU的, 此时操作系统是无法强行介入的, 为了将执行权让出来, 就需要硬件的配合了. 硬件每个一个时钟周期(比如10ms), 就会产生一个时钟中…

GO/testing包

前言 之前在写GO单元测试的时候, 使用了这个结构testing.T. 进来无事翻了翻, 发现testing包中还有一些其他的结构体, 想来是不同用处. 没想到GO的testing包竟然默默做了这么多支持, 之前竟然不知道. 在testing包中包含一下结构体: testing.T: 这就是我们平常使用的单元测试t…

CPU的分支预测

前言 最近在进行性能调优的时候, 碰到了这样的一段代码(为了展示问题而简化的代码): <?php // 第一次运行 $start microtime(true); for ($i 0; $i < 100; $i) {for ($j 0; $j <1000; $j) {for ($k 0;$k < 10000; $k) {}} } $end microtime(true); echo fi…

Golang Context 简介

前言 在写Golang程序调用各种第三方库的时候, 经常会传一个叫做Context的参数. 之前基本上见到接Context, 根本不管是干什么用的, 直接无脑context.Background(). 但是, 传着传着就不免发生一些小疑问, 这个参数到底是干什么用的呢? 这么多库都在使用, 至少说明其是Golang中…

PHP获取Opcode及C源码

是什么 在开始之前, 必须要先介绍一下Opcode是什么. 众所周知, Java在执行的时候, 会将.java后缀的文件预先编译为.class字节码文件, JVM加载字节码文件进行解释执行. 而字节码文件存在的意义, 就是为了加速执行. 那么PHP的Opcode与之类似, 也是从.php文件到执行的过程中, 所…

PHP require/include 区别

前言 在PHP中, 载入文件可以选择使用require, 也可以使用include, 那么那他们有什么区别呢? 看了网上的一些文章, 说他们使用场景不同, require一般在文件开头引入文件, include一般在函数中动态引入文件. 但是我觉得并不是这么简单, require是作为语言结构(关键字)出现的, …

RESTful API规范

前言 我现在工作的公司是在毕业前实习的公司, 实习结束后直接转正, 因此也是我任职过的唯一一家公司. 在日常工作进行 HTTP 接口的开发时, 发现了一个疑惑, 只用到了POST和GET请求, 但我们知道 HTTP还有PUT/DELETE等等, 为什么不用呢? 并且, 接口的响应码也只有200, 接口是…

Golang 接口原理

问题 小提示, 若想直接查看原理, 可从接口原理开始查看. 有这样一段GO代码: func main() {var obj interface{}fmt.Printf("obj nil. %b\n", obj nil)type st struct{}var s *stobj sfmt.Printf("s nil. %b\n", s nil)fmt.Printf("obj nil. …

Docker kill 1无效

前言 我们在平常强制停用一个进程的时候, 会选择什么命令? 一般在测试使, 不考虑程序突然中断带来的影响, 直接使用kill -9 pid强制停止就行. 但是, 就在刚刚, 我启动了一个docker容器, 进入容器后执行命令kill -9 1没有任何效果??? 啊这, 为什么呀? 尝试 为了解释这个…

容器内存相关知识

这篇文章是我研究容器内存整理出的相关内容. 前后内容并没有上下文关系, 每个知识点都可以单独查看. 内存控制 使用这样的命令启动一个容器docker run -d -m 300M xxx. 可以限制容器使用的内存最大为300M. 那么docker是如何实现容器的内存限制呢? 其实是操作系统已经做好了…

三星识别文字_比亚迪电子助力三星Galaxy Note 10系列霸气首发!

三星有子初长成气宇轩昂 秀美俊逸减之一分则嫌柔增之一分则嫌赘2019年8月7日于纽约巴克莱发布Galaxy Note 10系列用简约 重构美三星Galaxy Note 10与Galaxy Note 10分别搭载了6.3英寸和6.8英寸的超感官全视曲面屏&#xff0c;均采用单摄挖孔屏&#xff0c;开孔位于屏幕正上方。…

lisp 设计盘形齿轮铣刀_机械设计基础——周转轮系传动比的计算

点击上方蓝色字体&#xff0c;关注我们15(视频来源于网络&#xff0c;仅供学习交流&#xff0c;侵权请联系删除)机械计重点学习指导机械原理全书重点提要轴的结构改错机械设计作业集01机械设计作业集02机械设计作业集答案机械原理作业集机械原理作业集答案轴的强度计算院校推荐…

b+树阶怎么确定_B站公布年度弹幕,这个排名我不太服气

也忘记了是从什么时候开始&#xff0c;B站开始公布自己的年度弹幕了&#xff0c;今年的年度弹幕排名前五的分别是&#xff1a;爷青回、武汉加油、有内味了、双厨狂喜、禁止套娃。话说今年真的是不容易啊&#xff0c;过年那段时间以及上半年不会忘记那一幕幕感人深邃的瞬间&…