很多 PHP 开发者对变量赋值的直觉理解是“值传递”——$a = $b 就是把 $b 的值复制一份给 $a。这个理解在语义层面没错,但在引擎层面,PHP 远比这聪明。理解引用计数(refcount)和写时复制(Copy-On-Write,COW)的工作方式,不仅能帮你写出更高效的代码,还能让你避开一些隐蔽的性能陷阱。
每个变量背后都有一个 zval
PHP 7 之后,变量的底层容器叫 zval(Zend Value)。一个 zval 的核心结构大致如下:
struct _zval_struct {
zend_value value; // 实际存储的值(long、double、指针等)
uint32_t type_info; // 类型标记 + 若干标志位
// PHP 7.4+ 还包含 refcount 和 is_ref 的联合体
};
对于字符串、数组、对象这类“堆分配”的值,zval 中存的不是数据本身,而是一个指向堆内存的指针。这块堆内存的头部包含一个 refcount 字段,记录有多少个 zval 正在引用它。
当你执行 $a = "hello" 时,引擎创建一个 zval,分配堆内存存放字符串,refcount 设为 1。当你执行 $b = $a 时,引擎并不会复制字符串内容,而是让 $b 的 zval 指向同一块内存,然后把 refcount 加 1 变成 2。这就是引用计数的核心思想:多个变量可以共享同一份数据,用计数来追踪何时可以安全释放。
写时复制:共享是默认,复制是例外
既然 $a 和 $b 共享同一块内存,那修改 $b 的时候怎么办?如果直接改,$a 也会跟着变——这显然不符合 PHP 的值语义。
COW 机制在这里登场。当你试图修改 $b 时,引擎先检查这块内存的 refcount:
- 如果 refcount 为 1,说明只有
$b在用,直接原地修改,零开销。 - 如果 refcount 大于 1,说明还有别人(比如
$a)在共享,引擎先复制一份新内存,refcount 减 1,然后在新内存上修改。
$a = str_repeat('x', 1000000); // 分配 1MB 字符串,refcount = 1
$b = $a; // 共享,refcount = 2,无内存复制
$b .= 'y'; // refcount > 1,触发复制,$b 获得独立副本
这个设计的精妙之处在于:只要不写,就永远不复制。 对于大量只读传递的场景(比如把大数组传给函数但函数不修改它),COW 几乎消除了复制开销。
函数参数传递中的 COW
PHP 的函数参数默认按值传递,但借助 COW,实际行为接近于“按引用传递 + 写时复制”:
function process(array $data) {
// 不修改 $data 时,零复制
return count($data);
}
$big = range(1, 1000000);
process($big); // 不触发复制
但如果你在函数内修改了参数:
function mutate(array $data) {
$data[] = 'new'; // 触发复制,1MB 数组被完整拷贝
}
mutate($big); // 一次昂贵的复制
这就是第一个性能陷阱:在函数内修改大数组参数,会触发完整的 COW 复制。 如果你确实需要修改,考虑传引用 &$data,避免复制。但要注意,传引用会让 refcount 的 is_ref 标志置位,后续所有赋值都不再享受 COW 优化,需要权衡。
引用计数何时失效:循环引用
引用计数有一个经典缺陷:无法处理循环引用。
class Node {
public $next;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->next = $a;
unset($a, $b);
此时两个对象的 refcount 都不为 0(互相引用),但它们已经不可达了。引用计数无法释放它们,造成内存泄漏。PHP 为此配备了同步周期回收器(Concurrent Cycle Collector),定期扫描可能的循环引用并回收。但这个 GC 有成本,而且不是实时触发的。
在高并发或长驻进程(如 Swoole、RoadRunner)中,循环引用积累可能导致内存持续增长。最佳实践是主动打破循环引用,比如在 __destruct 中将引用置 null。
性能陷阱清单
陷阱一:不必要的引用赋值
$data = getHugeArray();
$ref = &$data; // 危险!
一旦用了 &,$data 的 is_ref 标志被置位。此后任何对 $data 的赋值或传递都不再走 COW 快路径,所有共享该 zval 的变量都会被强制同步。除非你明确需要引用语义,否则不要用 &。
陷阱二:在循环中修改大数组
foreach ($bigArray as $key => $value) {
$bigArray[$key] = $value * 2; // 每次迭代都可能触发复制检查
}
foreach 默认操作的是数组的副本(COW),但一旦在循环体内修改原数组,就会触发复制。如果必须修改,用引用遍历 foreach ($bigArray as $key => &$value),但记得循环后 unset($value) 断开引用。
陷阱三:字符串拼接的隐式复制
$result = '';
foreach ($parts as $part) {
$result .= $part; // 每次拼接都可能重新分配
}
PHP 的字符串拼接在某些情况下会尝试原地扩展(如果 refcount 为 1 且内存足够),但一旦字符串被共享过,就会触发复制。对于大量拼接,用 implode() 或 sprintf() 更高效。
陷阱四:混淆 unset() 和内存释放
$a = str_repeat('x', 1000000);
$b = $a;
unset($a); // refcount 减 1,但内存未释放,因为 $b 还在引用
unset() 只是减少 refcount,只有 refcount 归零时内存才真正释放。理解这一点有助于诊断“为什么 unset 后内存没降”。
如何观察这些机制
PHP 提供了 debug_zval_refcount() 函数(需要 debug 构建),可以查看 zval 的 refcount 和 is_ref 状态。在生产环境中,可以用 memory_get_usage() 配合实验来验证 COW 行为:
$mem1 = memory_get_usage();
$a = str_repeat('x', 1000000);
$mem2 = memory_get_usage();
$b = $a; // 不复制
$mem3 = memory_get_usage();
$b .= 'y'; // 触发复制
$mem4 = memory_get_usage();
echo "分配: " . ($mem2 - $mem1) . "\n"; // ~1MB
echo "共享: " . ($mem3 - $mem2) . "\n"; // ~0
echo "复制: " . ($mem4 - $mem3) . "\n"; // ~1MB
总结
引用计数和写时复制是 PHP 性能的基石之一。它们让变量赋值在大多数情况下近乎零成本,但也引入了需要开发者理解的边界条件:引用赋值会破坏 COW 优化、循环引用需要 GC 介入、大数组在函数内修改会触发完整复制。掌握这些底层机制,你就能在写代码时做出更明智的决策——什么时候该传引用,什么时候该用 implode,什么时候该主动断开循环引用。这些微小的选择,在高负载场景下会累积成显著的性能差异。
未经允许不得转载:任鹏个人博客 » PHP 引用计数与写时复制机制:变量赋值的底层真相与性能陷阱

