news 2026/10/5 4:11:07

PHP内存管理:引用计数与循环引用GC的深度拆解与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP内存管理:引用计数与循环引用GC的深度拆解与实战

聊PHP内存管理,永远绕不开两个词:引用计数和循环引用GC。日常写业务代码,你很少直接感知它们,但一旦线上内存持续上涨、常驻进程越跑越臃肿、或者被问到“两个对象互相引用,PHP到底怎么回收”,就会发现在这套机制上的认知深浅,直接决定了你能不能快速定位问题。这篇文章我会从zval层的引用计数讲起,把循环引用为什么会产生、GC用什么算法回收、以及实战中如何定位和治理,一层层拆开来看。

这篇文章既适合刚接触PHP底层机制的开发者建立完整认知,也适合已经在做Swoole、WorkerMan等长驻进程优化的同学拿去对照排查。我不打算复述官方文档,而是把我实际踩过的坑和处理过的案例糅进来,尽量让你看完能直接上手。

1. 先看地基:引用计数到底怎么工作

1.1 一个变量背后不只是“值”,还有一张计数器账单

PHP里,$a = new stdClass并不是把整个对象塞进变量,而是让变量名指向一个独立的“对象容器”,容器内部挂着一块记录引用状况的数据:refcount。可以把它想象成快递柜里的包裹,每个包裹外面贴着一张签收人数表。

$a = new stdClass; $b = $a; // 对象refcount从1变2 unset($b); // refcount从2变1,对象还活着 unset($a); // refcount变0,对象被立即释放

这里的核心逻辑是:每当一个变量指向某个容器,refcount加一;每当一个变量不再指向它,refcount减一。数到0的那一刻,PHP不会犹豫,直接回收内存,并递归释放它引用的所有子成员。这种即时性是PHP内存管理的最低层保障,大部分临时变量、函数参数、返回值都靠这个机制快速释放。

从PHP 7开始,zval结构本身只有16字节,字符串、数组、对象这类数据放在独立的堆内存中。字符串的refcount写在zend_string头部,数组的写在zend_array头部,对象的写在zend_object头部。这些结构统称为zend_refcounted,它们内部都有一个公共的引用计数头。

需要注意,常规的var_dump看不到refcount,想看具体数字要用debug_zval_dump,或者借助Xdebug。不过debug_zval_dump有个坑:函数传参本身会临时增加一次引用,所以打印出来的refcount通常比你预期的多1,比如一个只被一个变量持有的对象,打印出来可能是2。这时候别慌,关注的是相对变化,不是绝对值。

1.2 写时复制:计数器的另一面

PHP的数组、对象赋值默认近似“值语义”,但它并不傻复制。看这段代码:

$a = range(1, 100000); $b = $a; // 没有复制,$a和$b共享同一个数组,refcount=2 $b[0] = 99; // 这时才真正复制一份,改的是副本

这个机制叫写时复制(Copy On Write,简称COW)。数组被赋值给另一个变量时,底层只是把refcount加一,两个变量共用一份内存;只有某个变量真要写入时,PHP才把数据复制一份出来再修改,避免影响原来的变量。

COW的好处非常直观:大量只读的赋值、函数传参、返回值传递,都不必复制内存。坏处则是:触发写入时,大数组的复制会带来明显的CPU和内存尖峰。有些同学为了省掉复制,喜欢用引用传参(function foo(&$arr)),这确实能避免COW拷贝,但副作用是函数内部对数组的修改直接影响外部变量,一旦代码维护者没有意识到这点,容易埋下隐蔽bug。我个人的习惯是:小数组直接传值,大数组明确设计为“需要原地修改”时才用引用,同时在函数文档里写清楚副作用。

1.3 不是所有类型都参与循环回收

整数、浮点数、布尔值这些标量,在PHP 7之后通常直接内联在zval里,根本不参与引用计数。字符串虽然有自己的refcount,但由于字符串是不可变字节序列,它不可能包含指向其他对象的引用,更不可能自引用或互相引用成环。能被循环引用缠住的,只有数组和对象这两类真正的“容器”,其中对象也包括闭包对象(Closure)。

