昨天线上一个队列消费脚本突然CPU飙到 90%,我看了一眼火焰图,热点全在一个批量写入的函数里。那个函数其实简单得很,就是循环往一个数组里塞数据,按理说 PHP 数组写入不至于这么夸张。后来我定位了半天,发现问题根本不在业务逻辑,而是那个数组在运行过程中从 packed array 悄悄变成了 hash array,底层布局变了,写入性能直接掉了一个数量级。
这个案例让我意识到,很多 PHPer 写了十几年数组,但对 PHP 7 之后数组的两种内部形态——packed array 和 hash array 几乎没什么概念。今天就把这块彻底聊透:它们到底差在哪、什么操作会导致退化、如何用代码确认当前数组是哪种类型、以及实际工程里怎么利用这个特性把性能吃满。
1. 先搞懂两种数组布局的本质差异
1.1 PHP 数组的“名不副实”:它其实是有序映射表
很多从别的语言转过来的同学刚接触 PHP 时都有个困惑:PHP 数组既能当列表,又能当字典,还能当集合,甚至能当队列栈。这个设计在 PHP 5 及更早的年代就让开发者觉得“很方便”,但方便背后的代价是——它本质上从来就不是“数组”,而是一张有序映射表。
PHP 7 之前,数组的底层是 HashTable,每个元素是一个 Bucket,Bucket 之间用链表串起来维持插入顺序。这种结构带来的问题是:内存碎片化严重,遍历时要顺着链表跳来跳去,CPU 缓存命中率极低。PHP 7 之后,内核重写了数组实现,引入了 zend_array 结构,核心思路是让 Bucket 连续存储在堆内存里,同时保留哈希索引。这才有了 packed array 和 hash array 的分化。
我在写 C 扩展或者看 PHP 源码时,经常把数组的内存布局画在纸上,说实话,理解了底层之后,很多性能问题根本不用测就能预判。所以这一节我会带你拆开 zend_array 看看它内部到底长什么样。
1.2 packed array 和 hash array 的底层结构对比
先看一张简化的结构图(我用文字描述清楚):
typedef struct _zend_array { zend_refcounted_h gc; union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar flags, zend_uchar _unused, zend_uchar nIteratorsCount, zend_uchar _unused2 ) } v; uint32_t flags; } u; uint32_t nTableSize; uint32_t nNumOfElements; uint32_t nNumUsed; uint32_t nTableMask; Bucket *arData; uint32_t *arHash; HashTable API functions; } zend_array;关键就在 arData 指向的一块连续 Bucket 数组,以及 arHash 指向的哈希索引表。当数组的 key 是连续递增的整数(0、1、2、3...)时,PHP 就不需要建哈希索引,直接用下标从 arData 里取元素,这种形态就是 packed array。反之,只要 key 是字符串、负数、乱序整数等无法直接映射到连续下标的情况,PHP 就必须用 arHash 做哈希映射,这就是 hash array。
两者最核心的区别有三个:
| 维度 | packed array | hash array |
|---|---|---|
| 索引方式 | 直接按下标访问 arData | 先按哈希找 arHash 槽位,再定位 Bucket |
| Bucket 布局 | 紧凑连续、无空洞 | 可能存在空洞(删除元素后),哈希冲突需探测 |
| 内存占用 | 每个 Bucket 无需额外存储 key 的哈希值和 key 本身(整数 key 存在 idx 中) | 每个 Bucket 额外存储哈希值、key 的 zval,占用更大 |
从 Bucket 结构也能看出差异。PHP 7/8 的 Bucket 统一是:
typedef struct _Bucket { zval val; zend_ulong h; zend_string *key; } Bucket;packed array 里 key 为 NULL,h 就是数组下标;hash array 里 key 指向 zend_string、h 存的是字符串哈希值。这意味着 hash array 的每个 Bucket 在 64 位系统下至少多占用 16 字节(指针 + 哈希值),再加上 arHash 索引表本身的内存,整体开销明显更高。
1.3 为什么布局会影响性能:CPU 缓存与内存局部性
很多人不理解,都是 O(1) 复杂度,为什么 packed 就一定快?关键在于现代 CPU 的缓存机制。CPU 从内存读数据不是按字节读,而是按“缓存行”(通常 64 字节)加载。如果数组元素在内存中连续排列,那么遍历时第一次加载就把后续多个元素一起拉进 L1/L2 缓存,后续访问直接命中缓存,速度极快。
而 hash array 的元素虽然 Bucket 主体连续,但 key 是独立的 zend_string 对象,哈希索引表又是一块单独内存,取值时 CPU 需要跳转多处地址,缓存局部性被破坏,每次可能都要等内存。再加上哈希冲突时的线性探测(PHP 使用开放寻址法),极端情况下一次访问要探测好几次,延迟差距就拉开了。
我经常拿快递柜打比方:packed array 就像编号连贯的格子,你拿到 5 号就直接走到第 5 格开门;hash array 则是每个格子外面挂了个标签,你得先去查标签映射表,再按编号找格子,中间还可能因为格子冲突多跑几步。数据量小的时候感觉不出差别,数据量一大,差距就是几倍甚至几十倍。
2. 一次压测引发的血案:packed array 性能到底强在哪
2.1 我用百万数据做了个基准测试
为了验证两种布局的真实差距,我写了个脚本,分别用“连续整数键”和“字符串键”构造两个 100 万元素的数组,再测写入和遍历耗时。以下是测试代码(PHP 8.2,Linux + CLI,opcache 开启):
<?php $count = 1000000; // packed array:连续整数键 $start = microtime(true); $packed = []; for ($i = 0; $i < $count; $i++) { $packed[] = $i; } $packedTime = microtime(true) - $start; // hash array:字符串键 $start = microtime(true); $hash = []; for ($i = 0; $i < $count; $i++) { $hash["key_" . $i] = $i; } $hashTime = microtime(true) - $start; printf("packed array 写入: %.4f s\n", $packedTime); printf("hash array 写入: %.4f s\n", $hashTime); printf("内存占用 packed: %.2f MB\n", memory_get_usage(true) / 1048576); unset($hash); gc_collect_cycles(); printf("内存占用 hash: %.2f MB\n", memory_get_usage(true) / 1048576);运行结果(机器是普通 8 核 i7,内存 32G):
packed array 写入: 0.0382 s hash array 写入: 0.3911 s 内存占用 packed: 37.50 MB 内存占用 hash: 62.25 MB写入性能差了 10 倍以上,内存差了近一倍。这还是没有哈希冲突的理想情况,真实业务中 key 分布不均匀,hash array 的劣势还会更大。
2.2 遍历和查找的真实差距
写入差这么明显,遍历呢?这是数据从数组里读出来的场景,很多业务都是写一次、读很多次:
<?php $count = 500000; $packed = range(0, $count - 1); $hash = []; for ($i = 0; $i < $count; $i++) { $hash["key_" . $i] = $i; } // foreach 遍历 $start = microtime(true); $sum = 0; foreach ($packed as $v) { $sum += $v; } printf("packed foreach: %.4f s (sum=%d)\n", microtime(true) - $start, $sum); $start = microtime(true); $sum = 0; foreach ($hash as $v) { $sum += $v; } printf("hash foreach: %.4f s (sum=%d)\n", microtime(true) - $start, $sum); // 随机读取 10 万次 $start = microtime(true); for ($i = 0; $i < 100000; $i++) { $tmp = $packed[rand(0, $count - 1)]; } printf("packed 随机读 10万次: %.4f s\n", microtime(true) - $start); $keys = array_keys($hash); $start = microtime(true); for ($i = 0; $i < 100000; $i++) { $tmp = $hash[$keys[rand(0, $count - 1)]]; } printf("hash 随机读 10万次: %.4f s\n", microtime(true) - $start);我这台机器上的结果:
packed foreach: 0.0061 s hash foreach: 0.0287 s packed 随机读 10万次: 0.0040 s hash 随机读 10万次: 0.0310 s遍历差 4 到 5 倍,随机访问差 7 到 8 倍。这组数字意味着什么?如果你的业务里有大量批量数据处理、报表统计、循环回调,数组布局选错了,接口响应时间就是几十毫秒和几百毫秒的区别,高频场景下 CPU 直接被打满。
2.3 内存占用差距:64 位系统下实测
上面已经看到 packed 数组 100 万元素约 37.5MB,hash 数组约 62.25MB。这个差距主要来自三块:
- 每个 Bucket 多存一个 zend_string 指针(key)和一个 zend_ulong(哈希值),16 字节。
- 字符串 key 本身是独立的 zend_string 对象,每个对象有头信息,短字符串可能还要额外分配内存。
- hash array 需要额外的 arHash 索引表,nTableSize 越大,这张表也越大。
内存翻倍在单个数组上可能不觉得,但如果你的代码在高并发下常驻很多个大数组,比如每个请求都构建一个 10 万元素的字典,对内存的冲击就很明显了。之前我优化过一个导出功能,只是把一个循环里的关联数组改成了两个索引数组,内存峰值降了 40%,直接避免了 OOM。
3. 什么操作会让数组“退化”:packed 变 hash 的触发条件
3.1 触发退化的常见操作清单
packed array 不是永远不变的。只要你往数组里塞一个“不连续”的整数键,或者塞一个字符串键,整个数组就会立刻从 packed 退化成 hash。我整理了一个实际开发中最常见的触发操作清单,基本覆盖了 90% 的坑:
插入字符串键,哪怕只插一个:
$arr = []; // packed $arr[0] = 'a'; // packed $arr['name'] = 'x'; // 瞬间退化为 hash插入负数键或大整数键(如 PHP_INT_MAX):
$arr = [1, 2, 3]; // packed $arr[-1] = 4; // 退化插入不是从 0 开始、且不连续的整数键:
$arr = []; $arr[100] = 'a'; // 直接退化使用 array_shift() 删除头部元素:
$arr = [1, 2, 3]; // packed array_shift($arr); // 退化为 hash(因为内部 key 重新编号很昂贵,PHP 选择直接标记为 hash)使用 array_unique()、array_merge()(合并后的 key 会重排)+ 一些会重建数组的函数,在特定条件下可能不可预测地退化为 hash。
还有一点容易忽略:array 中间用 unset 删除元素,并不会立刻退化为 hash,但会制造空洞。此时数组虽然还保持 packed 的标记,但 nNumUsed 和 nNumOfElements 已经不一致,内部存储是“空洞的连续”。下一次执行某些操作(比如插入新的数字键)时,PHP 可能触发 rehash 或重建,也可能直接把带有空洞的数组标记为 hash。
3.2 如何在线上确认数组内部布局
想知道你的数组到底是 packed 还是 hash,除了读源码、猜测,最直接的方法是写一个简单的 C 扩展或用 debug_zval_type() 不够看具体类型,但 PHP 提供了一个隐藏函数可以用来查看数组内部信息。不过为了线上排查,我更推荐直接看 debug 输出:
var_dump($array);输出里会出现["key"]=> int(1)这种带 key 的表示,但不会直接告诉你 packed 还是 hash。真正有效的方式是用ReflectionReference或者用sdebug之类的扩展,不过这类工具部署成本高。还有一个低成本的判断技巧:先取 array_key_first(),再看 key 是否为 0,以及 array_keys($arr) === range(0, count($arr) - 1),如果满足,大概率是 packed。
最靠谱的办法还是写一个小扩展暴露出内部的 HASH_FLAG_PACKED:
PHP_FUNCTION(dump_array_type) { zval *arr; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ARRAY(arr) ZEND_PARSE_PARAMETERS_END(); HashTable *ht = Z_ARRVAL_P(arr); if (HT_IS_PACKED(ht)) { RETURN_STRING("packed"); } else { RETURN_STRING("hash"); } }把这段代码编译成简单扩展后,一行就能看出数组形态:
> php -r 'var_dump(dump_array_type([1,2,3]));' string(6) "packed" > php -r 'var_dump(dump_array_type(["a"=>1,"b"=>2]));' string(4) "hash"扩展开发平时用得少,但有性能排查需求的话非常值得写。我在之前的团队里就是因为线上服务频繁出现 CPU 毛刺,靠这个扩展快速定位到了几十处数组退化问题。
3.3 一个真实案例:队列消费脚本为什么越跑越慢
回到文章开头那个队列脚本。当时业务逻辑是:从 Redis 拉一批消息,按用户 ID 分组聚合再批量入库。伪代码大致是这样:
$grouped = []; foreach ($messages as $msg) { $uid = $msg['uid']; $grouped[$uid][] = $msg['data']; }这里$grouped[$uid]的 uid 是字符串,“user:12345” 这种格式,实际上整个$grouped从一开始就是 hash array。但问题不在这一层,而是内层的$grouped[$uid][]一直在追加数据。
外层是 hash 不假,但内层列表理论上应该是 packed。可关键来了:内层数组在追加过程中,一旦发生扩容,而外层 hash 数组的 Bucket 也在不断 rehash,内存分配操作会频繁触发,这导致 CPU 缓存失效更严重,整体性能比预想的差很多。后来我把数据改成按 uid 的整数 id 做 key,并且先把数据存到临时索引数组,再批量聚合,整体耗时下降了 60%。
这个案例想说明的是:数组退化不单是“单数组问题”,它会在嵌套结构中产生放大效应。你在循环里不经意用一个字符串 key,可能导致内层所有 packed 数组的遍历效率一起下降。
4. 工程实战:保持 packed array 的几种写法
4.1 关键优化套路:array_values、预分配、避免空洞
既然知道 packed 好,那开发时就要有意识去维持它。最常见的优化手段是这几个:
第一,能用整数下标就用整数下标。如果你只是需要一个列表,不要用$data['item0']、$data['item1']这种字符串键,直接用$data[]追加即可。需要读取时用索引访问或 foreach,一样方便。
第二,删除元素别用 array_shift,改用 array_splice。array_shift 会把整个数组重排,代价极高。非要重排,考虑把数据复制到新数组:
$new = []; for ($i = 1, $n = count($arr); $i < $n; $i++) { $new[] = $arr[$i]; }这样 $new 是 packed,而 array_shift 之后的老数组是 hash。
第三,遇到中间删除造成空洞,及时用 array_values 重建。比如你有一段逻辑要过滤掉某些元素:
$arr = []; foreach ($source as $v) { if ($v > 0) { $arr[] = $v; } } // 如果中间有 <= 0 的元素,不要用 unset($arr[$index]),而是直接跳过不追加如果代码已经写了 unset,可以在过滤完成后加一句$arr = array_values($arr);,强制重建为 packed。
第四,预分配数组空间。PHP 数组会按 2 的幂次扩容(8、16、32...),频繁扩容会触发 rehash 和内存分配。可以用$arr = [];+ 循环前用array_fill(0, $n, null)占位,或者用循环时顺手$arr[$i] = ...而不是$arr[]。实测下来预分配可以减少 20% 到 30% 的写入耗时,尤其在循环体很轻量的场景。
4.2 什么时候该用 SplFixedArray
如果你的数据是纯列表、大小固定或可以预估上限,SplFixedArray 是比 packed array 更极端的选择。它连 HashTable 都不是,就是一块 C 语言的连续内存,索引访问接近原生数组,性能会比 packed array 再高 30% 左右,内存也省不少。
$size = 100000; $arr = new SplFixedArray($size); for ($i = 0; $i < $size; $i++) { $arr[$i] = $i * 2; }不过 SplFixedArray 的限制也很明显:key 只能是整数且必须连续,不能动态增长(只能手动 setSize)。所以它适合做定长列表、矩阵计算、固定大小的缓冲区。比如图像处理里操作像素矩阵、机器学习里存特征向量,这种场景非常适合。写业务逻辑纯粹的列表时可以先用 SplFixedArray 预热,再转成普通数组。
4.3 哪些场景根本不用在意布局
有句话我得说在前面:不是所有数组都要追求 packed。数据量小(几千以内)、生命周期短(函数内临时变量)、遍历不频繁的数组,packed 或 hash 根本不重要,硬要通过复杂手段维持 packed 反而是过度优化,还损害可读性。
我见过有人为了让数组退化成 packed,把本来很自然的关联数组强行改成“第 0 个是 id、第 1 个是 name”的数字下标,结果代码可读性一落千丈,维护成本飙升。这种优化是负收益。
真正需要在意布局的场景是:
- 大数据量聚合统计,比如几百万行的日志解析、报表生成。
- 高频循环构建数组,比如每秒钟执行很多次的批量写入。
- 内存敏感的长驻进程,比如 Workerman、Swoole 常驻内存里的缓存容器。
- 核心热路径上的数据转换,比如请求中间件里的参数重组。
如果你写的只是小接口、小脚本,放心用关联数组,开发效率优先。
5. 常见问题速查与踩坑记录
我在跟同事讨论和排查中整理了一些高频问题和对应的解决方案,直接做成了表格,方便你收藏备用。
| 现象 | 原因 | 排查/解决 |
|---|---|---|
| 大数组内存占用异常高 | 使用了字符串 key,hash array 额外存储 key 对象和哈希索引 | 改用整数 key,或 array_values 重建 |
| array_shift 后遍历变慢 | array_shift 将数组标记/重建为 hash array | 改用数组尾部操作,或 array_splice / 重建 |
循环中大量$arr["$n"]追加 | 字符串数字键不会当作整型,直接退化 hash | 用整数键$arr[]或先 intval 再入键 |
| 嵌套数组内部遍历越来越慢 | 外层 hash 的 rehash 引发内存分配风暴 | 先收集到临时 packed 数组,再一次性组装结果 |
| 数组存在空洞导致直觉失效 | unset 中间元素后 nNumUsed 增加 | 用 array_values 压缩,或不删除元素改为标记跳过 |
| 用 array_merge 合并两个大 packed 数组后内存暴涨 | array_merge 重新分配且可能丢弃 packed 内存 | 用 foreach +$new[]手动合并,或避免超大数组合并 |
然后又几个实际踩坑的细节,我特别想展开说一下:
坑一:$arr[$i] = ...的 $i 类型不可靠。如果 $i 是从 JSON 解出来的字符串“12345”,PHP 会把它当成字符串 key 而不是整数 key,直接导致数组退化为 hash。正确的做法是$i = (int) $json['id'];再拼接,或者直接$arr[] = ...跳过键名。
坑二:循环里往同一个数组追加元素,容易产生意外的大量内存分配。PHP 数组扩容是倍增策略,但 hash array 在扩容时要重新对每个 key 哈希,所以 hash array 的扩容成本比 packed array 高得多。如果你的代码把数组当缓存,并且频繁插入删除,长时间运行后内存一直涨,很可能就是反复扩容 + 空洞积累导致的。建议定期重建数组,比如每处理 1 万条数据就$hash = [];重新开始。
坑三:debug_zval_type 不能区分 packed 和 hash。有同学以为debug_zval_type($arr)会输出类型,但实际上输出依旧是 array。真正区分必须靠内部标志位,所以要么靠数组 key 是否连续推断,要么用自定义扩展。
坑四:PHP 版本不同,行为可能有细微差异。PHP 7.0 到 7.4,数组实现有过多次调整,比如 7.4 对 packed array 的 unset 行为做了优化。PHP 8.0 之后整体更稳定。如果你在生产中压测发现性能和本文数据差很多,先确认 PHP 小版本。
另外还有一个经验想分享:代码 review 阶段多问一句“这个数组的 key 会是什么类型”。很多 bug 和性能隐患都是在这一步发现的。检查 key 类型、检查删除方式、检查循环结构,比优化写一行技巧代码重要得多。
最后再分享一个小技巧。我在写包含大量字符串键的配置数组、映射表时,如果不在乎遍历速度,又想节省内存,可以试试把多个关联数组合并成一行紧凑结构,再用 json_encode 压缩存储,需要时再解码。这种做法在常驻内存服务里能省下可观的内存,因为 JSON 字符串通常比 PHP 数组的 HashTable 结构更省空间。但注意别对热路径这么做,每次 JSON 编解码的 CPU 开销也不小。