拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从array_merge到PHP HashTable底层原理与性能优化

1. 从一次“顺手”的 array_merge 聊起它背后藏着什么先说一个场景。我早年写业务代码的时候array_merge用得相当顺手几个配置数组合并、接口返回数据合并、把两个查询结果攒成一个大数组一行搞定又干净又省事。直到有一次我在一个循环里对一个大数组反复调array_merge几百万次跑下来内存直接飙到让人头皮发麻的地步接口超时、机器告警大半夜被叫起来排查。那时候我才第一次正视一个问题array_merge这么好用底层的 PHP 到底是怎么把两个数组合在一起的如果只是“把元素倒进去”为什么会有这么大的性能损耗顺着这个问题往下钻就绕不开 PHP 数组的底层实现——HashTable。可以说数组在 PHP 里的一切行为包括array_merge的效率、数组遍历的先后顺序、unset之后内存为什么不降、array_rand为什么可以对关联数组生效全都由 HashTable 的结构和算法决定。这篇文章就以array_merge为入口一步步拆开 PHP HashTable 的内存布局、扩容算法和合并行为。我会把源码逻辑尽量讲得通俗同时结合我自己实际踩过的坑和做过的压测数据让你能真正把这些底层知识用到业务优化和面试表达里。适合谁来读想搞明白 PHP 数组底层原理的中高级开发者被数组性能问题困扰的业务研发以及正在准备 PHP 高级岗位面试、希望把“数组原理”讲出深度的人。花二十分钟读完你会得到一个很清晰的底层认知框架。2. HashTable 在 PHP 7 里到底长什么样2.1 数组背后不是“数组”而是一张散列表很多刚接触 PHP 的人会误以为数组就是 C 语言里那种连续内存的数组。如果是那样关联数组字符串键就根本没法解释——字符串怎么直接当下标所以 PHP 的数组实际是用散列表Hash Table实现的键通过散列函数换算成一个索引值存在对应的桶Bucket里。为什么要用散列表因为它兼顾了两件事插入/删除/查找的平均时间复杂度都是 O(1)同时还能保持元素的插入顺序。第二点非常关键PHP 数组是有序的你按什么顺序$arr[$k] $v放进去遍历的时候就是什么顺序这一点很多其他语言的 Map 是做不到的比如老版本 Go 的 map 遍历顺序就不保证。所以 HashTable 在实现散列的同时必须额外维护一套“顺序逻辑”。2.2 zend_array 结构体核心字段逐个说PHP 7 开始数组的底层结构体叫zend_array在 Zend/zend_types.h 里定义。我把关键字段拎出来逐个讲清楚它们的职责typedef struct _zend_array HashTable; 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; // 桶数组指针真正的数据存储区 uint32_t nNumUsed; // arData 中已用 bucket 数包含被标记为删除的 uint32_t nNumOfElements; // 数组中实际有效元素个数 uint32_t nTableSize; // 桶数组总大小2 的 n 次方 uint32_t nInternalPointer; // 内部遍历指针 zend_long nNextFreeElement; // 下一个自动分配的数字下标 dtor_func_t pDestructor; // 元素析构函数 };一眼看过去可能有点懵但抓住几个关键点就够了arData是真正存数据的地方一个Bucket数组。nTableSize是当前桶数组的容量注意它一定是 2 的 n 次方。nNumUsed是“用过多少个桶”nNumOfElements是“有效元素多少个”。这俩不一样因为 PHP 删除元素时不会真正把桶清掉只是标记一下后面细说。nTableMask是槽位计算的关键它等于-nTableSize二进制补码用哈希值和它做位与运算就能把任意哈希值映射到合法的索引区间。2.3 Bucket一个桶里装着哈希、键、值桶的结构体在同一个文件里typedef struct _Bucket { zval val; // 存储值 zend_ulong h; // 哈希值数字键直接就是键本身 zend_string *key; // 字符串键NULL 表示数字键 } Bucket;h和key是两个关键字段。对于字符串键h是字符串通过哈希函数算出来的zend_ulong值key指向zend_string。对于数字键h直接就是键的值key是 NULL。看到这里你可能会有个朦胧的感知数组里存储的“键”信息和用于定位的“哈希索引”是分离的。哈希值负责快速定位到某个桶而真实键负责在冲突时做精确匹配。这就是散列表的通用套路。2.4 内存布局arData 为什么会“负索引”这是 HashTable 内存布局里最精彩、也最容易被忽视的一段。直接看代码很难看出门道我换个讲法。PHP 在创建数组时会一次性分配一块连续内存大小是sizeof(Bucket) * nTableSize。但arData并不指向这块内存的开头而是指向开头偏移sizeof(Bucket) * nTableSize的位置也就是这块内存的结尾处。为什么要这么干因为 PHP 想用一个arData[-idx]的“负索引”来同时处理顺序遍历和散列定位。你看宏定义就明白了#define HT_HASH_EX(idx, p) \ ((p)-arData[(idx)])散列索引数组被放在arData指针的“前面”低地址方向桶数组本身被放在arData的“后面”高地址方向。这样散列槽位的计算可以用负偏移arData[nTableMask]实际上就是索引数组的起始位置。元素的顺序遍历可以顺着arData[0]、arData[1]、arData[2]……一直往前走因为新元素永远追加在桶数组的末尾内存天然有序。这个设计非常巧妙。它把“哈希查找”和“插入顺序”两个需求统一到了一块连续内存里索引数组负责快速定位桶数组负责记录顺序。代价是牺牲了一点内存对齐的直观性但换来的是遍历的高效——遍历数组时不需要跳转直接顺序读内存CPU 缓存命中率极高。我记得第一次在调试器里看arData地址的时候一度以为是 bug——怎么数组起始指针指向了内存块的末尾后来理清负索引的设计意图才明白这一手是 PHP 性能优化里非常典型的一笔。3. 数组的插入、查找、删除散列运算的完整链路3.1 哈希值怎么算从 time33 到索引定位PHP 7 里字符串键的哈希函数是经典的time33算法也叫 DJBX33A源码在 zend_hash.hstatic zend_always_inline zend_ulong zend_inline_hash_func(const char *str, size_t len) { zend_ulong hash Z_UL(5381); while (len--) { hash ((hash 5) hash) *str; } return hash; }初始值为 5381每读一个字节hash hash * 33 c。这个算法简单、快而且对字符串的分布足够均匀几十年下来经受住了大量生产环境的考验。拿到hash之后下一步是把它映射到索引数组的下标。这里用的是位与运算不是取模#define HT_HASH_INDEX(ht, h) \ ((h) (ht)-nTableMask)由于nTableMask -nTableSize而nTableSize是 2 的幂所以nTableMask在二进制上就是“最高位及以下全是 1、低位补 0”的形态。比如nTableSize 8那么nTableMask -8二进制是...11111000。任何整数与它做运算结果一定落在 0 到 7 之间正好对应 8 个槽位。用位与代替取模是因为位运算比除法快得多。这里顺带引出一个重要认知正因为槽位数量是 2 的幂HashTable 才能用位与做槽位定位。这是后面讲扩容时“为什么容量总是翻倍”的底层原因。3.2 冲突解决链地址法但是“退化”成索引链表散列函数再好也不可避免会发生冲突——两个不同的键算出的哈希值在同一个槽位上。PHP 的冲突解决方式是链地址法但实现方式很特别。传统散列表的做法是每个槽位挂一个链表节点单独分配内存。PHP 的做法是索引数组里存的是“下一个同槽位桶在 arData 中的下标”利用新的桶来构造一条逻辑链表。它的工作流程是新元素准备好取当前nNumUsed作为它的桶下标。根据哈希值和掩码找到槽位idx。如果槽位是空闲的值为HT_INVALID_IDX也就是-1就把新桶下标填进去。如果槽位已经被占了发生了冲突就把原本槽位里存的“旧桶下标”作为新元素的“下一个指针”存入新桶的数据区再把槽位更新为新桶下标。你可能要问那同一个槽位的旧数据存哪里怎么找到它关于这个“下一个桶下标”的存储位置我再补一句——PHP 在Bucket结构体之外为每个 bucket 额外用了一段内存在存储“链表的下一个索引”这是通过arData前面那块索引区之外的另一段辅助数据实现的。不过这部分的细节不影响主线理解你只需要知道它的作用当冲突发生时HashTable 通过形成一条“同槽位链”来保证所有元素都能被找到。查找时先定位槽位拿到链表的头节点然后顺着链表逐个比较key字符串键或h数字键直到命中。当哈希函数分布均匀链表长度很短平均查找复杂度依然是 O(1)。3.3 删除为什么不会立即回收内存在 PHP 里执行unset($arr[$key])元素并不会立刻从内存里消失。看这段简化逻辑Bucket *p ht-arData idx; if (p-key) { zend_string_release(p-key); } if (ht-pDestructor) { ht-pDestructor(p-val); } p-val tmp_zval; // 用空值覆盖 p-h 0; p-key NULL;实际上PHP 会把某个 bucket 标记为“空洞”同时把nNumOfElements减一但nNumUsed不变。这个桶在遍历的时候会被跳过。只有当空洞比例高到一定程度比如元素数少于容量的 1/2 且容量大于某个阈值才可能触发压缩重建zend_hash_rehash或通过array_merge等操作隐式触发。这也是为什么你往数组里塞了几百万个元素再全unset内存占用却迟迟不降——那些桶和已释放的 key 字符串占用的空间很多还留在内存里等着被复用或触发压缩。理解了这一点你就明白为什么业务代码里频繁“插入-删除-插入-删除”的大数组最终会表现出偶发性的性能抖动 HashTable 内部可能积攒了大量空洞查找链变长内存碎片化。优化手段之一就是在大规模清洗数据后用array_values()之类的操作强制重建数组。4. 深挖扩容算法2 倍增长背后的数学逻辑和性能底线4.1 什么时候触发扩容负载因子 0.75 的由来HashTable 并不是满了才扩容。PHP 的判定条件是#define HT_MAX_SIZE (1 31) static zend_always_inline void zend_hash_check_size(uint32_t nSize) { // 当有效元素数大于 nTableSize * 0.75 时下一步插入会触发扩容 }源码里并没有一个显式的“负载因子”变量但结论是等价的当nNumOfElements 1 nTableSize * 3 / 4也就是元素数量达到容量的 75% 时下一次插入就会触发扩容。为什么是 0.75这和散列表的查找性能直接相关。负载因子越高冲突概率越大链表越长查找效率越低。太低呢浪费内存。0.75 是空间和时间的一个经典平衡点JDK 的 HashMap 也用了差不多这个阈值0.75。我做了个简单测试来验证这一点的实际意义。一个容量为 1024 的 HashTable如果负载因子是 0.99那么槽位几乎全被占满每个位置的冲突链长度平均接近 1当负载因子是 0.75 时约有四分之一的位置是空的冲突概率显著下降查找时链表的期望长度要短得多。对于 PHP 这种动态语言数组操作极频繁选择 0.75 的阈值是很务实的。4.2 扩容过程翻倍、重建索引、重新散列一旦触发扩容PHP 会执行这些关键步骤新容量 旧容量 * 2始终是 2 的幂。分配新的Bucket内存块和新的索引数组。遍历旧桶数组把有效元素不是空洞重新插入到新 HashTable 中重新计算哈希索引。释放旧内存块。这个“重新散列”的过程是 O(n) 的。所以扩容不是每次插入都会发生只有当容量不足时才发生一次均摊下来插入复杂度还是 O(1)。但是这里有个隐蔽的性能陷阱如果一个数组反复在负载因子边界附近增删元素就可能频繁触发“扩容-压缩”的震荡。极端情况下插入一个元素引发一次全量 rehash删除一个元素又引发一次压缩性能会非常难看。我在做长生命周期数组比如常驻内存的 Swoole 进程里用来存 WebSocket 连接信息时就踩过这个坑后面专门用预设容量的方式解决了。4.3 预设容量一个被很多人忽略的数组性能优化点PHP 数组是自动扩容的所以普通场景下你根本不需要关心初始容量。但如果你明确知道自己要往数组里塞多少元素可以提前用SplFixedArray或通过构造方式预分配内存避免多次扩容带来的 rehash 开销。实测数据最能说明问题。我用 PHP 8.2 做了一个简单压测向数组中插入 100 万个整数分别用“直接往普通数组里塞”和“先用SplFixedArray预分配容量再填充”两种方式。方式耗时内存峰值普通数组自动扩容约 210ms约 68MBSplFixedArray 预分配约 150ms约 32MB差距非常明显。普通数组在插入过程中触发了多次扩容每次都要重新分配内存和 rehash预分配则完全绕开了这些开销。当然SplFixedArray有它自己的限制——键只能是整数且长度固定但它的确提供了一种可行的思路在能预估容量的高性能场景主动管理内存布局减少 HashTable 的动态扩容。另外补充一句那个“0.75 阈值”也意味着 HashTable 实际利用率最高只有 75% 左右的容量。为什么这么设计因为你分配的内存实际能存的有效元素只有容量的四分之三。这也是 PHP 数组比同数据量的 C 数组更吃内存的原因之一。5. 庖丁解牛 array_merge一次合并操作在内存里究竟干了什么5.1 array_merge 的核心语义右侧覆盖左侧键名决定去向回到本文的入口array_merge。先明确它的行为$a [name 张三, skills [PHP, MySQL]]; $b [name 李四, age 25]; $result array_merge($a, $b); // $result [name 李四, skills [PHP, MySQL], age 25]字符串键后出现的覆盖先出现的键保持不变。数字键不会覆盖而是重新编号从 0 开始追加排到后面。这个“数字键重新编号”的行为是array_merge和array $b联合运算符最本质的区别。array $b是“左侧优先同名键不覆盖”array_merge是“右侧优先数字键重排”。如果你不清楚这一点合并配置数组时很容易被隐蔽的键覆盖Bug坑到。5.2 从源码看 array_merge 的完整执行链路array_merge的实现逻辑在ext/standard/array.c的php_array_merge函数里。我简化核心流程先遍历所有参数数组计算出合并后的元素总数total_size。创建目标 HashTable并用zend_hash_init初始化初始容量设置为不小于total_size的 2 的幂。遍历每个源数组逐个元素执行插入如果键是字符串zend_hash_update更新存在则覆盖。如果键是数字zend_hash_next_index_insert插入到下标为nNextFreeElement的位置然后nNextFreeElement。注意第 2 步——一次array_merge会预估总容量目标数组基本不会再触发扩容。所以单次array_merge的性能是很不错的值得放心用。真正的性能隐患在于第 3 步里每个元素的拷贝逻辑。在zend_hash_merge的底层实现中元素的插入不是简单地把zval指针复制过去而是要执行ZVAL_COPY_VALUE和Z_TRY_ADDREF_P这样的操作——对它引用的值做引用计数增减。如果值本身是一个大的字符串或嵌套数组还要考虑写时复制Copy On Write机制。值本身不 deep copy只是引用计数加 1这已经相当高效了但如果这个 zval 是一个 IS_INDIRECT 或者 IS_REFERENCE 类型那就更复杂。5.3 为什么很多人说“循环里别用 array_merge”网上流传很广的一句话不要在循环里反复用array_merge去追加数组。这句话需要拆开看。先看一个反面示例$result []; for ($i 0; $i 100000; $i) { $result array_merge($result, [$i]); }这段代码的时间复杂度是 O(n^2)。原因很简单第$i次循环时array_merge要拷贝当前结果数组里的所有元素而结果数组已经积累了$i个元素所以总操作次数是1 2 3 ... n n(n1)/2。我拿 10 万次循环实测耗时达到 4 秒以上。正确的做法是用$result[] $i这种原地追加方式时间复杂度是 O(1) 均摊。或者如果你确实需要合并一个已知的小数组到结果末尾直接用array_push($result, ...$items)注意...展开对元素数量有限制或者 foreach 追加。但这里要澄清一点array_merge本身并不慢慢的是“反复合并已经很大的数组”。一次性的array_merge($bigA, $bigB)性能很好因为它预分配了容量只需要一次遍历拷贝。关键是要避免它在循环中成为累加器。5.4 array_merge 之后的内存表现耗时低但峰值内存可能翻倍array_merge有一个不太容易察觉的问题它创建的是一个新数组不是修改原数组。所以当你执行$merged array_merge($hugeA, $hugeB);在合并完成、$merged创建出来之前$hugeA和$hugeB都还完整地驻留在内存里峰值内存最高可能达到三者之和再加 HashTable 本身的开销。如果你是在内存敏感的进程里处理超大数组比如从数据库一次性查出一百万行这一步可能会把内存推高到接近 OOM 的边缘。解决方案有几种如果场景允许用foreach把$hugeB的元素逐个追加到$hugeA中原地修改能减少峰值内存。或者在合并完成后立刻unset掉不再使用的源数组把峰值降下来。如果是 Laravel 集合或 Symfony 数组操作它们内部也封装了类似逻辑但最终本质还是要看 HashTable 的合并策略。我给一个实际优化过的案例一个报表脚本要合并 30 个 CSV 文件解析出的数组每个数组约 20 万行关联数据。最初用array_merge依次合并内存峰值到了 1.2GB脚本经常被杀。改成先预估总行数、用一次性合并加及时unset源数组的方式峰值降到 700MB处理时间反而更快了。关键不是不用array_merge而是别让它和高峰值内存的根源叠加。6. 那些年我在 HashTable 上踩过的坑经验与验证6.1 数组遍历顺序的稳定性不是所有语言的 Map 都这样PHP 数组是有序的这个有序性由arData里 Bucket 的物理顺序保证。新插入的元素永远追加在末尾遍历就是顺序读内存。这一点带来了两个非常实际的影响第一json_encode输出的字段顺序跟你插入的顺序一致很多前后端联调时对接签名算法会依赖这个顺序。第二array_rand可以在关联数组上随机取键然后通过键去取值这也是 HashTable 顺序存储带来的便利。但反过来也要小心如果你在遍历过程中对数组做了增删操作顺序和指针位置可能会变得诡异。比如在foreach里unset当前元素会导致内部指针nInternalPointer失效后续current()、next()等函数的行为可能不符合直觉。建议就是遍历过程中不要修改数组结构要么先收集要删除的键遍历结束后统一处理。6.2 大数组的 unset 陷阱内存不降的真相和解决办法前面讲过unset只是标记空洞不立即回收内存。这在长生命周期进程里会积累出问题。我做过一个 Socket 服务维护一个在线用户映射表用户频繁上线下线数组不断插入和删除。运行一段时间后进程内存缓慢爬升定时任务一跑就卡。排查思路是这样打印数组的nNumUsed和nNumOfElements发现两者差距越来越大空洞比例一度超过 40%。这就是内存一直不释放的直接证据。解决办法有两个周期性重建每隔一段时间用array_values($map)强制重排数组把空洞压缩掉。注意array_values对字符串键也是保留值、丢弃键的所以如果是关联键需要自行处理。换数据结构如果场景是纯整数键且频繁增删考虑用SplObjectStorage或自己维护一个双向链表结构避免 HashTable 的空洞膨胀。6.3 字符串键的内存开销一个键可能吃掉 80 字节以上HashTable 每个元素的内存消耗比大多数人以为的要高得多。我用memory_get_usage()实测过$arr []; for ($i 0; $i 100000; $i) { $arr[key_ . $i] $i; } echo memory_get_usage() / 1024 / 1024; // 约 28MB平均一个元素开销接近 280 字节。对比整数键的纯数组同样 10 万个元素大约只有 12MB 左右。差距主要来自字符串键的zend_string结构包含引用计数、哈希值缓存、长度字段、字符数据加上 Bucket 本身的对齐和 HashTable 容量预留的冗余。这个知识点在面试里特别好用当面试官问“PHP 数组为什么比同数据量的 C 数组占内存大”你可以从三个层面回答——HashTable 容量冗余利用率只有 75%、Bucket 结构体本身的固定开销、zend_string和zval的额外字段。这样回答既有广度又体现了深入源码的痕迹。哈希值缓存这一点值得一提zend_string里缓存了字符串的哈希值所以同一个字符串在作为数组键反复使用时不用重新计算哈希。这是 PHP 性能优化里一个非常典型的空间换时间设计。6.4 一个反直觉的压测结论多次 array_merge 有时比一次慢不了多少你不要被我前面说的“循环里别用 array_merge”吓到。在某些场景下多次小数组的array_merge差距并没有想象中那么大。我拿 100 个长度为 100 的数组做合并// 方式一多次两两合并 $result []; foreach ($chunks as $chunk) { $result array_merge($result, $chunk); } // 方式二一次合并所有 $result array_merge(...$chunks);实测两种方案耗时几乎一致都是 2ms 级别。原因在于总元素量只有 1 万即使多次合并拷贝开销也没到质变的程度。所以这里想传达的是理解底层原理的目标是让你能判断问题的量级而不是把一条优化技巧当成万能药。什么时候优化收益大数据量上百万、循环次数上万、内存峰值逼近限制的时候底层差异才会真正浮出水面。日常几千几万的数据量用array_merge反而更可读、更不易出错。优化要基于数据而不是教条。6.5 调试 HashTable 的实用工具和方法推荐两个排查方向。第一PHP 源码里自带的zend_hash测试代码和 gdb 调试脚本可以直接查看 HashTable 的字段值非常适合验证扩容和压缩逻辑。第二写一个简单的扩展或用FFI读取zend_array结构体实时观察一个数组内部的nTableSize、nNumUsed、nNumOfElements变化。我自己最常用的方法是在 gdb 里打断点查看zend_hash_do_resize的调用栈一清二楚地看到扩容在什么时候发生。如果你觉得这些太重一个轻量办法是写一段带高水位观察的脚本在循环中记录memory_get_usage()和count($arr)画一条曲线就能直观看到扩容和空洞的痕迹。很多时候不需要读源码曲线本身就说明了问题。7. 把底层认知转化为业务嗅觉回到文章开头那个让我半夜爬起来排查的问题。那次事故的根因很清晰我在内存共享的常驻进程里反复用array_merge累加数据造成 O(n^2) 时间复杂度和翻倍的峰值内存。后来我改成了预分配容器加原地追加接口耗时从 6 秒降到 1 秒以内内存峰值下降了约 40%。这次排查让我养成一个习惯遇到数组性能问题先问三个问题——数组有多大增删多频繁合并发生在循环里还是循环外然后根据 HashTable 的特性反推哪个环节最可能是瓶颈。懂底层原理不是让你炫技它的价值在于别人看到一个 bug 只能看到表象你能看到它背后的结构性原因别人只能想到用array_merge你可以判断它适不适合当前的数据量级别人面试时只能说“数组底层是散列表”你能把Time33哈希、负索引布局、0.75 负载因子、空洞压缩机制一条线讲下来。这些就是“庖丁解牛”和“屠夫宰牛”的差别。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门