PHP 垃圾回收机制由引用计数与循环引用收集器两部分组成,前者负责绝大多数变量的即时释放,后者专门处理对象互引用产生的内存孤岛。理解 PHP 垃圾回收的触发条件、掌握循环引用的监控手段、落实内存泄漏规避措施,是常驻内存场景(Swoole、Laravel Octane、队列消费者)稳定运行的前提。以下内容基于 PHP 官方文档与 Zend 引擎实现整理,代码示例可在 PHP 8.1 及以上版本直接运行验证。

一、PHP 垃圾回收机制的运行原理
1 引用计数与 zval 存储结构
PHP 变量以 zval 结构体承载,zval 内部记录变量值与引用计数(refcount)。变量赋值、作为参数传递、被数组或对象属性持有时,refcount 相应增加;变量离开作用域或被 unset() 销毁时,refcount 相应减少。refcount 归零的 zval 被立即释放,这是 PHP 内存管理的第一层机制,响应快、无遍历开销,覆盖标量、数组、对象等全部类型。
2 循环引用为何无法自动释放
当对象 A 的属性持有对象 B,同时对象 B 的属性持有对象 A,两个对象的 refcount 至少为 1。外部变量全部 unset 之后,两个对象互相持有对方,refcount 无法归零,引用计数机制失去回收能力,形成内存孤岛。自引用(对象属性持有自身)属于同类问题。PHP 5.3 之前此类内存只能等待进程结束释放,PHP 5.3 起引入的循环引用收集器(Cycle Collector)专门解决该问题。
3 根缓冲区与标记清除算法
循环引用收集器维护一个根缓冲区(root buffer),编译期常量 GC_ROOT_BUFFER_MAX_ENTRIES 决定容量,默认 10000 条。数组或对象的 refcount 减少但未归零时,对应的 zval 作为”疑似循环根”(possible root)写入缓冲区。缓冲区写满或手动调用 gc_collect_cycles() 时,回收器执行一次完整的标记清除:
-
标记阶段:对每个疑似根做深度优先遍历,沿途将遇到的 zval 的 refcount 模拟减一,标记为灰色;
-
判定阶段:再次扫描,refcount 归零的节点判定为垃圾,标记为白色;
-
清除阶段:释放全部白色节点,恢复存活节点的真实 refcount。
该算法源自 Bacon 等人的同步循环回收论文,只处理缓冲区内的疑似根,不扫描全量内存,单次回收耗时与循环规模相关而非堆大小相关。php.ini 中的 zend.enable_gc 控制回收器开关,默认值为 On。
二、循环引用内存泄漏的典型场景
1 对象互引用与自引用
class Node
{
public ?Node $next = null;
public ?Node $prev = null;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->prev = $a;
unset($a, $b);
// 两个 Node 对象互相持有,refcount 均不为零
// 等待循环引用收集器介入才能释放双向链表、树结构的父子双向指针、ORM 模型间的双向关联,都属于高发结构。单元测试或长循环批量构造此类对象时,泄漏速度以每轮两个对象为基数累积。
2 闭包捕获形成的隐式循环
闭包通过 use 捕获 this,而 this 的某个属性又保存该闭包时,循环即刻成立:
class Service
{
private $callback;
public function register(): void
{
// 闭包捕获 $this,$this->callback 持有闭包
$this->callback = function () {
return $this->handle();
};
}
private function handle(): string
{
return 'ok';
}
}此类循环在代码评审中难以肉眼识别,Closure::bind() 与静态闭包改写是常见拆解手段:不需要访问 $this 的闭包应声明为 static function,从语法层面切断捕获。
3 静态属性与全局容器的持续累积
静态数组、全局变量、单例内部的注册表,生命周期与进程一致。向其中持续追加元素不构成严格意义的循环引用,但在常驻进程中的内存表现与泄漏相同:
class Registry
{
private static array $items = [];
public static function add(object $item): void
{
self::$items[] = $item; // 只增不减,内存线性上涨
}
}此类问题的治理核心是容量上限,LRU 淘汰或定期清空均可,与 GC 机制无关,但常与循环引用泄漏同时出现、混淆排查方向。
4 事件监听与回调未解绑
事件分发器持有监听器闭包,闭包捕获业务对象,业务对象又反向引用分发器时,三方构成循环。长生命周期进程中反复注册而不注销,监听器列表本身也在膨胀。规范做法是在对象销毁路径中显式调用 off()/removeListener(),或改用弱引用持有监听器。
5 常驻内存环境的放大效应
PHP-FPM 模式下请求结束即销毁全部符号表,泄漏在单次请求生命周期内被掩盖。Swoole、WorkerMan、Laravel Octane、queue:work 消费进程长期运行,每轮请求遗留的循环垃圾与累积容器持续占用堆内存,最终触发 memory_limit 致命错误或系统 OOM。常驻内存改造上线前,循环引用治理属于必做项而非优化项。
三、循环引用与内存状态的监控手段
1 gc_status() 读取回收器状态
gc_status() 自 PHP 7.3 起可用,返回回收器运行快照:
$status = gc_status();
// runs => 回收器已运行次数
// collected => 已回收的循环数量
// threshold => 根缓冲区触发阈值(默认 10000)
// roots => 当前缓冲区内疑似根数量PHP 8.3 起返回值扩展,新增 running(回收是否进行中)、protected(根缓冲区是否受保护)、full(缓冲区是否已满)、buffer_size(缓冲区容量)、application_time(应用累计运行时长)、collector_time(回收累计耗时)、destructor_time(析构累计耗时)、free_time(释放累计耗时)八个字段。collector_time 占比持续偏高,说明循环垃圾产生速度过快,应优先排查对象图设计而非调整回收频率。
2 gc_collect_cycles() 强制回收
gc_collect_cycles() 立即执行一次完整回收,返回本次回收的循环数量。该返回值是重要的诊断信号:
-
每次调用均回收大量循环,说明业务代码在稳定制造循环垃圾,需要定位具体结构;
-
返回值长期为零而内存仍在上涨,说明泄漏源是存活根(静态容器、全局状态)而非循环引用,应转向容器容量审计。
3 memory_get_usage 系列函数
memory_get_usage() 默认返回当前脚本实际占用的内存字节数,传入 true 时返回 Zend 内存管理器向系统申请的真实分配量(含内存池预留块)。memory_get_peak_usage() 返回峰值。监控埋点应同时记录两组数值与 gc_status() 输出,形成可回溯的时间序列。
4 队列消费者的监控埋点示例
$processed = 0;
while (true) {
$job = $queue->pop();
$job && handle($job);
if (++$processed % 500 === 0) {
$cycles = gc_collect_cycles();
$status = gc_status();
$logger->info('gc metrics', [
'memory' => memory_get_usage(true),
'peak' => memory_get_peak_usage(true),
'cycles' => $cycles,
'collected' => $status['collected'],
'roots' => $status['roots'],
]);
}
}每 500 个任务输出一次快照,日志接入 Grafana 或 ELK 后即可绘制内存水位曲线。水位呈锯齿状且每次回收后回落到基线,属于健康状态;基线本身持续抬升,即可判定泄漏存在。
5 php-meminfo 与专业分析工具
php-meminfo 是开源扩展,提供 meminfo_dump() 函数,将当前内存中全部变量及引用关系导出为 JSON 快照,配合分析器可定位具体是哪两个对象构成循环,适合开发环境与预发环境使用。Xdebug 的 xdebug_debug_zval() 可查看单个变量的 refcount。生产环境的内存趋势监控可接入 Blackfire、Tideways 等 APM 服务,避免在业务代码中长期保留埋点开销。
四、内存泄漏的规避与治理方法
1 显式断开引用
对象使用完毕后,将持有其他对象的属性置为 null,循环链条即从内部断开,引用计数机制重新生效:
$a->next = null;
$b->prev = null;
unset($a, $b); // refcount 归零,立即释放,无需等待 GC该方法成本最低,前提是对象生命周期边界清晰。析构函数 __destruct() 中不应向外部容器写入新引用,否则会在回收阶段制造新的根。
2 WeakReference 与 WeakMap
WeakReference 自 PHP 7.4 起可用,WeakMap 自 PHP 8.0 起可用。弱引用不增加目标对象的 refcount,目标被回收后弱引用自动失效,是打破循环的语法级方案:
class Cache
{
private WeakMap $map;
public function __construct()
{
$this->map = new WeakMap();
}
public function remember(object $key, mixed $value): void
{
// 以对象为键,对象释放后条目自动移除
$this->map[$key] = $value;
}
}观察者列表、双向关联的反向指针、以对象为键的缓存,是 WeakReference 与 WeakMap 的三类标准适用场景。需要注意 WeakMap 的键只能是对象,且不可迭代计数语义与普通数组完全一致,count() 结果随键对象回收实时变化。
3 定期强制回收
常驻进程不应依赖根缓冲区自然写满,建议每处理固定数量任务后调用一次 gc_collect_cycles()。Swoole 服务可在 onWorkerStart 中注册定时器周期性回收。回收本身是 CPU 密集型操作,单次耗时与循环规模成正比,频率需结合 gc_status() 的 collector_time 指标压测确定,每请求调用一次通常是过度设计。
4 关闭 GC 的取舍
gc_disable() 关闭循环引用收集器后,refcount 减一不再写入根缓冲区,可省下全部回收开销。代码路径确认无循环引用且进程生命周期短的 CLI 脚本适用此方案,Composer 等命令行工具内部即采用该策略换取执行速度。常驻进程禁用 GC 属于高风险操作,必须以静态分析确认无循环产生为前提,并配套内存水位告警。
5 容器容量上限与请求边界重置
长生命周期容器一律设置容量上限:缓存数组实现 LRU 淘汰,对象池与连接池设定 max size,注册表提供显式清理接口。框架层面,Laravel Octane 在 RequestTerminated 事件中重置应用容器状态,并以 –max-requests 参数控制 Worker 处理固定请求数后重启;PHP-FPM 的 pm.max_requests 与 queue:work 的 –max-jobs 属于同一思路,以进程级重启兜底代码级遗漏。
五、生产环境配置参考
php.ini 中与垃圾回收直接相关的配置仅 zend.enable_gc 一项,默认 On,生产环境应保持开启。memory_limit 建议按业务实际峰值上浮 30% 至 50% 设定,过大的 memory_limit 会推迟问题暴露时间,使泄漏进程长期占用物理内存。OPcache 与 GC 相互独立,开启 OPcache 不影响回收器行为。版本选择上,PHP 8.x 系列对 Zend 内存管理器与回收器实现持续优化,gc_status() 在 PHP 8.3 提供的分阶段耗时字段为生产调优提供了直接依据,新部署的常驻内存服务建议使用 PHP 8.2 及以上版本。

