1. 前言做 PHP 开发的朋友,多多少少都遇到过内存占用居高不下的情况。尤其是跑长任务、写常驻脚本或者处理大数组的时候,内存蹭蹭往上涨,看着就让人头疼。其实很多时候,问题并不在业务逻辑本身,而是出在 PHP 的垃圾回收机制上——说得再具体一点,是循环引用把本该释放的内存给“锁”住了。这篇文章不打算堆砌太多理论,而是想从实际排查的角度出发,把 PHP 垃圾回收的原理、循环引用为什么会导致内存泄漏,以及我们平时该怎么定位和解决这类问题,一次讲清楚。2. 先搞清楚 PHP 的引用计数PHP 的内存管理,核心就是“引用计数”。简单来说,每个变量背后都有一个计数器,记录着这个变量被引用了多少次。当计数器归零的时候,这个变量占用的内存就会被回收。举个例子,下面这段代码就很直观:<?php $a = new stdClass(); // 此时 $a 的引用计数为 1 $b = $a; // 赋值给 $b 后,引用计数变为 2 unset($a); // 销毁 $a,引用计数减为 1 unset($b); // 销毁 $b,引用计数归零,内存被回收 ?>看起来挺简单的对吧?但问题恰恰出在“循环引用”上——当两个对象互相引用对方的时候,它们的引用计数永远都不会归零。3. 循环引用是怎么把内存“锁死”的循环引用,说白了就是 A 引用了 B,B 又引用了 A,形成一个闭环。这时候,就算外部已经没有变量指向它们了,这两个对象的引用计数也始终是 1,永远不会归零,内存也就一直占着不放。看个典型的例子:<?php class Node { public $next; public $data; public function __construct($data) { $this->data = $data; } } $node1 = new Node('第一个节点'); $node2 = new Node('第二个节点'); // 互相引用,形成循环 $node1->next = $node2; $node2->next = $node1; // 外部引用全部销毁 unset($node1); unset($node2); // 但内存并没有被释放! // 因为 $node1 和 $node2 还在互相引用着对方 ?>这段代码跑完之后,两个对象依然存在于内存中,谁也释放不了谁。如果是在循环里反复创建这样的对象,内存就会像滚雪球一样越积越多,最终导致内存泄漏。4. PHP 的垃圾回收器是怎么工作的PHP 当然也意识到了这个问题,所以在 5.3 版本之后引入了专门的垃圾回收器(GC)。它的思路是:定期扫描那些“可能形成循环引用”的变量,通过一种叫“根缓冲区”的机制,把真正没有外部引用的循环对象找出来并回收掉。简单理解就是:当一个变量的引用计数减少但没归零时,它会被放进根缓冲区。当根缓冲区满了(默认 10000 个节点),GC 就会启动一次扫描。GC 会模拟“如果把这个对象的外部引用全部去掉,它的引用计数会不会归零”,如果会,就说明它是垃圾,可以回收。这个机制本身是有效的,但问题在于——GC 不是实时触发的,它需要等根缓冲区攒够一定数量才会跑一次。所以如果你的脚本创建了大量循环引用,但还没触发 GC,内存就会一直涨。5. 实战排查:怎么定位循环引用排查内存泄漏,最直接的办法就是看内存占用曲线。如果内存只涨不降,那基本可以断定有泄漏。接下来就是定位具体是哪里出了问题。这里分享几个我常用的排查手段:<?php // 方法一:在关键节点打印内存占用 echo '当前内存:' . memory_get_usage() . ' 字节' . PHP_EOL; // 方法二:手动触发一次垃圾回收,看内存有没有降下来 gc_collect_cycles(); echo 'GC 后内存:' . memory_get_usage() . ' 字节' . PHP_EOL; // 方法三:查看根缓冲区里有多少待处理节点 echo '根缓冲区节点数:' . gc_status()['runs'] . PHP_EOL; ?>如果手动调用gc_collect_cycles()之后内存明显下降,那就说明循环引用确实存在,而且 GC 还没来得及处理它们。6. 怎么避免和解决循环引用定位到问题之后,解决思路其实就几条:打破循环:在不需要的时候,手动把对象的引用置为null,比如$node1->next = null;,这样引用计数就能正常归零。用弱引用:PHP 7.4 之后支持WeakReference,它不会增加引用计数,适合做缓存、观察者这类场景。及时触发 GC:在长任务里,可以定期调用gc_collect_cycles(),避免垃圾堆积。避免深层嵌套的对象图:设计上尽量让对象之间的依赖关系简单一些,减少形成闭环的可能。拿前面的链表例子来说,最简单的修复就是:<?php // 销毁前先打破循环 $node1->next = null; $node2->next = null; unset($node1); unset($node2); // 这次内存就能正常释放了 ?>7. 写在最后PHP 的垃圾回收机制其实并不复杂,但循环引用这个坑确实容易踩。尤其是写队列消费、定时任务这类长生命周期脚本的时候,一个不起眼的循环引用,可能跑几天就把内存吃光了。建议大家在写代码的时候,心里多一根弦:对象之间互相引用的时候,想想它的生命周期是不是可控的。真出了问题也别慌,用memory_get_usage()配合gc_collect_cycles()一步步排查,很快就能找到元凶。希望这篇文章能帮到你。如果你在实战中也遇到过类似的内存泄漏问题,欢迎在评论区聊聊你的排查思路。
1. 前言做 PHP 开发的朋友,多多少少都遇到过内存占用居高不下的情况。尤其是跑长任务、写常驻脚本或者处理大数组的时候,内存蹭蹭往上涨,看着就让人头疼。其实很多时候,问题并不在业务逻辑本身,而是出在 PHP 的垃圾回收机制上——说得再具体一点,是循环引用把本该释放的内存给“锁”住了。这篇文章不打算堆砌太多理论,而是想从实际排查的角度出发,把 PHP 垃圾回收的原理、循环引用为什么会导致内存泄漏,以及我们平时该怎么定位和解决这类问题,一次讲清楚。2. 先搞清楚 PHP 的引用计数PHP 的内存管理,核心就是“引用计数”。简单来说,每个变量背后都有一个计数器,记录着这个变量被引用了多少次。当计数器归零的时候,这个变量占用的内存就会被回收。举个例子,下面这段代码就很直观:<?php $a = new stdClass(); // 此时 $a 的引用计数为 1 $b = $a; // 赋值给 $b 后,引用计数变为 2 unset($a); // 销毁 $a,引用计数减为 1 unset($b); // 销毁 $b,引用计数归零,内存被回收 ?>看起来挺简单的对吧?但问题恰恰出在“循环引用”上——当两个对象互相引用对方的时候,它们的引用计数永远都不会归零。3. 循环引用是怎么把内存“锁死”的循环引用,说白了就是 A 引用了 B,B 又引用了 A,形成一个闭环。这时候,就算外部已经没有变量指向它们了,这两个对象的引用计数也始终是 1,永远不会归零,内存也就一直占着不放。看个典型的例子:<?php class Node { public $next; public $data; public function __construct($data) { $this->data = $data; } } $node1 = new Node('第一个节点'); $node2 = new Node('第二个节点'); // 互相引用,形成循环 $node1->next = $node2; $node2->next = $node1; // 外部引用全部销毁 unset($node1); unset($node2); // 但内存并没有被释放! // 因为 $node1 和 $node2 还在互相引用着对方 ?>这段代码跑完之后,两个对象依然存在于内存中,谁也释放不了谁。如果是在循环里反复创建这样的对象,内存就会像滚雪球一样越积越多,最终导致内存泄漏。4. PHP 的垃圾回收器是怎么工作的PHP 当然也意识到了这个问题,所以在 5.3 版本之后引入了专门的垃圾回收器(GC)。它的思路是:定期扫描那些“可能形成循环引用”的变量,通过一种叫“根缓冲区”的机制,把真正没有外部引用的循环对象找出来并回收掉。简单理解就是:当一个变量的引用计数减少但没归零时,它会被放进根缓冲区。当根缓冲区满了(默认 10000 个节点),GC 就会启动一次扫描。GC 会模拟“如果把这个对象的外部引用全部去掉,它的引用计数会不会归零”,如果会,就说明它是垃圾,可以回收。这个机制本身是有效的,但问题在于——GC 不是实时触发的,它需要等根缓冲区攒够一定数量才会跑一次。所以如果你的脚本创建了大量循环引用,但还没触发 GC,内存就会一直涨。5. 实战排查:怎么定位循环引用排查内存泄漏,最直接的办法就是看内存占用曲线。如果内存只涨不降,那基本可以断定有泄漏。接下来就是定位具体是哪里出了问题。这里分享几个我常用的排查手段:<?php // 方法一:在关键节点打印内存占用 echo '当前内存:' . memory_get_usage() . ' 字节' . PHP_EOL; // 方法二:手动触发一次垃圾回收,看内存有没有降下来 gc_collect_cycles(); echo 'GC 后内存:' . memory_get_usage() . ' 字节' . PHP_EOL; // 方法三:查看根缓冲区里有多少待处理节点 echo '根缓冲区节点数:' . gc_status()['runs'] . PHP_EOL; ?>如果手动调用gc_collect_cycles()之后内存明显下降,那就说明循环引用确实存在,而且 GC 还没来得及处理它们。6. 怎么避免和解决循环引用定位到问题之后,解决思路其实就几条:打破循环:在不需要的时候,手动把对象的引用置为null,比如$node1->next = null;,这样引用计数就能正常归零。用弱引用:PHP 7.4 之后支持WeakReference,它不会增加引用计数,适合做缓存、观察者这类场景。及时触发 GC:在长任务里,可以定期调用gc_collect_cycles(),避免垃圾堆积。避免深层嵌套的对象图:设计上尽量让对象之间的依赖关系简单一些,减少形成闭环的可能。拿前面的链表例子来说,最简单的修复就是:<?php // 销毁前先打破循环 $node1->next = null; $node2->next = null; unset($node1); unset($node2); // 这次内存就能正常释放了 ?>7. 写在最后PHP 的垃圾回收机制其实并不复杂,但循环引用这个坑确实容易踩。尤其是写队列消费、定时任务这类长生命周期脚本的时候,一个不起眼的循环引用,可能跑几天就把内存吃光了。建议大家在写代码的时候,心里多一根弦:对象之间互相引用的时候,想想它的生命周期是不是可控的。真出了问题也别慌,用memory_get_usage()配合gc_collect_cycles()一步步排查,很快就能找到元凶。希望这篇文章能帮到你。如果你在实战中也遇到过类似的内存泄漏问题,欢迎在评论区聊聊你的排查思路。