这个认知很关键。面试里被问“PHP怎么解决循环引用”时,能立刻说出“GC只处理数组、对象这类容器,标量不参与”,这比笼统背一句“引用计数加GC”要有说服力得多。更重要的是,排查内存问题时你会更清楚该盯哪里:如果一个对象图里全是普通字符串和整数,那它几乎不可能形成环形泄漏。

2. 循环引用:引用计数“数不清”的那类内存

2.1 三个最常见的循环引用场景

循环引用在业务代码里其实非常常见,只是很多时候你没意识到。第一个典型场景是对象互相引用:

$a = new stdClass; $b = new stdClass; $a->other = $b; $b->other = $a; unset($a, $b);

unset之后,从外部看,这两个对象已经没有变量指向了。但对象$a内部的other属性还攥着$b,$b内部的other属性又攥着$a。结果每个对象的refcount都不是0,而是1(都被对方引用着)。普通引用计数永远等不到它们归零,内存就这么“锁”住了。

第二个典型场景藏在数组的自引用里。数组可以通过引用操作符引用自身:

$arr = ['name' => 'php']; $arr['self'] =& $arr; unset($arr);

$arr['self']用=&指向数组本身,这个数组容器的refcount会变成2,unset($arr)之后降为1。数组和它内部的self引用就这样互相咬住不放。这类写法在解析复杂配置、处理嵌套JSON时容易被无意中制造出来,排查时也最隐蔽,因为你根本不会想到数组里居然藏了一个指向自己的引用。

第三个场景是闭包捕获自身。闭包本质是Closure对象,当它通过use捕获一个指向自己的引用时,照样构成环:

