尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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共享同一个数组refcount2 $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会变成2unset($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);显式把互相引用的字段置nullrefcount会立刻归零内存当场释放根本不用等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调度放到可控的时间点配合弱引用和显式断环绝大部分“内存只涨不降”的问题都能治住。这些坑我都是真金白银踩过来的希望这篇梳理能帮你少走几步弯路。
返回列表