PHP 数组的写时复制(COW)原理与实测
一位开发者在分析一个 PHP 函数的性能:它接收一个 20 万元素的数组,经六个内部辅助函数逐层传递后产出结果。分析器显示该函数耗时约 4 毫秒——考虑到“PHP 到处都会复制数组”的说法,这个速度出乎意料。开发者怀疑分析器在撒谎,于是在每个函数调用周围布下 memory_get_usage() 检查点。结果回来了:第一次调用分配了 0 字节;第二次也是 0 字节;所有只读的调用都是 0 字节。到第七个辅助函数执行 $data['flag'] = true 时,内存增量突然出现了 +5 MB。“复制无处不在”的心智模型是错的;真相是“复制恰好发生在某一个特定时刻,而且只发生在那一刻”。
那个时刻正是写时复制(copy-on-write,COW)在 PHP 中的真正含义。模型是这样的:一个值可以同时拥有多个持有者,它们都指向同一块底层内存;只有当其中一个持有者试图修改它时,这块内存才会被复制。在修改发生之前,“这个数组”无论被多少个变量引用,在内存中都只存在一份。正是这一点让 PHP 惯用的“随意传递数组”的代码保持高性能;也正是这一点让开发者在初次接触时对性能特征感到意外。成本并不出现在人们以为它应该在的地方。
本文是对 COW 的深入实测:不停留在“PHP 有写时复制”这种高层论断,而是对每个时刻实际发生的事情进行测量——通过 xdebug_debug_zval 直接检查 refcount,并以字节为单位测量内存增量。目标是建立一个心智模型:在运行代码之前,就能准确预测复制何时发生、成本会有多大。全文对照 PHP 8.3.6 验证通过。
速览
COW 用一句话概括:一个值在多个变量之间保持共享,直到其中一个变量修改它;修改的那一刻,该变量获得自己的副本。在此之前,无论有多少个变量持有它,“这个数组”在内存中都只存在一份。 已验证的测量结果:一个 10,000 个元素的数组占用 266,296 字节;把它赋给另一个变量花费 0 字节(refcount 从 1 变为 2);对任一副本的第一次写入恰好花费 266,296 字节——整个数组被复制;对同一变量的第二次写入花费接近 0 字节(不再需要分离)。 分离事件比同一变量上的后续写入慢约 8,800 倍。第一次写入:8.947 毫秒(分配并复制 266,296 字节);第二次写入:0.001 毫秒。这就是为什么正确的思考单位是“第一次修改的成本”,而不是“每次写入的成本”。 嵌套数组按需选择性分离。修改 $copy['a'][0] 只会复制外层容器和 'a' 子数组——而不是 'b' 或 'c'。已验证:单叶修改分配 135,600 字节,而完整结构本应约占 400 KB;未触及的子数组保持 refcount=2,仍然共享。 字符串同样遵循 COW,但有一个关键差异:任何修改都会复制整个字符串。一个 100KB 字符串的赋值花费 0 字节;修改一个字符花费 102,400 字节(完整复制外加分配器开销)。PHP 字符串不是可以就地修改的字符缓冲区——它们是不可变的值,修改时会被复制。 引用(&)会完全禁用 COW。把某个值标记为 is_ref=1,意味着引用集合中的所有变量都必须观察到相同的修改,PHP 因此放弃这一优化。已验证:执行 $y = &$x 之后,其他共享 $x 数据的变量会被迫立即分离。
学习要点
- 数组赋值与首次修改的确切内存成本
- 如何用 xdebug_debug_zval 检查 refcount 的变迁
- 嵌套数组如何利用选择性分离避免整体复制
- 为什么字符串的 COW 行为与数组不同
- 引用(&)在何种情况下会破坏这一优化
- “分离事件”的性能特征及其预测方法
引用计数模型(简述)
COW 的底层机制是引用计数。PHP 中每个可进行引用计数的值(数组、字符串、对象、资源)都带有一个计数器——refcount——记录有多少个变量或结构引用了该值。赋值使计数器递增;离开作用域或 unset 使计数器递减。当计数器归零时,内存被释放。
COW 的规则只有一句话:当 refcount > 1 时发生写入,PHP 会先复制该值(原值的 refcount 递减),再对副本执行写入。原值保持不动;原值的其他持有者看不到任何变化。
xdebug_debug_zval 可以直接暴露 refcount:
$arr = [1, 2, 3];
xdebug_debug_zval('arr');
// arr: (refcount=2, is_ref=0)=array (0 => 1, 1 => 2, 2 => 3)这里的 refcount=2 包含了 xdebug 自身的临时引用——应用可见的 refcount 是 1。is_ref=0 表示该值不属于引用集合(没有涉及 &)。本文后续将使用这种格式展示各操作过程中值的实际状态。
测量一:赋值零成本
第一项测量确立基线:把一个 10,000 个元素的数组赋给多个变量。
$mem = memory_get_usage();
$big = range(1, 10000);
echo "After range(1, 10000): +" . (memory_get_usage() - $mem) . " bytes\n";
// After range(1, 10000): +266296 bytes
$mem = memory_get_usage();
$copy = $big;
echo "After \$copy = \$big: +" . (memory_get_usage() - $mem) . " bytes\n";
// After $copy = $big: +0 bytes这个 10,000 个元素的数组本身占用 266,296 字节。赋值花费为零。不是“几个字节”——是零。整个操作不过是“递增 refcount,让 $copy 指向 $big 原本指向的那块内存”。
继续增加赋值也不会改变任何结果:
$a = $big; // refcount: 1 → 2
$b = $big; // refcount: 2 → 3
$c = $big; // refcount: 3 → 4
$d = $big; // refcount: 4 → 5
$e = $big; // refcount: 5 → 6
// Memory growth across all 5 assignments: 0 bytes这就是“PHP 数组是带 COW 的按值传递”在实践中的含义。语义模型是“每个变量都拥有自己的数组”;实现却是“一个数组,多个引用”。在有人修改它之前,这一差别无关紧要。
测量二:分离事件
现在修改其中一个变量:
$mem = memory_get_usage();
$copy[0] = 999;
echo "After \$copy[0] = 999: +" . (memory_get_usage() - $mem) . " bytes\n";
// After $copy[0] = 999: +266296 bytes完全相同的数字——266,296 字节。整个数组被复制。不只是元素 0;而是整个结构(HashTable、所有元素、所有键)。这就是分离事件。
在此之后:
- $big 仍持有其原始数组,refcount 已递减(它原先与 $copy 共享)
- $copy 拥有自己的数组,refcount=1,包含全部原始元素以及这次修改
- 对 $copy 的后续修改不再需要任何复制
对比分离与后续写入的耗时,可以看出量级差异:
Setup: $arr1 = range(1, 100000); $copy1 = $arr1;
$copy1[0] = 'modified' → 8.947 ms (allocates and copies the entire array)
$copy1[1] = 'modified' → 0.001 ms (just modifies an element)
Ratio: 8,823× slower for the first write第一次写入不只是稍慢——它慢三个数量级,因为它做的是本质上不同的工作。第一次写入触发分离:分配新内存、复制每一个元素、递减原值的 refcount、让 $copy1 指向新内存,然后修改元素 0。第二次写入只是修改一个已被独占的数组的元素 1。
这种耗时特征在性能分析结果中一眼可辨。同一个函数多次被调用、每次看起来都传入“同一个数组”,但单次调用的耗时可能天差地别,取决于那次调用是否触发分离。赋值后的第一次调用昂贵;同一变量上的后续调用便宜。
测量三:嵌套数组的选择性分离
这里才是 COW 真正聪明的地方。对于嵌套数组,只有实际被修改的那条路径会分离——兄弟分支保持共享。
一个三层结构:
$nested = [
'a' => range(1, 5000), // ~135 KB
'b' => range(1, 5000), // ~135 KB
'c' => range(1, 5000), // ~135 KB
];
$copy = $nested;
// 0 bytes allocated (top-level COW)
$copy['a'][0] = 'modified';
// 135,600 bytes allocated - NOT 400 KB对 $copy['a'][0] 的修改触发了:
- 外层数组($nested/$copy 共享的容器)的分离——几百字节
- 'a' 子数组(被写入的那个)的分离——135 KB
保持共享的部分:'b' 和 'c' 子数组。它们从未被触及,因此无需复制。经 xdebug_debug_zval 验证:
copy: (refcount=1, is_ref=0)=array (
'a' => (refcount=1, is_ref=0)=array (0 => 'modified', 1 => 2, ...),
'b' => (refcount=2, is_ref=0)=array (0 => 1, 1 => 2, ...), ← still shared
'c' => (refcount=2, is_ref=0)=array (0 => 1, 1 => 2, ...), ← still shared
)'b' 和 'c' 显示 refcount=2——它们仍在 $nested['b']/$copy['b'] 和 $nested['c']/$copy['c'] 之间共享。PHP 只复制了不得不复制的部分,仅遍历从根到被修改叶子的路径。
这正是深层嵌套的配置结构、JSON 形态的数据和树状数据在 PHP 中高效的原因。修改深树的一个分支不会复制其他分支。成本与实际触及的部分成正比,而不是与数据总量成正比。
同样的原则,也适用于修改嵌套数据的函数调用:
function deepCopyModify(array $data): array {
$data['users'][0]['email'] = 'changed';
return $data;
}
$shared = [
'users' => [
['id' => 1, 'email' => 'a@example.com'],
['id' => 2, 'email' => 'b@example.com'],
],
'config' => range(1, 1000), // ~30 KB
];
$before = memory_get_usage();
$mutated = deepCopyModify($shared);
$after = memory_get_usage();
// Memory delta: 968 bytes968 字节——而不是 30 KB。30 KB 的 config 子数组未被触及,保持共享。只有 users → 0 → email 这条路径发生了分离。每一层分离都会为被复制的容器结构增加几百字节。
正是这种效率让“纯函数式更新”风格在 PHP 中能够合理地工作。返回大数据结构的修改版本,不必支付整个结构的成本——只需支付路径的成本。
测量四:字符串同样遵循 COW
PHP 中的字符串是值,而不是字符缓冲区。它们与数组遵循相同的 COW 规则,但有一个关键差异:对字符串的任何修改都会复制整个字符串。
$s = str_repeat('A', 100000); // 100KB string
xdebug_debug_zval('s');
// s: (refcount=2, is_ref=0)='AAAA...' (100000 chars)
$s2 = $s;
xdebug_debug_zval('s');
// s: (refcount=3, is_ref=0)='AAAA...' (refcount incremented)
// 0 bytes allocated for the assignment现在修改一个字符:
$s2[0] = 'X';
// +102,400 bytes allocated (full string duplication + allocator overhead)在一个 100,000 字节的字符串中修改一个字符,要花 102,400 字节。原因在于:PHP 字符串内部不是可变的字符数组。“修改一个字符”的操作会创建一个与旧字符串几乎完全相同的新字符串,然后让 $s2 指向新字符串。旧字符串的 refcount 递减;$s 仍然持有它。
这对性能的影响出人意料。一个“向字符串追加”的循环,实际上每次迭代都在重新复制字符串:
$s = '';
for ($i = 0; $i < 10000; $i++) {
$s .= 'x'; // re-allocates a slightly larger string each time
}PHP 的字符串处理会对某些情形做优化(对非共享字符串的小幅追加可以使用预分配了额外空间的缓冲区),但概念模型仍是“每次 .= 都会产生一个新字符串”。构建长字符串时,惯用的做法是 implode(array_fill(...))、先构建数组最后再 implode,或者使用基于流的输出缓冲区——这些都能避免反复的整串复制。
与数组的差异:数组有 HashTable 结构,在数组未被共享时,元素可以在原地添加或修改。字符串没有这种结构——它们是连续的字符数据,修改任意字符都等同于创建新字符串。COW 的分离规则以相同的方式适用(refcount > 1 触发复制),但单次修改的成本不同。
测量五:函数不会复制
COW 最实际的影响体现在函数调用上。函数参数是接收该值的新变量。没有 & 时,函数参数与任何其他变量一样参与 COW。
function readOnly(array $data): int {
return count($data);
}
function passesAlong(array $data): int {
return readOnly($data); // passes through another layer
}
$big = range(1, 50000);
// $big costs about 1.39 MB
readOnly($big);
// Memory delta: 0 bytes
passesAlong($big);
// Memory delta: 0 bytes (through two layers)一个 1.39 MB 的数组穿过两次函数调用,两个函数都不修改它:分配 0 字节。每个函数内部的变量 $data 与 $big 指向同一块内存;调用期间 refcount 上升,作用域退出时下降。
对比——一个会修改其参数的函数:
function modifies(array $data): array {
$data[] = 'extra';
return $data;
}
modifies($big);
// Memory delta: 1,052,728 bytes该函数在修改 $data 时,其局部副本发生了分离。调用方的 $big 保持不变。这就是 PHP 所谓“按值传递”的含义——修改只作用于函数的局部变量,调用方看不到。COW 机制让这一切既高效(不修改就不复制)又正确(调用方的数组不受函数修改的影响)。
这有一个反直觉的推论:把数组传给一个不修改它的函数,本质上免费,与数组大小无关。为了“性能”而使用 & 的本能几乎总是错的——& 只改变语义(让修改对调用方可见);它不会加速传参本身。
什么会破坏 COW
这一优化假设一个值可以有多个持有者,且每个持有者都能看到一致的状态。引用(&)违背了这一假设——按设计,PHP 中的引用意味着“该值是某个集合的一部分;集合中的所有成员必须观察到相同的修改”。一旦某个值被引用,该值的 COW 就变得不可能。
$x = [1, 2, 3];
$y = $x;
$z = $x;
xdebug_debug_zval('x');
// x: (refcount=4, is_ref=0)=array ← all three share the zval
$y = &$x; // make $y a reference to $x
xdebug_debug_zval('x');
// x: (refcount=2, is_ref=1)=array ← only $x and $y now share the (now-referenced) value
xdebug_debug_zval('z');
// z: (refcount=1, is_ref=0)=array ← $z had to separate!创建 $y = &$x 这一动作迫使 $z 分离。$z 原本与 $x 共享同一个值,但现在 $x 成了引用集合的一部分(is_ref=1)。$z 不能再继续共享——如果继续共享,对 $z 的修改就必须传播到 $x 和 $y(因为它们共享同一存储),这违背了 $z 对值语义的预期。PHP 的解决办法是在创建引用的那一刻为 $z 分配一份自己的副本。
教训:在某处引入 & 不只是改变那一个变量——它可能迫使共享同一值的其他变量立即分配副本。对于大型数据结构,这可能是一笔相当可观的意外开销。
另一件破坏 COW 的事:对象上的类型化属性,在特定情况下。当 PHP 向类型化属性赋值时,可能需要校验类型是否匹配;如果被赋的值是共享的(refcount > 1),这就会以取决于具体类型的方式与 COW 相互作用。常见情形(把共享数组赋给数组类型属性)仍然正确使用 COW。边界情形涉及要求该值只能被唯一持有的类型强制转换。这种情况相当少见,大多数应用代码永远不会遇到。
嵌套修改中的逐层分离
当深入嵌套结构进行修改时,PHP 会在从根到修改点的每一层都执行分离——但只针对那一条路径。前面已验证过:
$shared['users'][0]['email'] = 'new'
→ outer array separates (refcount was > 1, now its own copy)
→ 'users' sub-array separates (was shared, now its own copy)
→ users[0] sub-array separates (was shared, now its own copy)
→ email property gets the new value其他子数组在每一层都未被触及,保持共享,refcount=2 成本等式:分离深度 × 每层开销 + 最终修改。对于浅层嵌套(1–2 层),成本由最终修改主导;对于深层嵌套(10 层以上),每层开销就变得不可忽视。
这就是为什么“宽而浅”的数据结构(一个根、许多叶子、深度相同)在 PHP 中通常比“窄而深”的结构(一个根,向下穿过许多包装层)表现更好。宽结构只修改被触及的叶子;深结构则让分离逐层传播过每一个包装层。
对于频繁变更且嵌套很深的数据结构,访问模式很重要。在函数局部变量中构建最终状态(无共享,因此无需 COW)再赋值结果,有时比增量地修改共享结构更便宜。函数局部的构建方式避免了每次修改的分离成本;最终的一次赋值只是一个 COW 事件。
需要避免的陷阱
以为函数会复制它们的参数。不会,除非函数修改它们。“把大数组传给函数很慢”的直觉对 PHP 来说是错的——只有当函数修改数组时才会慢。只读的函数调用在传参上零开销。
用 & 来“优化”。引用不会加速传参;它们只改变语义。它们还会迫使共享同一值的其他变量分离,从而增加意外的内存成本。
在紧凑循环中用 .= 构建字符串。每次 .= 概念上都是一个新字符串。PHP 会优化某些情形,但模型是“字符串追加 = 新字符串”。构建长字符串时,先在数组中累积,最后一次性 implode。
只对函数分析一次就相信结果。第一次调用可能包含分离成本;同一变量上的后续调用则不会。“这个函数耗时 50ms”的分析结果,可能实际上是“这个函数第一次耗时 50ms,之后耗时 5ms”。应在循环中分析,或用能控制 COW 状态的显式设置来分析。
修改函数参数却期待调用方看到变化。没有 & 时,函数的修改是局部的。返回修改后的值并使用返回值才是惯用模式;指望通过修改参数让调用方看到变化,是对 PHP 语义的误解。
忘记 is_ref 的粘性。一旦某个值被引用(引用集合中的任何变量),该值就会保持 is_ref=1,直到所有引用都被 unset。这意味着后续对其他变量的赋值会经由该引用(破坏 COW),而不是全新赋值。
在 COW 问题上把对象属性当作数组元素。对象使用的是句柄共享,而不是 COW。对对象执行 $b = $a 会共享同一个底层对象——通过任一方进行的修改,双方都能看到。对象内部的数组仍然使用 COW,但对象本身不使用。
迷你问答
把大数组传给函数时,PHP 真的不会复制吗?
已验证,确实不会。一个 1.39 MB 的数组穿过两次函数调用(两者都不修改数组),额外分配为零字节。函数参数成为对同一底层内存的额外引用;调用期间 refcount 上升,函数退出时下降。只有当函数修改其参数时才会发生复制——而且只复制结构中被触及的部分。
如何判断某段代码是否会触发 COW 分离?
最直接的方法:在疑似操作前后用 memory_get_usage() 测量内存。出现非零增量且没有明显的分配原因,就表明发生了分离。xdebug_debug_zval 直接显示 refcount 值——如果某个变量的 refcount > 1(扣除 xdebug 自身的引用后),修改该变量就会触发分离。
为什么字符串构建循环会分配这么多内存?
因为每次 .= 操作概念上都会创建一个新字符串。增长模式取决于 PHP 的分配器和具体情形,但模型是“新字符串是一次全新分配,包含旧内容加上新片段”。构建长字符串时,先在数组中累积(追加高效),最后再 implode。
unset() 能加速 COW 操作吗?
间接可以。如果 $big 和 $copy 共享数据(refcount=2),修改任一都会触发分离。如果先 unset($copy)(refcount 降到 1),随后对 $big 的修改就不会触发分离,因为没有其他东西共享该值。对于明知即将修改、且不需要其他引用的变量,显式 unset 其他引用可以避免复制成本。
类型化属性会因 COW 而变慢吗?
通常不会——类型化数组属性仍然正确使用 COW。边界情形涉及类型强制转换(例如赋入一个需要转换以匹配声明类型的值)。类型匹配时(把数组赋给数组属性),COW 行为与无类型属性完全一致。
array_merge 或 array_combine 会复制数据吗?
会。这些函数会生成包含其输入元素的新数组。元素本身可能是 COW 共享的(如果输入是简单值,新数组会包含对这些值的引用并 refcount++)。新数组结构(HashTable 和桶数据)是全新分配的。对于非常大的数组,这些函数有真实的内存成本;对于小数组,成本可以忽略。
那 foreach 呢——会造成复制吗?
对数组的 foreach 使用一个与迭代共享该数组的内部迭代器;在只读迭代期间不会发生复制。foreach ($arr as &$v)(按引用)会把每个元素变成引用,从而影响这些元素后续的 COW。最常见的 bug 模式:在 foreach ($arr as &$v) 之后,变量 $v 会一直保持为对最后一个数组元素的引用,直到显式调用 unset($v)。
总结
PHP 的写时复制是这样一个特性:它无处不在,以至于大多数开发者从不会明确地思考它;但又举足轻重,以至于不理解它就无从理解 PHP 代码的性能特征。在 PHP 代码中习以为常的“随意传递数组”惯用法,在没有 COW 的语言里会成为性能灾难;有了 COW,它就是正确的模式。“PHP 在每次赋值时都会复制数组”的心智模型,错在一个对性能至关重要的具体地方。
正确的心智模型是:赋值免费(refcount 递增);对共享值的修改触发该特定值的分离,成本与被分离部分的大小成正比;嵌套结构选择性分离——只有通向修改点的路径,而不是整个结构;字符串在任何修改上都会整体分离,因为它们是连续数据,没有可共享的内部结构。
对应用代码而言,实际后果是:把数组传给函数很便宜;修改函数参数不影响调用方(没有 & 时);引用会以可能产生非局部影响的方式破坏优化;用 .= 增量构建字符串在规模上很昂贵。这些都不需要显式编程——它们只是影响那些本就自然写出的代码的性能特征。
更深一层的收获,也适用于 PHP 之外:一次计算的成本并不总在人们以为它应该在的地方。一个“做了很多事”的函数可能很便宜,因为底层数据是共享的;一个“只修改一个字段”的函数可能很贵,因为那次修改会穿透多层触发分离。为成本而读代码,需要理解运行时模型,而不只是表层语法。COW 正是这样一类机制:表层语法($copy = $big)对实际运行时行为(refcount 递增、零分配、成本推迟)毫无描述。内化这个模型,正是区分“PHP 有时神秘地快”与“我确切知道这段代码在哪里分配内存”的分界线。
收尾
那位分析 20 万元素数组穿过六个辅助函数的开发者,最终得到了清晰的理解。4 毫秒的总耗时不是分析器撒谎——它是六层 refcount 递增(可忽略)加上一次最终读取(可忽略)的实际成本。昂贵的操作是第七个辅助函数的 $data['flag'] = true:因为数组在全部六层调用之间共享,第七层的修改触发了对整个 20 万元素数组的复制。
修复的办法不是让第七个辅助函数更快——而是要认识到分离才是昂贵的操作,进而要么避免它,要么改在另一个时点只做一次。在本例中,团队重构了代码,让修改作用在函数局部变量上(不共享,因此无需分离),最终结果被返回。第七个辅助函数的运行时间从 60 毫秒降到 4 毫秒;团队整体请求时间每次请求改善 50 毫秒,因为同样的分离模式还发生在另外三处他们尚未分析过的地方。
一年后,一位新开发者看着同一份代码,问为什么辅助函数返回结果,而不是修改那个“显而易见”、本就在各处传递的参数。资深开发者解释了 COW、分离成本,以及那个“显而易见”的修改模式正是最初的性能 bug。新开发者内化了这个模型;团队从此在代码评审文化中开始在上线前就捕捉类似模式。
这就是理解 COW 的实际回报:不只是“PHP 传数组比你预期的快”,更是在分析之前就能预测成本所在。多数时候这无关紧要——处理小数组的应用代码,无论团队是否理解 COW,运行起来都一样。但对于处理大型数据结构的代码,懂与不懂这个模型的差别,就是“性能可预测的代码”与“有着神秘性能悬崖的代码”之间的差别。
“大家还想知道”
PHP 中的写时复制是什么?写时复制(COW)是这样一种优化:PHP 在把一个变量赋给另一个变量时,并不真正复制数组或字符串数据。相反,两个变量引用同一块底层内存,由一个 refcount 追踪存在多少个引用。只有当其中一个变量修改该值时,PHP 才分配新内存并复制数据。已验证:一个 10,000 个元素的数组(266,296 字节)赋给另一个变量花费 0 字节;第一次修改花费 266,296 字节(完整结构被复制)。
把数组传给函数时 PHP 会复制吗?不会,除非函数修改数组。函数参数与变量赋值一样使用 COW——参数是对同一底层值的新引用,调用时 refcount 上升,函数退出时下降。只读取数组的函数分配 0 字节;修改数组的函数承担分离成本(对嵌套结构而言,与数组中被修改的部分成正比)。已验证:一个 1.39 MB 的数组穿过两层函数且无任何修改,额外分配为 0 字节。
如何查看某个变量的 refcount?使用 Xdebug 扩展的 xdebug_debug_zval() 函数。它会为任意变量打印 refcount、is_ref 标志和值:xdebug_debug_zval('arr') 会输出类似 arr: (refcount=2, is_ref=0)=array(...) 的内容。显示的 refcount 包含 Xdebug 自身的临时引用;应用可见的 refcount 需减去 1。这是在开发期间检查 COW 状态的标准方法。
为什么对复制后的数组第一次写入这么慢?因为第一次写入会触发分离事件——PHP 分配一块等于数组大小的新内存,把所有元素复制过去,递减原数组上的 refcount,然后才执行修改。对一个 100,000 个元素的数组,这大约需要 8.947 毫秒(实测)。对同一变量的后续写入不会触发分离(该变量已拥有自己的副本),大约需要 0.001 毫秒。第一次写入与后续写入的比率约为 8,800 倍。
修改深层元素时,PHP 会整体复制嵌套数组吗?不会。PHP 执行选择性分离——只有从根到被修改元素的路径会分离。每一层的兄弟分支保持共享,refcount 不变。已验证:一个包含三个各含 5,000 个元素的子数组的结构(总计约 400 KB),修改第一个子数组中的一个元素,只分配 135,600 字节(一个子数组加外层容器)。另外两个子数组保持 refcount=2——仍与原始变量共享。
PHP 引用(&)与写时复制如何相互作用?引用会禁用它们触及的值的 COW。当某个变量属于引用集合(is_ref=1)时,集合中的所有变量都必须观察到彼此的修改,这与 COW 的“惰性复制”模型不相容。用 & 创建引用还可能迫使原本共享同一值的其他变量立即分离,因为它们不能与一个期待值语义的变量处于同一个引用集合中。
PHP 字符串也使用写时复制吗?是的,但有一个重要差异。字符串赋值使用 COW(赋值时不复制),但对字符串的任何修改都会复制整个字符串。字符串没有数组 HashTable 那样的内部结构——它们是连续的字符数据,因此“修改第 N 个字符”等价于“生成一个改了那个字符的新字符串”。已验证:一个 100,000 字节字符串的赋值花费 0 字节;修改单个字符花费 102,400 字节(完整字符串复制)。构建字符串时,应先在数组中累积、最后再 implode,而不是在循环中使用 .=。
如何判断 PHP 代码中哪一行导致了内存增长?在可疑操作周围加入 memory_get_usage() 调用并测量增量。出现非零增量且没有明显的分配原因,通常表明发生了 COW 分离——该行触发了一个先前共享值的复制。更详细的信息可借助 xdebug_debug_zval:它显示操作前后的 refcount,让分离事件清晰可见(refcount 从大于 1 降到 1,且该变量现在拥有自己的副本)。两者结合,就能精确定位代码中真正发生分配工作的地方。
注:本文中所有内存测量和计时数据均使用 memory_get_usage() 和 hrtime() 在 PHP 8.3.6 上验证。通过 xdebug_debug_zval 显示的 refcount 值包含 Xdebug 自身的临时引用(通常比应用可见的 refcount 高 1)。数组的具体字节数取决于数组的内部结构(HashTable 大小、桶数量、分配器开销),在不同 PHP 版本和配置下可能略有差异;文中描述的规律和比率在 PHP 7+ 各版本应保持稳定。分离事件的计时(比后续写入慢约 8,800 倍)在典型的 Linux x86_64 环境上测得;硬件和分配器状态会改变绝对值,但数量级比率反映的是“分配+复制”与“单元素修改”之间根本性的成本差异。