PHP 的数组堪称语言中最强大也最“魔幻”的数据结构:它既是有序映射,又能当列表、栈、队列、集合使用。面试中,围绕数组的考题层出不穷,而 foreach 与引用结合时的“灵异现象”更是高频陷阱。很多候选人能背出“foreach 后要 unset 引用”的结论,却说不清底层为什么。本文从 PHP 数组的哈希表实现讲起,逐层揭开 foreach 与引用陷阱的本质,帮你在面试中答出深度。
一、PHP 数组的底层:不只是哈希表
PHP 数组底层是哈希表(HashTable)+ 双向链表的复合结构。核心结构体 zend_array(PHP 7+)大致包含:
Bucket *arData:存储元素的连续内存数组,每个 Bucket 保存 key、value 以及一个zval。uint32_t nNumUsed:已使用的 Bucket 数量。uint32_t nNumOfElements:实际有效元素个数。HashTable内部还维护哈希冲突链和遍历指针。
关键点在于:每个 Bucket 除了存储数据,还保存了哈希值和指向下一个元素的索引,从而在物理连续内存上模拟出双向链表。这带来两个重要特性:
- 有序性:数组按插入顺序排列,哈希表同时充当链表。
- 整数键的优化:当 key 是连续递增整数时,PHP 会使用 packed array(打包数组),不存储哈希值,节省内存且访问更快。
理解这一点,才能明白为什么 foreach 不是简单地“按索引遍历”,而是操作哈希表内部的遍历指针。
二、foreach 的两种模式:值遍历与引用遍历
foreach 在 PHP 7 之后有本质区别:
- 按值遍历(
foreach ($arr as $v)):操作的是数组的副本(写时复制,COW)。遍历时使用HashTableIterator,它保存了当前位置的 Bucket 指针。由于是副本,原数组在遍历中被修改不影响遍历。 - 按引用遍历(
foreach ($arr as &$v)):直接操作原数组,$v是原数组元素的引用。此时 PHP 会把数组的refcount增加,并设置遍历标志,遍历指针直接指向原哈希表。
面试常问:为什么按值遍历时修改数组不会影响遍历? 答案就是 COW:foreach 开始时若数组 refcount > 1,会触发分离(separation),生成副本,遍历在副本上进行。
三、经典陷阱:引用残留导致的“幽灵数据”
看这段代码:
$arr = [1, 2, 3];
foreach ($arr as &$v) {
// 第一次循环后,$v 是 $arr[0] 的引用
}
foreach ($arr as $v) {
// 这里会发生什么?
}
print_r($arr);
输出结果是:
Array
(
[0] => 1
[1] => 2
[2] => 2
)
最后一个元素莫名变成了 2。原因在于:
- 第一个
foreach结束后,$v仍然是$arr[2]的引用,并未销毁。 - 第二个
foreach按值遍历时,每次迭代把当前元素赋值给$v。由于$v是$arr[2]的引用,赋值实际修改了$arr[2]。 - 遍历到
$arr[1](值为 2)时,$v = 2把$arr[2]改成 2;遍历到$arr[2](此时已是 2)时,$v = 2又写回 2。最终$arr[2]被覆盖为 2。
这个陷阱的根源是:引用是变量层面的别名,不会随 foreach 结束自动解除。PHP 不会在循环结束时清理 $v 的引用状态。
四、从哈希表角度看“为什么 unset 能解决”
unset($v) 之所以能修复,是因为它断开了 $v 与 $arr[2] 的引用绑定,使 $v 变回普通变量。此后第二个 foreach 赋值时,$v 不再指向数组元素,自然不会污染原数组。
但面试中更该强调的是:即使 unset,按引用遍历仍有其他坑。例如在引用遍历中 unset($arr[$key]) 会破坏哈希表内部遍历指针,导致跳过元素或重复遍历。因为 foreach 依赖 Bucket 的 pNext 指针,删除当前 Bucket 后指针可能失效。
PHP 7 对 foreach 引用遍历做了改进:遍历时会对数组做“写时复制”的变体——如果数组在遍历中被修改,会先分离出一个副本用于遍历,但引用变量仍指向原数组。这导致行为更复杂,面试中若被追问,可以指出:PHP 7 的 foreach 引用遍历在修改数组时会触发分离,遍历的是分离后的副本,而 $v 引用的是原数组,两者可能不一致。
五、面试答题框架
遇到“foreach 引用陷阱”类问题,建议按以下层次回答:
- 现象:指出引用残留导致最后一个元素被覆盖。
- 原因:
$v是引用别名,foreach 结束不自动解除;第二次按值遍历时赋值写入了引用目标。 - 底层:PHP 数组是哈希表 + 双向链表,引用是 zval 层面的
is_ref标记和引用计数,不随作用域结束清理。 - 解决方案:
unset($v),或改用foreach ($arr as $k => $v)并避免复用变量名。 - 延伸:按引用遍历中修改数组会触发 COW 分离,PHP 7 与 PHP 5 行为不同;packed array 与 hash array 的遍历差异。
六、总结
foreach 与引用的陷阱,表面是语法细节,本质是 PHP 数组哈希表实现与 zval 引用语义的交互结果。面试官想考察的不是“你记不记得 unset”,而是你是否理解:
- 数组的物理结构(Bucket、哈希冲突链、遍历指针);
- 写时复制如何影响遍历;
- 引用在 zval 层面如何工作。
把这些串起来,你不仅能答对题,还能在追问中展现对 PHP 内核的理解深度。下次面试再遇到 foreach ($arr as &$v),你可以从容地从哈希表讲到 zval,让面试官眼前一亮。
未经允许不得转载:任鹏个人博客 » PHP 面试精讲:从数组底层哈希表实现看 foreach 与引用陷阱

