资讯动态

PHP GC回收机制实例详解

发布时间:2026/10/8 19:33:53 来源:尧图企业网站定制
前言先说清一个容易被想歪的前提PHP 的「GC」不是 Java 那种从根集合出发、全堆扫描的垃圾回收器。PHP 的主力机制是引用计数reference counting垃圾回收器只是它的一个补充专门用来处理引用计数处理不了的情况——循环引用。所以更准确的叫法是「循环回收器」cycle collector。搞混这一点就会提出「GC 会不会 stop the world」「GC 是不是要遍历所有对象」这类在 PHP 里并不成立的问题。第二件要提前说的事在普通的短生命周期 Web 请求里你几乎不需要关心 GC。请求结束时 PHP 会把这次运行占用的内存整体释放循环引用根本来不及造成影响。真正需要认真对待的是长驻进程队列消费者、常驻服务、定时任务里的长时间循环。本文会分别给出这两类场景下的实例。示例基于 PHP 8.x。文中代码无法在本机执行验证写作环境没有 PHP 运行时请以本地实测输出为准。一、引用计数解决了什么又漏掉了什么PHP 堆上的每个字符串、数组、对象都带一个引用计数。规则很简单有新的变量指向它计数加一变量被unset()、被重新赋值、或者走出作用域计数减一计数降到 0内存立即释放。这个机制的好处是即时——不需要等某个回收时机内存当场就还回去了。但它有一个天生的盲区如果两个或一群数据结构互相引用它们的计数永远降不到 0。对象 A 的引用计数1来自对象 B 的属性对象 B 的引用计数1来自对象 A 的属性外部变量已经全部 unset但 A 和 B 谁也不放手→ 引用计数永远不为 0永远不会被释放这就是循环引用。历史分界线在这里PHP 5.3.0 引入了循环回收器。在此之前PHP 5.2 及更早PHP 只有纯粹的引用计数环形结构只能等到请求结束时随整体内存释放一起消失——对于运行几毫秒的 Web 请求问题不大对于跑几个小时的长驻脚本就是实打实的内存泄漏。现在这个回收器由 ini 项zend.enable_gc控制默认是开启的。二、循环长什么样两个实例实例一对象互相引用。?php // 适用于 PHP 8.0构造器属性提升需要 PHP 8.0declare(strict_types1);class Node{public ?Node $peer null;public function __construct(public string $label ) {}}$a new Node(a);$b new Node(b);$a-peer $b;$b-peer $a; // 两个对象互相引用形成环$before memory_get_usage();unset($a, $b); // 外部变量没了$after memory_get_usage();printf(unset 前后内存变化%d 字节\n, $before - $after);printf(本次回收的环数量%d\n, gc_collect_cycles());把这段跑起来会看到一个反直觉的现象unset()之后内存几乎没有减少差值接近 0因为在引用计数的视角里这两个对象仍然「有人用」。直到gc_collect_cycles()被调用它们才真正被回收。这里的具体字节数取决于你的 PHP 版本请以实测为准。实例二数组通过引用构成环。?php // 适用于 PHP 8.0$a [one];$a[] $a; // 数组的一个元素引用了数组自己unset($a); // 外部变量消失但数组仍被自己的元素引用着echo gc_collect_cycles(), \n; // 这里才会把它回收掉这两种形态就是 PHP 里循环引用的全部来源。归纳一下能构成环的只有数组、对象以及显式引用。标量和字符串不持有别的值的引用不可能形成环。这也解释了为什么回收器不需要扫描所有变量——它只关心那些「可能成为环的一员」的值。三、回收器是怎么工作的PHP 的循环回收器基于一篇经典论文Bacon 与 Rajan 的Concurrent Cycle Collection in Refcounting Environments但 PHP 的实现是同步的不是论文里那种并发收集。整体流程可以这样理解登记候选根。当某个值的引用计数被减少、而它本身是数组或对象时引擎会把它放进一个「根缓冲区」root buffer。缓冲区有固定容量默认是 10000 个根。触发时机。缓冲区满了会自动触发一轮回收此外任何时候都可以手动调用gc_collect_cycles()强制跑一轮。标记。回收器把候选根标成「可疑」内部概念上是紫色然后模拟地把每个候选的引用计数减去「来自内部的引用数」。如果减完之后计数大于 0说明还有外部引用它是活的减完归零的那些才是真正的垃圾。回收。被判定为垃圾的环整体释放释放过程中会调用对象的__destruct()。两个必须知道的细节析构函数的执行顺序不保证。官方手册明确提醒过这一点被回收的环里各个对象的__destruct()以什么顺序执行不是你能依赖的东西。有顺序要求的清理逻辑不要放在析构里。根缓冲区不是「所有变量」。只有引用计数减少过的数组/对象才会进入候选队列普通标量、字符串不进。这就是为什么这个机制不会拖慢整体执行。四、实例长驻进程里该怎么管短请求不用管长跑进程必须管。下面这段模拟一个「每轮产生一批循环引用」的消费者?php // 适用于 PHP 8.0declare(strict_types1);class Item{public ?Item $peer null;public function __construct(public string $name) {}}/** 造一批首尾相接的对象函数返回后只剩下环内部互相引用 */function makeBatch(int $n): void{$nodes [];for ($i 0; $i $n; $i) {$nodes[$i] new Item(item-{$i});}$count count($nodes);for ($i 0; $i $count; $i) {$nodes[$i]-peer $nodes[($i 1) % $count];}}for ($round 1; $round 5; $round) {makeBatch(1000);$status gc_status();printf(第 %d 轮内存 %d 字节候选根 %s累计已回收 %s阈值 %s\n,$round,memory_get_usage(),$status[roots] ?? ($status[buffer_size] ?? 未知),$status[collected] ?? 未知,$status[threshold] ?? 未知);}// 主动回收把累积的环一次性清掉printf(手动回收%d 个环\n, gc_collect_cycles());这段代码想说明三件事每轮 1000 个对象构成一个大环它们不会被自动回收——因为根缓冲区的默认阈值是 10000累积量还没到。长跑进程里的内存增长往往就是这么来的。gc_status()可以观察当前状态。它在 PHP 7.3 引入返回的字段在后续版本尤其是 PHP 8.3有过扩充和调整所以代码里用??兜底不要硬编码单个字段名具体字段以官方手册为准。长跑进程里主动在合适的时机比如每处理完一批任务调用一次gc_collect_cycles()是比「调大阈值」更可控的做法。如果确定一段代码不会产生循环引用例如纯数值计算可以在这一小段里gc_disable()省掉登记候选根的开销但用完必须gc_enable()恢复——忘了恢复后面的循环引用就只能一直堆着。常见坑点❌ 以为 PHP 的 GC 是「全堆扫描式」的担心它带来长时间的停顿✅ PHP 以引用计数为主循环回收器只处理登记在案的候选根不做全堆遍历。短请求里它几乎不构成可观测的开销。❌unset($obj)之后立刻断言内存降下来了✅ 只要还有别的引用或环存在内存就不会释放。用gc_collect_cycles()后再看并注意memory_get_usage()与memory_get_usage(true)口径不同。❌ 把gc_collect_cycles()的返回值当作「释放了多少字节」✅ 它返回的是回收的循环引用数量不是字节数。❌ 在普通的 Web 控制器里到处调gc_collect_cycles()想「优化内存」✅ 请求结束会整体释放这些调用只是白费 CPU。这个函数属于长驻进程和特定批处理场景。❌ 把有顺序要求的清理逻辑写进__destruct()✅ 被回收的环里各个析构函数的执行顺序不被保证。需要顺序就显式写清理方法在确定的位置调用。❌ 用debug_zval_dump()打印的 refcount 直接推导「这里有没有泄漏」✅ 函数调用自身也会影响计数输出不能直接当结论。要精确观察引用计数用 Xdebug 的xdebug_debug_zval()。❌ 用unset($v)抵消foreach ($arr as $v)以为万事大吉✅ 循环引用型 foreach 结束后必须unset($v)否则$v仍是指向最后一个元素的引用后续同名循环会改写它。这类「引用残留」问题和循环回收是两码事得分别处理。❌ 认为字符串、整数也会「互相引用」导致泄漏✅ 只有数组、对象和显式引用能构成环。标量与字符串不持有其他值的引用不在回收器的候选范围内。总结问题结论PHP 的主力内存机制是什么引用计数计数归零立即释放为什么还需要 GC循环引用让计数永远降不到 0什么时候才有真正的 GCPHP 5.3.0 引入循环回收器更早的版本只有引用计数谁能构成环数组、对象、显式引用标量和字符串不会自动回收的触发条件根缓冲区默认 10000 个根被填满手动回收怎么做gc_collect_cycles()返回回收的环数量回收时析构顺序不保证不要依赖什么场景才需要关心长驻进程队列、常驻服务、长时脚本一句话结论PHP 的内存管理以引用计数为核心GC 只是为循环引用打的那个补丁它既不会全堆扫描也不会在短请求里给你添麻烦。所以你该记住的不是「怎么调 GC 参数」而是两件事——循环引用只对长驻进程构成真实威胁以及在长跑循环里定期主动调用gc_collect_cycles()比依赖阈值触发更可控。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