$fn = null; $fn = function () use (&$fn) { // 递归逻辑里可能会判断 $fn 是否为空 }; unset($fn);

$fn变量指向Closure对象,Closure内部的use引用又指向$fn变量指向的那个Closure对象。即使unset($fn),Closure对象内部的引用仍然让自身refcount保持为1,无法释放。闭包作为事件监听器、回调处理器使用时特别容易踩中这个,因为它“看不见摸不着”,不像对象属性那样容易从代码里直观发现。

2.2 根因:失去全局视图的计数器

为什么引用计数解决不了循环引用?回到快递柜类比:引用计数是每个包裹上的签收人数,人数变成0就销毁。而循环引用是两个人互相寄了一个“给您存着,回头来取”的快递,结果签收人数永远不为0,哪怕主人早就不要这两个包裹了。

本质原因是:引用计数的“销毁条件”只看局部数字,它不知道整个对象图里谁还能从外部访问到。只有当某个容器的refcount归零,它才被释放;而环形结构里的每个容器都因为环内的引用而“看起来还有人用”。要打破这种僵局,必须引入一个更高层级的机制,定期对整个对象图做一次全局可达性分析,找出那些“只被自己人引用、与外部彻底断联”的孤岛,整岛回收。这就是循环引用GC存在的意义。

2.3 有环不等于泄漏:可达性才是关键

这里有个需要澄清的误区:不是所有循环引用都会造成泄漏。如果外部仍然有一个变量能到达这个环,对象图依然可达,那它就不是垃圾,也不该被回收。判断标准不是“有没有环”,而是“从全局根集合(变量表、静态变量、全局符号表等)还能不能访问到它”。

换句话说,两个对象互相引用但外部仍持有其中一个变量时,它们活得好好的,内存也不会异常;只有外部根全部断开,这个环才是真正的垃圾。理解这一点,你在分析代码时就不会一见“互相引用”就紧张,而会优先问:还有没有外部变量在撑着它。

3. PHP循环引用GC:根缓冲区与三阶段清理

3.1 根缓冲区:只收“疑似根”

如果GC每次扫描整个进程的所有对象,开销是不可接受的。PHP的做法是维护一个根缓冲区(root buffer),只收集“有可能构成环的候选者”。

什么时候一个容器会成为候选者?关键条件是:它的refcount发生了一次“减少但没归零”。比如refcount从2变成1,或者从3变成2。这说明它身上有引用被移除了,但还没移除干净,它可能是一个环的入口。如果refcount直接减到0,普通引用计数机制立刻会释放它,根本轮不到GC插手。

伪代码可以这样理解:

function refcountDecrease($zv) { $zv->refcount--; if ($zv->refcount == 0) { release($zv); // 常规回收,立刻释放 } else { gc_possible_root($zv); // 计数减了但还活着,可能是环,收进根缓冲区 } }

根缓冲区默认最多容纳GC_ROOT_BUFFER_MAX_ENTRIES个疑似根节点,具体值是10000。当缓冲区满了,GC就会在合适的时机自动跑一轮。所以你会看到一些常驻进程的内存曲线是“阶梯式上涨、然后突然掉下来”,那通常就是GC周期性工作的痕迹。

一个容器只会进根缓冲区一次,不会再重复排队,这是通过容器头部的标记位控制的,避免同一个对象在缓冲区内堆积多个副本。

3.2 三阶段清理:模拟删除、恢复扫描、真的删除

真正执行时,GC会对根缓冲区里的每个疑似根节点做深度优先遍历。整个过程可以拆成三个阶段,源码里大致对应gc_mark_roots、gc_scan_roots、gc_collect_roots三个阶段。为了便于理解,可以把节点颜色视为状态标记:

  • 白色:初始状态,未处理
  • 灰色:正在遍历中
  • 黑色:确认仍被外部引用,不是垃圾
  • 紫色:确认是垃圾,待回收

第一阶段,模拟删除(标记)。从疑似根节点开始,深度优先遍历它所能触达的所有成员。每经过一个容器,就把这个容器的refcount减1,并标记为灰色。这个阶段的含义是:假设疑似根和它内部整片对象图都被删除,看看谁的引用数会被“削弱”。

第二阶段,恢复扫描(扫描)。再遍历一遍刚才标记为灰色的节点。此时有两种结果:如果某个节点的refcount经过模拟删除后仍然大于0,说明它还被环外的什么东西引用着,它不是垃圾,那就要把第一阶段减掉的引用数补回来,并把节点状态恢复成黑色。如果refcount正好归零,说明它与外界的所有联系都是通过这个疑似根建立的,现在疑似根被“模拟删除”后,它就是一座与大陆失联的孤岛,标记成紫色。

第三阶段,收集(清除)。把所有紫色节点真正从对象图中移除,递归释放它们持有的成员引用,触发相应的析构逻辑,把内存还给系统。

这个过程可以想象成处理一个海上的幽灵船队:先假设把整个船队拖到岸边,挨个检查每个船员是否还有岸上的亲人;有亲人联系的放回去,完全没人认领的直接把船拆了。拆船就是释放内存。

3.3 触发时机、手动触发与代价

GC的触发时机主要有三个:根缓冲区满了自动触发;请求结束进入shutdown流程时会做一轮清理;开发者也可以手动调用gc_collect_cycles()强制立即收集。

需要清楚地认识到,GC的标记清除不是免费的。它需要深度优先遍历整片对象图,容器越多、环越大、引用越深,单次GC的耗时就越长。在高并发的PHP-FPM进程里,一次自动GC相当于在业务处理中间突然插入一段全局扫描,如果业务对象图很大,这段扫描会让请求出现肉眼可见的延迟尖峰。

所以对延迟敏感的场景,我建议做一次“GC成本体检”:

$t = microtime(true); $collected = gc_collect_cycles(); printf("collected=%d, elapsed=%.6f seconds\n", $collected, microtime(true) - $t);

实测时你会发现,几十个循环对象的情况下,收集耗时几乎可以忽略;但如果你构造了百万级节点的巨型对象图,一次GC耗时会显著上涨。这也是为什么“随手写循环引用”在测试环境看不出问题,一到生产高并发下就变成性能事故。

3.4 相关配置:zend.enable_gc 与 GC 阈值

PHP提供了一个配置项zend.enable_gc,控制是否启用循环引用收集,默认是开启的。注意它的修改级别是PHP_INI_SYSTEM,也就是说不能在脚本里用ini_set('zend.enable_gc', 0)临时改,只能在php.ini里配置或在进程启动时指定。

关闭GC意味着放弃循环引用回收兜底。对于一次性运行的短生命周期CLI脚本,比如一个几十秒就跑完的批处理,关闭GC可以省掉反复扫描的开销,脚本结束后内存全部归还给系统,没有任何问题。但对于常驻进程,比如Swoole的Worker、WorkerMan的进程,关闭GC后循环引用垃圾会持续堆积,内存会稳定上涨直到撑爆,这是非常危险的。我的建议很明确:长驻进程永远不要关zend.enable_gc,短CLI脚本如果对执行时间极其敏感,可以考虑关掉。

从PHP 8.3开始,gc_status()返回的信息更完善了,可以观察到running、protected、full、buffer_size、collection_count、threshold、roots等字段。这些字段在调优时非常有用,roots尤其关键,它表示当前根缓冲区里积压了多少疑似根节点。这个数字往往是内存问题的“前哨信号”,比内存占用数值更早暴露风险。

4. 实战:从“内存只涨不降”到稳定可控

4.1 复现一次典型的循环引用泄漏

理论讲再多,不如亲手复现一次。我经常用这样的脚本模拟ORM实体互相引用造成的泄漏:

class User { public ?Post $lastPost = null; public function __construct(public string $name) {} } class Post { public ?User $author = null; public function __construct(public string $title) {} } for ($i = 0; $i < 200000; $i++) { $user = new User("user_$i"); $post = new Post("post_$i"); $user->lastPost = $post; $post->author = $user; unset($user, $post); } echo "peak memory: " . memory_get_peak_usage(true) / 1048576 . " MB\n";

这段代码循环创建20万个互相引用的对象对,每一个外部断连后都是垃圾,但refcount永远不为0。如果你在PHP 8.x上运行,会看到峰值内存轻松突破几百MB,甚至更高。实际业务里当然不会这么极端,但如果你用ORM做过双向关联(文章表关联作者表、作者表再关联文章列表),底层就是这种结构,只是数量级不同。

跑完这个脚本你就明白:普通引用计数对“环”完全无能为力,必须依赖GC。但如果此时zend.enable_gc是关闭的,内存峰值会更高;开启时,虽然这些对象不会立即释放,但根缓冲区满后会自动收集,峰值会明显降低。

4.2 定位工具组合拳:gc_status、debug_zval_dump、memory_get_usage

遇到线上内存只涨不降,我一般按顺序上三样工具。

第一看memory_get_usage(true)和memory_get_peak_usage(true),确认是真涨还是假涨。PHP-FPM模式下,进程可能因为内存池复用而保留部分已释放内存,RSS没有立刻降下来不完全等于泄漏,要结合长周期趋势判断。

第二看gc_status():

$status = gc_status(); print_r($status);

重点关注roots字段。如果这个数字持续偏大,比如一直积压着几千上万个疑似根节点,说明系统里不断在产生循环引用或“减引用但未归零”的容器。配合collection_count增长情况,可以判断GC到底有没有在干活。

第三用debug_zval_dump定位具体嫌疑人:

$a = new stdClass; debug_zval_dump($a); // 输出类似:object(stdClass)#1 (0) refcount(2)

前面提过,函数调用本身会让refcount显示多1,所以重点是看变化:unset前后refcount有没有归零。如果unset之后debug_zval_dump还显示引用数大于1,说明背后有隐藏引用攥着它,最常见的就是$GLOBALS、闭包use引用、foreach引用残留、静态变量缓存这四类。

4.3 代码治理:弱引用、显式解除、GC调度

定位到问题之后,治理手段分三个层次。

第一层是设计层面,用弱引用打破强引用环。PHP 7.4开始提供WeakReference,它不会增加目标对象的refcount:

$obj = new stdClass; $ref = WeakReference::create($obj); unset($obj); var_dump($ref->get()); // NULL

这个特性非常适合做缓存、观察者模式、事件监听器。比如一个事件分发器持有大量监听器闭包,闭包又捕获了事件分发器本身,这就是一个典型的强引用环。把监听器放进WeakReference包装,宿主不再强持有监听器,环就断了。不过要注意,使用WeakReference的前提是你要接受“对象可能随时消失”的语义,不适合作为必需依赖的引用。

第二层是操作层面,清理关键大对象时主动解除环里的引用:

$user->lastPost = null; $post->author = null; unset($user, $post);

显式把互相引用的字段置null,refcount会立刻归零,内存当场释放,根本不用等GC。在ORM实体清理、关停后台任务、释放大缓存对象时,这个习惯非常有效。要清理的对象,不要只靠unset指望GC,主动断环比什么都直接。

第三层是调度层面,长驻进程里别只靠根缓冲区装满才自动GC。高并发请求中间突然来一次全图扫描,延迟毛刺会很扎眼。更可控的做法是主动选择低峰期或者请求间隙触发:

if (gc_status()['roots'] > 5000) { gc_collect_cycles(); }

给自己设定一个阈值,把GC的执行窗口掌握在自己手里,避免高峰期“随机卡顿”。在Swoole协程环境里尤其要小心,gc_collect_cycles()是同步阻塞的,会挡住整个进程的事件循环,所以一定要放在可控的时间点,不能跟着每个请求走。

4.4 常见问题速查表

我把实际排查中最高频的现象、原因和解法整理成了表格,方便你对照使用。

现象可能原因排查方向与解法
内存持续增长,roots越积越多循环引用对象持续堆积代码层面断环,或周期性手动gc_collect_cycles()
unset后内存立刻不降存在隐藏引用检查$GLOBALS、闭包use引用、foreach残留引用、静态变量缓存
请求内偶发卡顿根缓冲区满,自动触发全图扫描低峰期主动GC,避免高峰期自动触发
关闭zend.enable_gc后内存涨更快长驻进程失去循环回收兜底长驻进程不要关闭GC;只有短CLI脚本可考虑关闭
debug_zval_dump的refcount总比预期大函数传参临时增加一次引用关注相对变化,不要纠结绝对值
析构函数里搞复杂逻辑析构时新增引用,干扰GC收集析构函数保持轻量,不要做对象关系重建

4.5 容易被忽略的“隐藏引用”坑

这里想单独拎出来几个我真实踩过的坑,因为它们太隐蔽了。

第一个是foreach的引用残留。很多人写完循环不记得unset($item):

foreach ($list as &$item) { $item = trim($item); } // 忘记 unset($item)

循环结束后,$item仍然指向数组最后一个元素。后面只要继续往$list里添加元素、或者把$list传给别的函数,这个残留引用会让最后一个元素的refcount异常升高,内存不释放,修改行为也变得诡异。我见过这种问题在线上潜伏很久,就是因为代码逻辑看起来完全正常,问题藏在引用残留里。解法很简单:用完引用变量立刻unset($item)。

第二个是$GLOBALS。在任何函数内通过$GLOBALS['xxx']赋值,等于给全局变量加了一层引用。如果某个全局变量被$GLOBALS反复操作,即使局部变量unset了,全局那层引用还在,对象看着一直有“外部使用者”,自然就不会释放。检查时优先怀疑全局符号表。

第三个是静态变量缓存。函数内用static $cache缓存对象、数组时,这份缓存的生命周期是进程级的。如果缓存了对象且没有清理策略,进程存活期间对象永远可达,这和循环引用无关,纯粹是“根引用没断开”。常驻进程里做静态缓存,一定要配套明确的生命周期管理。

5. 结尾:一点个人体会

我接手过的几个内存泄漏案例,最后都发现不是GC算法失效,而是框架容器、事件监听器、ORM双向关联把对象攥得太死。GC只是最后一道防线,代码设计里的强引用才是绝大多数内存泄漏的源头。把根引用清干净、把该断的环显式断开,垃圾回收器其实比你想象的轻松得多。

如果你正在优化一个常驻服务,我的建议是从gc_status()['roots']这个字段开始看起。它比内存数值更早暴露问题,能告诉你系统里积压了多少待回收的疑似根。把GC调度放到可控的时间点,配合弱引用和显式断环,绝大部分“内存只涨不降”的问题都能治住。这些坑我都是真金白银踩过来的,希望这篇梳理能帮你少走几步弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 4:10:48

Cursor插件不是下载功能,而是可编程IDE扩展契约

1. “plugins”不是功能按钮&#xff0c;而是Cursor生态的神经中枢“plugins”这个词在Cursor社区里被高频搜索&#xff0c;但绝大多数人第一次点开它时&#xff0c;都以为只是个“插件市场入口”——点进去发现空空如也&#xff0c;或者只看到几行JSON配置&#xff0c;立刻困惑…

作者头像 李华
网站建设 2026/10/5 4:10:15

嵌入式C语言函数传参全攻略:值传递、指针、结构体与回调解析

搞嵌入式开发的&#xff0c;大部分时间都在跟C语言里的函数打交道。函数说白了就是把一段逻辑封装起来&#xff0c;给它输入、拿回输出&#xff0c;但“传参”这两个字&#xff0c;恰恰是很多人从入门到放弃的分水岭。我见过不少同事&#xff0c;跑得动流水灯&#xff0c;写得出…

作者头像 李华
网站建设 2026/10/5 4:09:54

基于YOLOv11的无人机电力设备异常检测系统设计全解析

简介&#xff1a;针对传统人工巡检效率低、成本高、隐患发现不及时等问题&#xff0c;这份38页PDF文档以YOLOv11为核心&#xff0c;系统给出无人机电力设备异常检测与定位的整体设计方案。文档从场景现状与需求切入&#xff0c;不仅梳理了YOLO系列算法的发展历程&#xff0c;还…

作者头像 李华
网站建设 2026/10/5 4:09:51

Elasticsearch快速入门:从索引到查询的实战指南

“ES”这三个字母在不同的圈子里含义能差出十万八千里&#xff0c;前端朋友想到的是 ES Module&#xff0c;图形方向想到的是 OpenGL ES&#xff0c;老安卓用户可能先冒出 ES 文件浏览器。但如果你搜“ES 快速入门”时看到的大多数教程、热词都指向同一个东西&#xff0c;那八成…

作者头像 李华
网站建设 2026/10/5 4:09:45

NumPy原理与实战:从数组运算到性能优化全指南

你家体系里有上头文件&#xff0c;我得跟你确认清楚&#xff1a;别指望我在正文字数里注水&#xff0c;也别让我用什么"潜在巨大价值"之类的空话来凑。正文我实打实写&#xff0c;代码、参数、坑点都摆出来&#xff0c;该多少字就是多少字。说到NumPy&#xff0c;我先…

作者头像 李华
网站建设 2026/10/5 4:09:22

Dart循环与集合类型详解:List/Set/Map遍历及避坑指南

Dart学到第6天&#xff0c;终于把循环和集合类型放到一起讲了。这两个知识点拆开看都很简单&#xff1a;循环无非是 for、while 那几套写法&#xff0c;集合无非是 List、Set、Map 三种容器。但真正写起代码来你会发现&#xff0c;它们几乎总是成对出现——集合装数据&#xff…

作者头像 李华