
写一篇 PHP 底层 GC 机制的高质量博文需要把引用计数和循环引用这两块核心讲透同时照顾到实际开发中的排查和调优。我直接按从业者分享的调性来从原理讲到实战注意保持专业度和可读性的平衡。1. 从一次线上内存告警聊起先讲个真实的场景。某个凌晨两点业务群里突然有人发了一张告警截图某台 PHP-FPM 机器的内存使用率直接飙到了 90% 以上。第一反应当然是重启进程救急但作为一名写过多年 PHP 的老兵我清楚地知道这种业务量没涨、内存却持续走高的现象十有八九和 PHP 的垃圾回收机制有关。PHP 从 5.3 版本开始引入了一套专门处理循环引用的 GC 机制但这个机制可不是银弹。它在特定场景下反而会加剧内存占用——缓冲区里的根缓冲区root buffer积压GC 执行时全量扫描带来的性能损耗这些都是实打实的代价。所以搞懂 PHP 的引用计数和循环引用 GC不是为了面试背八股而是你在生产环境排查内存泄漏、优化长生命周期脚本比如 Swoole 常驻进程、队列消费者时必须具备的基本功。这篇文章会把 PHP 的引用计数机制、zval 结构体、循环引用的形成原理和检测算法一层层拆开结合可运行的代码示例和我在项目中踩过的坑讲清楚这个底层 GC 到底是怎么工作的以及你写代码时应该注意什么。不管你是刚接触 PHP 底层原理的进阶开发者还是被线上内存泄漏折腾过的老兵这篇文章都能给你一些可复用的思路。在开始之前先把结论抛出来引用计数是 PHP 内存管理的基石而循环引用 GC 是一套建立在引用计数之上的补丁机制——它解决的是引用计数法天生的缺陷但它本身也有成本和边界。理解了这句话后面所有细节都顺了。2. 引用计数的核心机制与 zval 结构2.1 变量在内存中到底是什么样子很多 PHPer 写了几年业务代码却不清楚一个$a hello在内存里是长什么样的。简单说PHP 中每个变量都是由一个叫 zval 的结构体承载的。zval 里存了变量的类型、值还有一个关键的字段refcount也就是引用计数。$a hello; $b $a;执行完这两行代码之后$a和$b指向同一个 zval这个 zval 的refcount从 1 变成了 2。这里的核心逻辑是 PHP 采用了写时复制Copy On Write策略——只有当其中一个变量被修改时才会真正复制一份新的 zval并把两个变量的引用拆分开来。$b world; // 此时才发生复制$a 和 $b 各指向不同的 zvalrefcount的设计思路是当一个 zval 的引用计数降为 0说明没有任何变量在引用它这块内存就可以被回收了。这个机制非常高效因为内存释放是即时的不需要像某些语言的 GC 那样等到某个触发点才开始回收。PHP 7 之后 zval 的结构经历了一次重大优化直接内联在 zval 里存储不再通过指针间接访问这带来了巨大的性能提升。到 PHP 8 的时候zval 结构更紧凑字段布局也更合理。但不管结构怎么变refcount这个核心概念一直在。2.2 引用计数的三种状态与 unset 操作在实际开发中你会发现refcount的取值情况远比想象中复杂。我整理了最常见的三种状态状态refcount 值说明无人引用0内存已被回收zval 从变量容器中移除单一引用1最常见状态普通变量赋值后即为此状态多重引用1多个变量指向同一 zval包括引用传参、数组元素引用等用xdebug_debug_zval()这个函数能看到一个变量对应的 zval 详细信息这是排查引用问题最直接的工具之一需要安装 Xdebug 扩展。$a hello; xdebug_debug_zval(a); // refcount1 $b $a; // 注意这里是引用赋值不是普通赋值 xdebug_debug_zval(a); // refcount2$b 和 $a 指向同一个 zvalunset操作的本质不是删除变量而是减少一次引用。当refcount减到 0 时zval 才真正被销毁。这个区分非常重要很多人以为unset($a)就直接释放内存了但实际上如果还有别的变量引用同一个 zval内存并不会立即释放。$a hello; $b $a; unset($a); // 只是把 refcount 从 2 减到 1内存没有释放 echo $b; // 仍然输出 hello$b 正常可用2.3 写时复制与引用传递的微妙关系写时复制是引用计数体系的配套机制。它的目的是让多个变量共享同一块内存只有发生写操作时才复制从而最大化节省内存。但这里有个容易踩坑的点引用传递会强制打破写时复制策略。function modify($var) { $var modified; } $data [name test]; modify($data[name]);这种函数参数引用传值的方式会让外部变量的 zval 和函数内部的局部变量共享同一个引用计数。如果你的业务代码在循环里大量使用引用传参就可能导致某些本应释放的中间变量迟迟不能释放造成内存的隐形增长。我在项目中定过一条规矩除非确实需要修改原始数据否则禁止在函数签名里使用引用传参。这一个简单的编码规范能帮你减少一大批隐性的引用计数问题。匿名函数闭包的use ($var)也是同样的道理闭包会捕获外部变量的引用这直接影响后面的循环引用判定。3. 循环引用 GC 的原理与触发时机3.1 循环引用是怎么把内存吃掉的引用计数有一个公认的理论缺陷它无法处理循环引用。两个对象互相引用或者一个数组引用了自身这些情况下即使外部已经没有任何变量在引用它们它们的refcount依然不为 0内存就永远得不到释放。class Node { public $next; } $a new Node(); $b new Node(); $a-next $b; // $b 的 refcount外部 1 $a 的引用 1 2 $b-next $a; // $a 的 refcount外部 1 $b 的引用 1 2 unset($a); // $a 的 refcount 减为 1$b 还在引用它 unset($b); // $b 的 refcount 减为 1$a 还在引用它 // 此时 $a 和 $b 互相引用但外部没有任何变量指向它们——内存泄漏我把这个场景跑过无数次。用memory_get_usage()观察你会发现即使unset之后内存占用并不会回落这就是典型的循环引用导致的内存泄漏。数组也会发生同样的问题而且数组的循环引用更容易被忽视$arr [value test]; $arr[self] $arr; // 数组引用了自身 unset($arr); // refcount 永远不会降为 03.2 PHP 循环引用 GC 算法的工作流程从 PHP 5.3 开始官方实现了一套基于标记-清除Mark and Sweep算法的循环引用 GC。它的核心思路是定期扫描那些可能形成循环引用的 zval通过模拟减少一次引用计数来确认它们是否真的孤立无援从而决定是否回收。这里有一个关键概念叫根缓冲区root buffer又被称为紫色缓冲区。当一个 zval 的refcount减少时如果它仍然大于 0这个 zval 就会被放入根缓冲区等待处理。根缓冲区默认大小是 5000 个节点你可以在php.ini里通过zend.enable_gc切换开关把 GC 完全关闭不推荐。整个算法流程大致如下收集阶段当根缓冲区达到阈值默认 5000 个待处理节点时GC 开始工作。深度优先遍历从根缓冲区中的每个可能根节点出发沿引用链遍历所有可达的 zval将它们的refcount减 1模拟去掉这个引用的效果。再遍历判定经过模拟减 1 之后如果某个 zval 的refcount变为 0说明它只被循环引用内部互相持有外部已经没有任何引用了。这就是一个候选的垃圾节点。清除阶段把所有refcount为 0 的节点真正释放恢复其他仍然可达节点的refcount因为之前只是模拟减 1需要加回来。这个过程可以用一个简单类比来理解想象一屋子人互相拉着手围成一个圈外面的人已经全部走了。要判断这圈人是否都该清除就模拟让每个人松开一只手看还有没有人被拉着。如果所有人都松手后还站在原地说明他们确实和外界没有联系了可以整组清走。这个类比你体会一下后面看代码会更顺。3.3 手动触发 GC 的时机与代价循环引用 GC 是自动触发的它在满足条件时会自动运行你也可以通过gc_collect_cycles()手动触发。但这里有一个重要的权衡GC 是有代价的而且代价不低。最直接的开销是性能。GC 需要遍历引用链当你的程序里存活的对象非常多时这个遍历过程会消耗大量的 CPU。Zend 引擎的默认策略是非紧急不强跑所以它会尽量让根缓冲区攒到一定数量5000个才触发一次完整的回收流程。这也是为什么很多时候你会发现内存一直涨但 GC 迟迟不工作。我在做 Swoole 长驻进程项目的时候就遇到过这种情况。因为 Worker 进程不退出循环引用产生的垃圾会持续累积直到触发 GC 阈值才开始回收。但如果每个请求周期都会产生几百个循环引用积压速度远大于回收速度内存照样会持续飙升。后来我是在合适的时机主动调用gc_collect_cycles()并且配合gc_status()观察 GC 的运行统计数据才算把内存曲线稳定下来。需要注意gc_collect_cycles()的返回值是本次回收的 zval 数量。通过它你能量化地看到每次回收到底清理了多少垃圾这在调试内存泄漏问题时非常有用。// 记录 GC 运行信息 gc_status(); // 输出示例 // [running 0, protected false, full false, buffer_size 5000, ...]3.4 PHP 版本对 GC 机制的演进影响不同 PHP 版本对 GC 机制的实现细节有显著差异这一点经常被忽略。我整理了一张表方便你对照自己项目里的 PHP 版本版本GC 相关变化影响PHP 5.2 及以前无循环引用 GC循环引用只能靠脚本结束释放PHP 5.3引入循环引用 GC根缓冲区机制上线默认开启PHP 7.0zval 内联refcount 存入 zval 内部GC 扫描效率大幅提升PHP 7.3改进根缓冲区溢出处理策略降低极端场景下内存峰值PHP 7.4引入 WeakReference为消除循环引用提供了新工具PHP 8.0进一步优化 zval 的引用计数内部表示减少了无谓的 refcount 操作特别提一下 PHP 8.0 的优化它在 zval 内部用了一个叫引用计数隔离的技巧。某些操作不再直接修改 refcount 指针而是在安全前提下做直接赋值这个优化在实测中能带来 3%-5% 的性能提升。虽然单看数字不大但在高并发场景下这 3%-5% 可能就是你的 FPM 进程在 CPU 满负荷与正常运转之间的差距对做底层优化的同学来说值得关注。4. 弱引用与编码层面的循环引用规避4.1 WeakReference 和 WeakMap 的正确用法PHP 7.4 引入了 WeakReference这是官方提供的一把解决循环引用问题的手术刀。它的作用是持有一个对象的引用但不会增加这个对象的refcount。也就是说你可以安全地引用一个对象同时不会阻止这个对象被回收。看一个实际场景你有一个配置管理器它保存了所有已加载的配置对象。如果配置管理器持有这些对象的强引用而配置对象内部又引用了配置管理器比如需要回调管理器的事件这就是典型的循环引用。用WeakReference改造后配置管理器只持有弱引用配置对象可以被正常回收。class ConfigManager { private array $configs []; public function register(object $config): void { // 用弱引用保存不会阻止对象回收 $this-configs[spl_object_id($config)] WeakReference::create($config); } public function get(object $config): ?object { $id spl_object_id($config); if (!isset($this-configs[$id])) { return null; } return $this-configs[$id]-get(); } }PHP 8.0 进一步推出了 WeakMap它只能在对象作为 key 时使用特别适合做对象级别的元数据存储或缓存。WeakMap 的底层实现就是弱引用的集合逻辑目的就是让容器不会成为对象回收的阻碍。// PHP 8.0 的 WeakMap 示例 $cache new WeakMap(); $obj new stdClass(); $cache[$obj] [cached_data ...]; unset($obj); // $obj 销毁后WeakMap 中的条目会被自动清理不会造成内存滞留4.2 项目中的循环引用高危场景结合我的排查经验实际开发中循环引用最容易出现在以下几个场景中ORM 关联模型用户模型关联文章模型文章模型又反向关联用户模型如果模型生命周期跨度长比如被缓存极易形成循环引用。事件订阅者事件分发器持有订阅者引用订阅者内部又持有分发器引用形成互引。这是最常见的循环引用模式之一。单例模式单例对象内部保存了其他单例对象的引用而这些对象又反向引用回来整个应用生命周期内都无法释放。闭包捕获闭包通过use ($var)捕获外部变量而这个外部变量本身又包含对这个闭包的引用形成环。针对这些场景我总结了一套规避方案场景推荐方案ORM 关联模型使用短生命周期 DTO避免模型长驻事件订阅者使用 WeakReference 替代强引用单例模式确保不构成闭环或设计为无状态闭包捕获尽量避免引用捕获改用值捕获或参数传递4.3 一个完整的循环引用优化实战这里分享一个我之前处理过的真实案例。项目用了一个简单的数据库连接池连接对象内部保存了一个日志对象日志对象反过来需要知道连接对象的信息比如连接 ID以便记录日志。典型的双向引用class Connection { public Logger $logger; public string $id; } class Logger { public ?Connection $conn; public function log(string $msg): void { // 需要访问 $conn-id } }原本的实现中$conn和$logger互相强引用。由于连接池里的连接是长驻的这个循环引用就永远无法被回收。更麻烦的是每次连接重连都会创建一个新的 logger旧的 logger 因为还持有旧的连接引用也无法释放。最后整个连接池的内存呈现阶梯式上涨直到 OOM。重构方案很简单Logger 里不再持有完整的 Connection 对象只保存一个string $connId。需要记录日志时传 ID 进来不持有对象引用。这样一来Connection 可以安全地持有 LoggerLogger 本身不引用 Connection环就被打破了。class Logger { public function log(string $connId, string $msg): void { // 只使用连接 ID不持有 Connection 对象 } }这个案例给到我的启发是消灭循环引用的最好时机不在 GC而在代码设计层面。只要在设计阶段审视一下对象之间的引用方向你就能避免大量后期排查内存问题的痛苦。当然如果确实无法避免循环引用比如业务模型天然存在双向关联那就要合理利用gc_collect_cycles()或者弱引用来兜底不能完全依赖自动 GC 去猜你的回收时机。5. 排查内存泄漏与 GC 相关问题的调试实录5.1 用工具量化内存增长的边界这块内容在常规文档里很难找到系统性的整理我把自己的排查思路梳理成一条可执行的路子。排查内存泄漏问题的第一步是确认它到底是不是循环引用造成的还是单纯的引用计数异常、静态变量累积、或者 PHP-FPM 进程本身的缓慢内存增长专业术语叫 RSS 漂移。常用方法是在业务代码的关键路径上用memory_get_usage(true)打点记录每个函数调用前后的内存差。内存增长明显的点往往就是嫌疑对象。我更喜欢用 Xdebug 的xdebug_debug_zval()配合gc_status()来交叉验证。比如在怀疑某个对象是否被循环引用持有的时候先unset掉外部引用然后调用gc_collect_cycles()看返回值是否大于 0。如果每次都大于 0说明确实存在需要 GC 介入才能释放的循环引用垃圾。以一个简单的排查脚本为例while (true) { $obj new SomeClass(); $obj-ref $obj; // 自引用 unset($obj); $count gc_collect_cycles(); echo 收集到 {$count} 个垃圾节点当前内存: . memory_get_usage(true) . PHP_EOL; usleep(50000); }这个脚本能直观地展示循环引用和 GC 之间的博弈。如果你把$obj-ref $obj这一行注释掉gc_collect_cycles()的返回值就会恒为 0因为普通的引用计数已经足够回收了。5.2 高频问题速查表我把项目中常见的内存/GC 问题整理成了一张速查表方便你直接对号入座症状可能原因排查方向解决方案内存持续上涨但业务量稳定循环引用或静态属性累积用gc_status()观察 buffer_size 是否常满检查对象引用方向使用 WeakReferencegc_collect_cycles()返回大量节点生产代码中存在大量循环引用定位产生循环引用的代码路径重构双向引用或设置定时回收内存突然暴涨根缓冲区溢出PHP 7.3 前常见升级 PHP 版本PHP 7.3 已优化溢出处理策略调用了gc_collect_cycles()但内存不降非循环引用的强引用持有静态数组等用xdebug_debug_zval()检查 refcount手动清除静态缓存或用 WeakMap关闭 GC 后性能正常但内存暴涨高并发下 GC 扫描开销占比过高权衡内存增长与 GC 频率调大gc_buffer_size或手动控制 GC 时机5.3 我在实际项目中踩过的三个隐形坑第一个坑是误以为关了 GC 就能提升性能。确实zend.enable_gc 0能省下 GC 扫描的 CPU 开销但代价是循环引用产生的垃圾永远不会被回收。如果你的业务代码里存在循环引用这是常有的事内存就会持续增长。高并发短生命周期请求还可能有 FPM 进程重启兜底但长驻进程比如 Workerman、Swoole就真的会吃满内存直到崩溃。所以关闭 GC 一定要确认代码里没有循环引用或者有其它兜底机制。第二个坑是过度使用gc_collect_cycles()。之前有个同事为了控制内存在每次请求结束后都调用一次gc_collect_cycles()。结果性能测试发现接口的 P99 延迟涨了将近一倍。原因很简单GC 是全局性的它要扫描整个进程内的所有活跃对象而你为了回收几个循环引用付出了全量扫描的代价。正确的用法是设置一个频率比如每处理 100 个队列消息调用一次或者只在检测到内存达到峰值线时才调用而不是每次请求都跑。用频率控制成本而不是用全量调用换内存。第三个坑和数组引用的隐蔽性有关。有些代码会在循环里反复构建这种结构$result []; foreach ($rows as $row) { $item getSomething($row); $result[] $item; // 每次循环都把同一个变量引用放进去 }这段代码的问题在于循环结束后$result里的每个元素都指向同一个 zval最后一次循环的$item而且$item本身因为被引用refcount 也不为 0。看起来只是引用赋值的小细节却可能在进程生命周期内造成持续的额外引用累积。这种问题非常隐蔽用xdebug_debug_zval()检查时你会看到refcount出奇地高。类似的代码模式我现在的排查习惯是凡是涉及的赋值都打上重点标记。引用操作是引用计数问题的重灾区没有之一。5.4 实战中的调优底线最后给几条底线性质的经验总结第一分清场景。短生命周期脚本比如 CLI 跑一次就退出循环引用 GC 基本没用脚本结束就释放了。长驻进程Swoole、Workerman、常驻 CLI才是循环引用 GC 真正发挥作用的地方也是内存排查的主战场。第二观察 GC 统计信息。gc_status()提供的buffer_size、collected、threshold等字段是你判断是否需要人工干预的依据。如果buffer_size经常处于满的状态说明 GC 经常被迫工作这本身就是一个值得关注的性能信号。第三在设计阶段就消灭循环引用。弱引用、WeakMap、DTO 设计模式、事件总线模式这些工具能在架构层面规避大部分循环引用。依赖 GC 去擦屁股是下策虽然 PHP 的 GC 做得不差但那只是最后一道保险不是你的设计常态。我个人的体会是理解 PHP 的引用计数和循环引用 GC最大的收获并不是我能调 GC 参数这种操作层面的能力而是让你在写代码时天然地避开那些会产生环的设计。排查内存问题就像在黑暗里找东西你手里有手电筒能照亮原理和地图能指引排查方向找起来就快得多。那些一遇到内存上涨就重启进程的做法都是在逃避问题不是解决问题。