PHP 7+ 的 GC 通过根缓冲区和模拟删除机制识别循环引用,仅在缓冲区满或调用 gc_collect_cycles() 时触发扫描,不实时回收;unset() 后内存不立即释放是因为循环中 refcount 卡在 ≥1,需 GC 扫描才能回收。

PHP 7+ 的 GC 是怎么发现循环引用的
PHP 的垃圾回收器(GC)不靠引用计数实时清理,而是用“根缓冲区 + 模拟删除”机制来识别循环引用。它只在根缓冲区满(默认 10,000 个节点)或手动调用 gc_collect_cycles() 时触发扫描,不是每次 unset() 都跑一遍。
关键点在于:GC 不直接追踪“谁引用了谁”,而是把所有可能成为循环起点的 zval(比如数组、对象)先放进根缓冲区;然后模拟一次“强制断开所有引用”,再看哪些 zval 还能被从根集合反向访问到——那些访问不到、但引用计数又没归零的,就是循环引用残留。
- 只有复合类型(
array、object)才可能进根缓冲区,标量(int、string)完全不参与 GC - 如果循环里混有资源(
resource),GC 仍能处理,但资源本身生命周期由独立机制管理 -
gc_disable()会停用整个 GC,但不会清空已积累的根缓冲区,下次启用时仍会处理
为什么 unset() 后内存不立刻释放
这是最常被误解的地方:调用 unset($a) 只是减少引用计数,如果 $a 参与了循环(比如对象 A → 属性 B → 对象 A),它的 refcount 就卡在 ≥1,无法被引用计数器回收。此时内存实际还在,只是“逻辑上该释放却不能放”。
GC 不是即时的,所以你看到 memory_get_usage() 没变化,不等于泄漏——它只是还没扫到。
立即学习“PHP免费学习笔记(深入)”;
- 手动触发
gc_collect_cycles()能强制扫描并回收,适合在长任务中间点调用(如批量处理循环内) - 根缓冲区阈值可通过
gc_enable()的第二个参数调整,例如gc_enable(5000)让 GC 更频繁启动 - 注意:
gc_collect_cycles()返回的是本次回收的 cycle 数,不是释放的字节数,别拿它当内存监控指标
常见踩坑:哪些写法会让 GC 失效或变慢
GC 能工作,但某些结构会让它“看不见”循环,或拖慢扫描速度。
- 全局变量或 $GLOBALS 中的循环引用:GC 能处理,但因为根缓冲区包含全局符号表,扫描开销更大
- 使用
static属性维持对象引用(如单例中互相持有):这类循环会被捕获,但若 static 变量生命周期跨请求(如 OPcache 预加载),需额外注意 - 闭包绑定
$this并存入对象属性:PHP 7.4+ 已修复大部分场景,但 PHP 7.2/7.3 中可能漏判,建议避免use ($this)+ 循环存储 - 大量小数组构成深层嵌套循环(如树形结构 parent/children):GC 扫描时间与根缓冲区中节点数呈近似线性关系,10 万节点可能耗时几十毫秒
怎么验证循环引用是否真被 GC 回收了
别依赖 memory_get_usage() 看绝对值——PHP 内存池有预分配和碎片,波动很正常。真正有效的验证方式是观察 refcount 和 cycle 状态。
- 用
xdebug_debug_zval('var_name')查看 refcount 和 is_ref,确认 unset 后 refcount 是否卡住 - 调用
gc_collect_cycles()前后对比xdebug_debug_zval()输出,看 refcount 是否归零、zval 是否标记为 destroyed - 开启 GC 日志(需编译时加
--enable-gc-debug)可输出 cycle 扫描细节,但生产环境别开 - 注意:
debug_zval_dump()在 PHP 8+ 已废弃,必须用 Xdebug 的函数
GC 对循环引用的处理是可靠的,但它的延迟性和扫描成本意味着你得主动配合——不是写完代码就万事大吉,尤其在内存敏感或长生命周期脚本里,该调 gc_collect_cycles() 的地方不能省。



















