
聊PHP优化躲避不开两个词引用计数refcount和写时复制Copy On Write下文统一叫COW。这俩机制藏在zval层面平时写业务代码看不见摸不着但只要碰到大数组、长循环、驻留进程它们会直接决定你的内存峰值和响应时间。我做过一个CLI批量任务同样的数据量有人用file()把日志整个读进数组再处理峰值内存是我用生成器逐行读的12倍另一次我把一个大数组传进函数做只读统计发现内存占用几乎没涨。这些现象背后都是同一套底层规则PHP变量并不是每次赋值都复制一份。这篇文章按我的排查习惯把这套机制从原理到利用方式完整拆一遍适合已经在写PHP、想往性能优化方向走的开发者。1. PHP变量到底是怎么管理的你先得理解zval1.1 变量是一张贴纸不是一个盒子很多初学者会把PHP变量想象成“一个装数据的盒子”$a $b就是把$b的盒子复制一遍。这个直觉在大一学C语言时没错但在PHP 7之后内存管理层面早就不这么干了。PHP 7里每个变量在符号表里对应一个zval结构体。zval里除了存值的联合体还有一个refcount字段用来记录当前有多少个符号、数组元素或者对象属性指向同一个数据。比如$a [1, 2, 3, 4, 5]; $b $a;$b $a这行执行完后并不是创建了一个新数组而是符号表里的$b直接指向了$a原来那个zval同时refcount从1变成2。内存上基本没动。那什么时候才真正复制只有当某个变量要“写”的时候。比如$a [1, 2, 3, 4, 5]; $b $a; $b[] 6; // 这行才真正复制给$b追加元素时PHP发现这个zval的refcount大于1于是先分离出一份新的数组给$b再把原数组引用计数减回去。这个过程就是写时复制。打个比方快递到了门口一群人共享同一个盒子。只有某个人要往盒子里塞东西或者拿走盒子里属于他的那部分时才会去重新买一个盒子然后把原来这个盒子的所有权让给剩下的人。只要没人“动手写”大家共用一份数据。1.2 COW解决的是“值语义”开销问题PHP语言层面承诺的是“按值传递”$b $a之后你改$b不应该影响$a。为了实现这个语义最直接的方案就是复制但复制在大多数场景是浪费的。拿函数传参来说function countTotal($list) { $sum 0; foreach ($list as $v) { $sum $v; } return $sum; } $big range(1, 1000000); $result countTotal($big);如果PHP每次都把$big完整复制一份给函数参数这个函数光是传参就得浪费几十MB内存和大量CPU。但有了COW函数内部的$list和外部$big共享同一个zval函数只读不写就永远不复制。这也是COW的核心价值语法上按值物理上按引用共享把复制操作延迟到真正发生写入的那一刻。对大多数“读多写少”的业务场景收益极其明显。1.3 对象不适用COW别一概而论注意一个很容易踩的认知误区对象不是COW。PHP里对象变量保存的是对象句柄$obj2 $obj1只是复制了一个句柄两个变量指向同一个对象本体。$obj1 new stdClass(); $obj1-name a; $obj2 $obj1; $obj2-name b; echo $obj1-name; // b数组和字符串走COW对象不走。想要真正的对象副本必须用clone这部分后面排查篇还会展开。2. COW是怎么触发的细节比想象中多2.1 四种典型写操作触发分离COW触发的前提是“对一个refcount大于1的zval执行写操作”。那哪些操作算写写操作类型典型代码实际效果变量重新赋值$b 2;如果$b和$a共享则先分离再赋值数组元素修改/新增$b[] 1; $b[k] v;整个数组容器被分离数组元素删除unset($b[k]);数组容器被分离字符串拼接/替换$b . x; $b[0]y;字符串被分离并重新分配这里的“分离”并不是把整个数据结构深层复制一遍而是把最外层容器复制一份。比如一个二维数组$b $a之后修改$b[0][0] 1PHP会把最外层数组复制一份但内层子数组还是共享的再修改到内层那一项时才会再分离内层数组。这个“逐层分离”的特点很重要理解它之后你就能解释为什么COW并不会让你的内存瞬间翻倍。它只复制“当前写入路径上的那层容器”。2.2 普通赋值和引用赋值是两套规则普通赋值走的是“refcount COW”引用赋值走的是另一套规则$a 1; $b $a; // $a 和 $b 进入同一个引用集合is_ref 1 $b 2; echo $a; // 2一旦使用建立引用zval的is_ref标志会被置为1之后对这个zval的任何写操作都不再触发分离而是直接修改共享值。这是“引用”和“按值”的根本区别。更微妙的是两者混合时的行为$a 1; $b $a; // $a,$b 引用集合is_ref 1 $c $b; // 普通赋值$c不会加入引用集合 $c 3; echo $a; // 1$c影响不到$a把引用集合中的变量再普通赋值给一个新变量PHP会主动把新变量分离出去否则$c一旦被修改就会意外改动$a和$b。这种“隔离”是必要的但也是很多人在排查诡异变量时容易忽略的点。2.3 foreach里的$value到底是副本还是引用经典问题foreach里修改$value为什么原数组不变$orders [ [id 1, amount 100], [id 2, amount 200], ]; foreach ($orders as $order) { $order[amount] 10; } print_r($orders); // 原数组没变这里的$order是当前数组元素的副本修改它只是修改一个临时zval不会动原数组。想直接改原数组就得用引用foreach ($orders as $order) { $order[amount] 10; } unset($order);用之后$order指向的是原数组内部元素的zval写的时候由于is_ref1不会分离直接改原数组。这里特别提醒循环结束后一定要unset($order)否则$order会保留对最后一个元素的引用后面再foreach的时候极容易出现“最后一个元素被悄悄改掉”的问题。2.4 函数传参的COW红利怎么拿到手函数默认按值传参但有了COW只读函数几乎零成本function sumAmount(array $list) { $total 0; foreach ($list as $item) { $total $item[amount]; } return $total; } $rows loadRows(); // 假设20万行 $sum sumAmount($rows); // 这里没有复制20万行但如果你在函数内部写了参数function addRow(array $list) { $list[] [amount 999]; return $list; }$list[]这一行会触发数组分离把外部$rows复制一份新的出来再追加。结果就是你看似只加了一行数据内存里却多了一份完整的大数组。所以设计函数时要想清楚这个函数是只读变换还是要产生新集合只读函数保持参数不可写既隔离了外部变量又能享受COW零拷贝红利需要产生新集合时在函数内部返回新数组但不要无脑修改传入参数。3. 实战优化把内存峰值稳定地压下来3.1 日志处理翻车现场file()为什么那么吃内存一个非常常见的场景是处理大日志。很多人习惯一行搞定$lines file($logFile); foreach ($lines as $line) { parse($line); }file()会把整个文件按行切分成一个大数组。如果日志500MB这个数组大概也要占几百MB内存还没开始处理就已经顶到天花板了。这里COW救不了你因为它只能避免“再复制一份”不能帮你省掉“读取时那份原始数据”。正确做法是逐行读取或者用生成器function readLines($path) { $fp fopen($path, rb); if (!$fp) { return; } while (($line fgets($fp)) ! false) { yield rtrim($line, \r\n); } fclose($fp); } foreach (readLines($logFile) as $line) { parse($line); }生成器不会把整份数据装进数组每次只产出当前一行内存占用基本恒定。这个例子和COW没有直接关系但它提醒了一件事COW是内存优化的一环不是万能药能不能省内存首先取决于你一开始有没有构造出那个“巨大的集合”。3.2 复用$row变量构建二维数组到底会不会复制批量处理数据时常见写法是这样$orders []; $row []; for ($i 0; $i 100000; $i) { $row[id] $i; $row[amount] rand(100, 999); $row[items] $items; $orders[] $row; }直觉上$orders[] $row每执行一次就是把$row复制一份放进$orders所以100万条数据就会复制100万次。其实不是。第一次执行$orders[] $row时$row和$orders[0]共享同一个zvalrefcount变成2。进入第二次循环执行$row[id] $i时PHP发现$row这个zval refcount大于1于是触发COW把$row分离成一个新数组刚才那个旧数组则保留在$orders[0]中。也就是说旧数据的“复制”其实发生在下一轮写$row的时候但它不是把旧数据重新拷贝了一遍只是让$row指向一个新分配的数组。这个机制带来的实际好处是$row变量可以复用不用担心“每赋值一次就深拷贝一次”。但要注意一个隐蔽坑如果你在复用$row时先赋值给$orders再修改$row那么已进入$orders的那份数据不会带上后续修改。$row []; $row[id] 1; $orders[] $row; // 此时$orders[0]里只有id $row[amount] 100; // 这里会复制一份新的$row不再影响$orders[0] $orders[] $row; // $orders[1]有id和amount$orders[0]没有amount所以复用$row时要么每轮完整构建好再入数组要么就老老实实每次新建变量。COW能帮你省掉不必要的拷贝但不会替你修正业务逻辑。3.3 内置函数会造中间数组COW管不了COW解决的是“变量共享和分离”的问题但很多内置函数本身会主动创建新数组这部分内存是绕不过去的。$rows loadRows(); // 20万行 $amounts array_column($rows, amount); // 新数组 $total array_sum($amounts);array_column会生成一个新数组array_sum再遍历一遍。如果只是要总和直接foreach累加就好中间那层$amounts完全是浪费$total 0; foreach ($rows as $row) { $total $row[amount]; }这类优化要培养习惯凡是“遍历一遍最终只是想算个聚合值”的场景不要用array_map、array_column、array_filter等函数链式调用。那些函数的结果数组可能比原数组还占内存而且COW不会帮你省掉它们产生的中间结果。3.4 利用COW做配置数据的“低成本多副本”有一个非常实用的技巧是故意利用COW做多份逻辑视图。比如项目里有一份复杂的配置数组加载一次后多个模块都要用$config loadConfig(); // 大数组几MB $webConfig $config; $cliConfig $config; $workerConfig $config;这三个变量并没有复制三份配置它们共享同一个zval内存占用基本不变。之后某个模块想临时改一下配置又不想影响全局$local $config; $local[debug] true; // 这里才复制而且只复制最外层数组写发生时COW会分离出一份新的数组给$local原$config不受影响。这个做法比array_merge再去合并整个配置要高效得多因为你大多数时候只是改几个顶层键。不过要注意如果配置是深层嵌套修改$local[db][host]会触发最外层分离再触发内层db数组分离但其他深层子数组仍然共享。这既是优点也是坑后续如果需要完整快照还是要手动深拷贝。4. 引用计数的高级利用解构、循环引用与调试手段4.1 延迟解构从大数组里摘数据并不耗内存从一个大数组里取几个字段常见的写法是$user $row[user]; $score $row[score]; unset($row); // 原大数组不再整体保留$user和$score只是分别指向原数组里对应子值的zvalrefcount加1并没有把整个$row复制一份。unset($row)只会把最外层数组的引用计数减掉如果大数组里其他字段没有任何变量引用它们会被释放而$user和$score这两个子值因为还有变量持有继续存活。这种“摘取子集再释放大数组”的做法非常适合从DB查询结果或API响应里只留几个关键字段的场景。你不需要把整个数组拆成很多变量更不需要做$user [name $row[name], ...]这种重建直接引用计数的共享原则就够了。PHP 7.1之后还能用短数组解构[$user, $score] [$row[user], $row[score]];解构出来的变量同样按引用计数共享后续谁先写谁才复制。4.2 循环引用引用计数机制的天生短板引用计数最大的敌人是循环引用。两个对象互相持有对方refcount永远减不到0内存就无法释放。class Node { public $next; public $prev; } $a new Node(); $b new Node(); $a-next $b; $b-prev $a; $a null; $b null;这段代码执行完后两个Node对象依然互相引用引用计数不会归零PHP只能靠GC的循环检测去回收而且回收时机不可控。如果这种结构出现在长驻进程里比如常驻的Web Worker或者消息消费进程内存会持续增长。解决办法是设计上尽量避免强双向依赖或者在业务结束时手动断开$a-next null; $b-prev null;PHP 7.4开始有WeakReference可以用于缓存、观察者这类“需要引用但不希望阻止回收”的场景$obj new stdClass(); $ref WeakReference::create($obj); $obj null; var_dump($ref-get()); // NULL对象已被回收不能说有了GC就万事大吉GC本身有扫描开销而且触发时机不确定。写服务端代码时双向引用、父子强引用、事件订阅器这几种结构都要谨慎。4.3 怎么观察引用计数和内存变化调试COW最直接的手段是看内存占用变化$a range(1, 1000000); $before memory_get_usage(true); $b $a; $afterAssign memory_get_usage(true); $b[] 0; $afterWrite memory_get_usage(true); echo $afterAssign - $before, PHP_EOL; // 基本为0 echo $afterWrite - $afterAssign, PHP_EOL; // 约等于一个百万整数数组大小这个例子能直观看到赋值不复制、写操作才复制。另有一个内置函数debug_zval_refcount可以查看引用计数但要注意它本身传参会带来额外引用PHP不同版本返回值的修正方式还不一样所以不要纠结绝对数字看趋势就好。生产环境更建议用memory_get_peak_usage(true)做基线$base memory_get_peak_usage(true); runTask(); $peak memory_get_peak_usage(true);通过峰值差来确认某个大数组操作到底有没有产生额外拷贝比猜要靠谱得多。5. 常见问题与排查思路实录5.1 foreach里用了为什么外面的普通变量也被改了$a [1, 2, 3]; $b $a; foreach ($a as $v) { $v $v * 2; } print_r($b); // b也变成了 [2, 4, 6]原因在于foreach使用时会强制让$a的zval进入引用集合状态。而$b当时和$a共享同一个zvalis_ref变成1后写操作不再分离开结果$b和$a被一起修改。这正是混合引用与普通赋值最容易踩的坑。如果你要保护其他共享变量循环前先复制一份目标数组或者避免在共享数组上直接foreach引用遍历。5.2 引用返回的“伪共享”函数可以返回引用但调用时不加是不会共享的function getConfig() { static $config [debug false]; return $config; } $cfg getConfig(); // 没有用普通赋值 $cfg[debug] true; print_r(getConfig()); // debug还是false想真正拿到引用必须显式$cfg getConfig(); $cfg[debug] true; print_r(getConfig()); // debug变成true这个语法细节很容易让人误判。一个函数声明的返回类型是引用调用处不写COW就会让它退化成“普通只读副本”后续写操作直接分离。除非你明确要共享变量否则这个“退化成副本”的行为反而是安全的。5.3 字符串拼接为什么那么慢COW能让字符串赋值零拷贝但$str . x这种追加每次都会生成一个全新的字符串对象。比如$buf ; foreach ($rows as $row) { $buf . $row[content]; }100万行拼接理论上会产生大量中间字符串CPU和内存都扛不住。更合理的做法是用数组收集中间片段最后implode$chunks []; foreach ($rows as $row) { $chunks[] $row[content]; } $buf implode(, $chunks);虽然$chunks数组会占一点内存但比反复分配大字符串要高效得多。COW能帮你省掉“把同一字符串复制给多个变量”的开销但省不掉“每次追加都要分配新字符串”的成本。5.4 unset之后为什么内存没有立刻降下来有同学遇到这种情况unset一个大数组之后memory_get_usage显示内存还是很高。其实不一定是泄漏常见原因有三个。一是内存管理器不会立刻把内存还给操作系统进程内可能还有空闲内存块只是PHP后续可以复用二是对象存在循环引用REFCOUNT归不了零GC还没运行三是你unset的只是最外层变量内部某个字段可能还被其他地方引用着。排查时要先用gc_status()看看GC buffer里积压了多少待回收的循环引用再用memory_get_usage对比。如果确实有循环引用编写代码时就显式断开关系而不是等GC。5.5 永远不要把COW当成深拷贝COW的分离是“逐层”的不是“整个数据结构”的。修改深层数组时外层的复制会共享内层子数组所以在某些局部修改场景下你以为自己拿的是独立副本其实内层数据仍然共享。比如$a [config [host 127.0.0.1, port 3306]]; $b $a; $b[config][port] 3307; $a[config][port]; // 还是3306这里$b[config]本身触发了复制所以修改$b里的port不会影响$a。但如果业务逻辑是“先修改外层结构再在后续步骤中修改同一个内层子数组”就要格外小心因为内层共享关系可能比你预想的更深。排查复杂数组问题时不要只靠print_r看结果直接把memory_get_peak_usage的前后差值打印出来能快速判断到底有没有发生整体复制。最后说一个我自己的习惯任何涉及大数组的方案上线前我都会写一个小脚本用memory_get_peak_usage(true)打三行基线——原始数据构建完的峰值、传参处理中的峰值、输出结果后的峰值。只要这三条数字和理论预期对得上COW和引用计数的行为就基本不会坑你。很多时候内存问题不是靠某一条魔法配置解决的而是靠一层层确认“哪些复制被推迟了、哪些中间数组根本不该存在”来治的。