「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
前言
由于php简单的语法以及丰富的数据类型和数据操作函数, 使我们可以快速上手并开发应用, 尤其随着php7, php8在数据结构和运行时方面大刀阔斧的改革, 社区优秀的扩展如swoole的持续迭代优化, 使得php一直随着时代的进步而进步。个人认为php受众广泛的原因之一是因为提供了"数组"这个可靠, 而且非常易用的数据结构, php的数组实现了数组, hashmap的功能, 同时也保持了hash的有序遍历, 高效查找等特性, 是一个非常优秀的数据容器, 一起学习其中的原理和设计哲学可以带给我们很多启发。
php源码结构
本次实践源码版本为: 7.3.22, 不同小版本可能会有些许差异, 如当前版本op_code定义了编号: 0-198的集合, 8.0版本的定义了0-201的操作集合, php的扩容负载因子变化等。
|目录|说明| |-|-|-| | build |主要存放和编译相关的文件, 如构建脚本和环境检查等| | ext |官方扩展目录, 包含多数php扩展, 如mysql, redis等, 其中比较重要的子目录: standard 存放php标准数据操作函数实现等| | main |主要实现PHP的基本设施,这里和Zend引擎不一样,Zend引擎主要实现语言最核心的语言运行环境| | Zend |zend引擎核心实现目录, 包含php核心词法解析, OPCODE定义, hashtable实现和丰富的api| | sapi |应用服务器抽象代码层, 定义和实现不同模式的接入规范交互接口| | TSRM |线程安全资源管理器相关实现| | tests |测试用户集合, 包含全面的php测试用例| | win32 |和windows平台接口的交互相关实现|
php数组源码
根据之前总结的目录规范, 可以在目录: Zend/zend_types.h 中找到数组数据结构定义: _zend_array, 开发组在源码中对于数组结构的内存布局也做了比较形象的注释, 同理可以找到另外两个比较重要的结构体定义: Bucket, _zval_struct, 其中字段的摘要说明如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| nTableSize | uint32_t(unsigned int) | HashTable大小, 初始化为8, 始终是2^n, 32位系统最大为: 2^30, 64位系统最大为: 2^31 |
| nNumOfElements | uint32_t(unsigned int) | 有效的bucket数量, <= nNumUsed当unset一个bucket是并不会释放内存, 而是置为IS_UNDEF |
| nNextFreeElement | zend_long | 下一个可用的自然key, 如执行 $a[100] = 1, 则下一个可用自然key为101 |
| nNumUsed | uint32_t(unsigned int) | 所有已使用bucket的数量,包括有效bucket和无效bucket数量 |
| nTableMask | uint32_t(unsigned int) | 一般为-nTableSize |
| *arData | Bucket | 存储数据的容器结构, 其中包含 zval |
数组的具体执行流程大家可以通过调试zend_api做更多了解。
一段有意思的代码
执行结果
同样构造6w个元素, 理论上是非常easy的一件事, 结果却产生了危险的效果, 我们可以通过源码来一看究竟。
调试
通过观察第一段代码插入的key值, 可知依次为: 0, 65536, 131072, 196608, 262144, 327680, 458752, 524288, 589824, 655360..., 当执行:
$array[$key] = 0;
时, 首先会调用: zend_hash_index_find_bucket() 查找当前key是否存在,代码如下:
其中红框部分代码为我们调试定位增加代码, 当key值为: 131072 时, 数组内存结构如下:
当key值为: 262114 时, 数组内存结构如下:
可以看到产生了hash碰撞, 进一步调试可以看到当前数组未发生扩容, HashTable内存中各值如下:
和我们预先研究的结果一致, 此时我们放开调试, 将数组元素超过临界值, 再次过程中会同样数次发生hash碰撞, 当key值为: 589824时, 数组扩容为16, 当前时刻589824并未加入元素, 所以nNextFreeElement为:524828+1, 此时数组内存为:
有前面元素增加可知, 按照恶意方法构造的数组元素每次都会导致hash, 从而使数组退化成为逻辑链表。以此类推, 可以预测到后面元素的情况, 如增加到元素: 655360 时(第10个元素), 需要循环查询9次HashTable:
此时 *arData.val.u2.next 指向前一个元素的bucket下标:
循环9次后元素插入完毕。
流程分析
- 当 key = 0, 时:
h = 0;
nTableMask = 4294967294;
nIndex = h | nTableMask = 4294967294;
idx = ((uint32_t*)(data))[(int32_t)(idx)] = 4294967295;
此时未发生冲突, zend_hash_index_find_bucket()返回NULL, 直接插入。 2. 当 key = 131072 是:
h = 131072;
nTableMask = 4284969280;
nIndex = h | nTableMask = 4284969280;
idx = ((uint32_t*)(data))[(int32_t)(idx)] = 1;
此时索引表4284969280中指向的索引位置为1, 查看bucket元素
p = ((Bucket*)((char*)(data) + (1)))
p->h = 65536
不是当前key, 则调用宏: Z_NEXT() 继续查找:
idx = Z_NEXT(p->val) = p.val.u2.next = 0;
p = ((Bucket*)((char*)(data) + (0)));
p->h = 0;
直到:
idx = HT_INVALID_IDX = uint32 - 1 = 4294967295
退出查找流程, 为查找到key, 直接走插入流程;
- 以此类推, 当key = 262144 时, 数组内存结构为:
val = {value = {lval = ....}, u2 = {next = 4294967295, ....}}, h = 0;
val = {value = {lval = ....}, u2 = {next = 0, ....}}, h = 65536
val = {value = {lval = ....}, u2 = {next = 1, ....}}, h = 131072
val = {value = {lval = ....}, u2 = {next = 2, ....}}, h = 196608
val = {value = {lval = ....}, u2 = {next = 3, ....}}, h = 262144
- 下图模拟了数据插入的关键数据元素内存变化
由此可得, 插入需要遍历次数为: 1+2+3+4+5+…+65536 = 2147516416 次, 插入元素的时间复杂度退化为: O(n*(1+n)/2)=O(n^2)
总结
- 数据结构的转变: php5至php7数组实现由物理链表变为逻辑链表, 通过数据结构巧妙实现了高效的数组和HashTable操作, 设计思路非常值得我们学习和研究。
- 设计的取舍: 使用较多的字段保存某些关键信息, 使得运行中可以高效的获取一些关键信息, 如count, foreach等,同时也增加了维护成本, 类比到业务中, 缓存系统, 异步任务等设计也是在保证数据一致性的情况下通过额外的开发维护任务达到高性能的目的。
反思
- php的数组执行效率是否可以再继续提高? 个人认为仍然有比较多的压榨空间, 比如在执行插入操作时, 存在key的情况寻找和插入仍然需要重复计算
- hash冲突是否有可优化的策略?
各语言在处理hashmap数据类型时都有不同的策略, 个人认为在语言层面的处理上, 拉链法要优于开发寻址的不确定性, java的实现思路也是一种优秀解决方案:
- 首先设定冲突链表的长度
- 当长度达到一定阈值, 以key键值转化为红黑树, 其中key全部字符串化处理, 同时以典序排序
- 指针指向根节点
- 增删改查维护树结构
针对于不同的实际业务场景也各有优劣, 如php中可以设置: max_input_vars 值在一定程度上避免数据结构的风险。
参考:
- 陈雷老师 《php7底层实现与源码分析》
- 鸟哥: www.laruence.com/2011/12/30/…