聊到PHP变量回收机制,很多人第一反应是“不就是引用计数加垃圾回收嘛”,但真到排查内存泄漏或写常驻进程时,往往又说不清底层的来龙去脉。这篇文章我会从zval结构讲到写时复制,从unset的真实行为讲到循环引用和垃圾收集器,再结合Swoole、Workerman这类常驻场景聊聊排查内存问题的实战经验。无论你是准备面试,还是在写长驻服务时被内存持续上涨折磨过,这篇都值得花十分钟认真读一遍。这里不堆术语,尽量用代码和场景把每一层掰开讲透。
1. PHP变量回收机制:底层到底是怎么跑的
1.1 变量存储的根基:zval结构
想要搞懂变量回收,先得搞懂变量在PHP内部到底长什么样。PHP源码里,每个变量本质上对应一个zval结构,zval的全称是“Zend Value”。在PHP 5的时代,一个zval里同时包含了value(值)、type(类型)、refcount(引用计数)、is_ref(是否为引用)这些字段。变量名只是符号表里的一个key,真正的数据存在zval里,多个变量名可能指向同一个zval。
到了PHP 7以后,zval的形态发生了很大变化。标量类型比如整型、浮点型、布尔型的值直接内联在zval里,不再单独分配内存,也不再参与引用计数;而数组、对象这类复杂类型,则通过zval里的指针指向一个带有独立引用计数头的结构体,这个结构体叫zend_refcounted_h。我用一个日常的东西来类比:zval就像一个文件袋,小纸条直接放在文件袋里,而一本厚资料则放一份索引卡片在文件袋里,真正的资料存放在文件柜里,多个文件袋可以共用同一份资料。
这个变化直接影响回收行为。在PHP 5里,即使是$a = 1; $b = $a;这种看似简单的情况,背后也可能经历了refcount的增加和zval的共享;但在PHP 7里,整型变量的复制几乎没有任何额外开销,因为根本没有refcount需要维护。这也是PHP 7比PHP 5快的一个底层原因。
1.2 refcount到底是什么:变量回收的计数原点
引用计数是PHP变量回收的核心依据。简单理解,refcount表示当前这个值被多少个变量名、数组元素或者对象属性所引用。初始状态下,一个变量引用某个值时refcount为1;当第二个变量通过赋值指向同一个值时,refcount变成2;每当有一个变量不再指向这个值,refcount就减1;当refcount减到0时,说明没有任何地方引用它了,这片内存就可以被释放。
php复制$a = 'hello'; // refcount = 1
$b = $a; // refcount = 2,$a和$b共享同一个字符串
unset($a); // refcount = 1,内存不释放,因为$b还在用
unset($b); // refcount = 0,内存释放
注意,PHP 7里标量类型不维护refcount,这个例子如果你在PHP 7环境用debug_zval_dump查看,看到的计数会和上面不完全一样,因为函数参数本身也会临时增加引用。但数组和对象完整保留了引用计数机制:当你把一个大数组赋值给另一个变量时,底层并不复制数据,只是给数组的引用计数加1,这也就是后面要讲的写时复制。
很多人调试时会发现refcount数值比预期大,原因往往出在“调用函数本身会增加临时引用”这个细节上,这会在后面的排查章节展开。
1.3 写时复制:你写的$b = $a可能根本没复制
PHP采用了一种叫Copy On Write(COW,写时复制)的策略。多个变量可以同时指向同一份数据,只有其中一个变量试图修改数据时,PHP才会真正复制一份出来,再对新副本进行修改。这样做最大的好处是节省内存:赋值操作变得非常廉价,因为只是refcount加1。
php复制$a = range(1, 100000);
$b = $a; // 这里没有复制10万元素的数组,只是refcount + 1
$b[] = 100001; // 此时才会真正复制一份数组给$b
我在实际开发中见过不少因此踩坑的代码。比如有人为了“避免影响原数组”,一上来就写$copy = $arr;,然后遍历修改$copy,自信以为这样安全。但PHP的COW本来就是为了这种情况下不浪费内存才设计的,如果一开始就创建副本,只会在后续某个修改点才真实复制。问题是,如果你的代码流程里逻辑分支特别多,某些分支修改了某些分支没修改,COW的行为会让内存峰值变得难以预估。性能调优的另一个思路是:如果确实需要修改一个数组,又不想影响原数据,尽量显式分配新数组变量,避免在多个引用之间来回切换导致不必要的复制时机混乱。
COW也带来一个重要推论:判断两个变量是否共用同一份内容,不能只看值是否相等,而要关注refcount。这在调试对象和数组引用关系时会用得上。
1.4 什么时候真正释放内存
变量引用计数归零后,PHP会立即释放这块内存吗?严格来说,是“立即归还给PHP的内存管理层”,但不一定马上归还给操作系统。PHP内部有一套内存分配器(Zend MM),它在底层会维护一个内存池,小内存块从操作系统申请来以后先放进池子里,PHP层面的释放只是把内存块标记为可用,并不会立刻通过free()还给系统。这样做是为了减少系统调用的次数,提升整体性能。
这带来的现象就是:即便你在脚本里unset了很多大变量,观察内存占用可能不会立刻下降,尤其是通过memory_get_usage(false)看到的数值可能变化不大。用memory_get_usage(true)能看到实际从系统申请的内存,这个值在进程刚启动时会比较高,因为内存池一次性向系统要了不少。理解这一点对于排查“我的内存怎么没降下来”非常重要,后面排查章节会再展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. unset和变量生命周期:你以为删除了,其实没有
2.1 unset的真实操作
unset是很多人以为“删除变量并释放内存”的操作,但它的行为是:把变量名从当前符号表中移除,同时将对应值的refcount减1。只有当refcount因此变为0时,内存才会被释放。如果还有其他变量引用同一个值,unset并不会让内存消失。
php复制$data = str_repeat('x', 1024 * 1024);
$tmp = $data; // refcount = 2
unset($data); // refcount = 1,内存还在
echo memory_get_usage();
如果$tmp一直保留,那你unset掉$data根本不会释放这1MB内存。这类问题在处理大文件内容、大字符串拼接时很容易出现。我见过有人读了一个大文件内容到$content,然后出于保险习惯主动unset($content),但另一个变量还引用着它,结果内存完全没降,最后误判成“内存泄漏”。
2.2 函数作用域内的变量回收
函数执行完毕后,局部变量会自动销毁,这其实是回收的一个常见触发时机。看这段代码:
php复制function process() {
$big = range(1, 1000000);
// 处理逻辑...
}
process();
// 函数返回后,$big 已被释放
很多人以为函数参数是按值传递时会发生一次内存复制,实际上不是。把一个数组作为参数传入函数时,PHP不会复制数组,只是把数组的refcount加1;当函数内部修改这个参数时,才会触发COW复制。所以“按值传参”其实是很廉价的操作。但这也带来一个隐蔽开销:函数内部只要不修改参数,就不会复制,可一旦在函数里用了引用参数&$param并修改了内容,外部的变量也会跟着变,而且refcount的处理逻辑不同,有可能导致该变量的is_ref状态被改变,进而影响COW的行为。
2.3 引用传递的坑:is_ref一出,COW就失效了
PHP的引用&与引用计数是两套机制,但会相互作用。看这个例子:
php复制$a = 'hello';
$b = &$a;
// 此时$a的zval里is_ref = 1,refcount = 2
$b = 'world';
echo $a; // 输出 world
当is_ref为1时,PHP不会再对这个zval应用写时复制,因为所有引用它的变量都要求数据同步。如果你在同一份数据上混用普通赋值和引用赋值,PHP内部为了保证语义正确,会选择让所有普通赋值也共享同一个zval,这会导致一些没想到的“联动修改”和额外的复制。比如:
php复制$a = 'hello';
$b = &$a;
$c = $a; // 这里$c会强制拷贝一份,而不是与$a共享
我在早期写代码时被这种混用坑过一次:某个对象属性被外部引用后,我在另一个地方给对象属性赋值,结果外部变量的值也变了。排查半天才意识到罪魁祸首是is_ref破坏了COW机制。建议是:在一个变量上要么全程用引用,要么一直用普通赋值,不要在同一个值上混用。
2.4 全局变量、静态变量与请求生命周期
全局变量和静态变量是回收机制里的两个特例。global $var或者$GLOBALS['var']会让变量在函数内部也能访问,本质上也是增加引用计数,但因为它被挂在全局符号表上,只要请求没有结束,它的refcount就不会归零,内存就一直留着。
static变量则更特殊:静态变量在函数多次调用之间会保留值。如果静态变量保存的是一个大数组或者对象,它会在整个请求期间甚至整个进程生命周期内一直存在(在常驻进程里就是整个进程生命周期)。我遇到过的情况是:一个CLI脚本里某个函数用static变量做缓存,后面因为逻辑变化,缓存里的数据越来越多,最终导致内存暴涨。解决方法是定期清理缓存,或者改用外部存储如Redis。
在PHP-FPM模式下,一个请求结束后,所有请求级别的变量都会被销毁,包括循环引用未清理的情况也会被整体释放。这也就是为什么很多人在普通Web开发中不太关心垃圾回收器,但在Swoole、Workerman这类常驻进程里,垃圾回收问题会突然变得非常明显。
3. 循环引用与垃圾回收器:引用计数的盲区
3.1 循环引用是怎么产生的
引用计数的最大盲区是循环引用。两个变量互相引用,或者一个对象持有自己的引用,会导致它们的refcount永远不为0,即使外部已经没有变量指向它们,内存也无法被普通机制释放。
php复制$a = new stdClass();
$b = new stdClass();
$a->b = $b;
$b->a = $a;
unset($a);
unset($b);
// 两个对象互相引用,refcount 都不为0,形成“垃圾”
这种循环结构在复杂业务里并不罕见:ORM实体互相关联、树形结构、观察者模式的事件订阅、依赖注入容器等场景都可能出现。如果你只依赖引用计数,这块内存就会伴随进程整个生命周期。
3.2 从引用计数到根缓冲区的演进
PHP 5.3引入了基于同步算法的垃圾回收器,目的就是解决循环引用。它维护了一个“根缓冲区”,当一个变量的refcount减少但没归零时,PHP会把这个变量作为“疑似垃圾根”加入根缓冲区。当根缓冲区里的根数量达到阈值(默认10000)时,垃圾回收器就会启动。
这里有个容易混淆的概念:垃圾回收器不是实时扫描所有变量的,它只在根缓冲区满或者你手动触发时运行。而且它回收的对象是“疑似垃圾根”,也就是那些refcount变化异常、可能存在循环引用的数据。普通变量的释放仍然靠引用计数,GC是引用计数机制的补充。
3.3 垃圾回收器的标记清除过程
GC的完整算法比较复杂,我用通俗方式描述一遍核心逻辑。它会把每个疑似垃圾根节点里的成员引用计数都减去1(模拟“移除外部引用”),然后检查哪个节点refcount变成0,如果变成0,说明这个节点是循环引用的一部分,即垃圾;如果不是0,则说明还有其他外部引用存在,需要恢复数据。
整个过程中,节点会被标记为不同颜色:正在处理的节点、可能是垃圾的节点、确定存活的节点。最终,被判定为垃圾的循环结构会被清除,内存得到释放。这套算法在PHP 5.3之后经历过多次优化,但基本思路一致。
有个经典误区是:垃圾回收器运行时会卡住你的脚本。确实,在大量循环引用存在时,GC的标记清除会消耗CPU和内存,但它并非每时每刻都在运行。默认配置下,只有根缓冲区累积到10000个可能垃圾根才会触发,普通请求里几乎不会达到这个量级。
3.4 阈值与手动触发时机
PHP提供了一些函数和配置来控制GC:
gc_collect_cycles():手动触发一次垃圾回收,返回回收的字节数。gc_disable()/gc_enable():关闭或开启循环引用回收。gc_status():查看GC当前状态,包括运行次数、根缓冲区大小、阈值等。zend.enable_gc=1/0:php.ini中控制GC是否默认开启。
什么场景需要手动触发?最常见的是常驻进程里的循环任务。比如用Swoole写一个WebSocket服务,每个连接会创建很多临时对象,如果代码里存在循环引用,连接关闭后内存不会自动释放。此时可以在合适的时机调用gc_collect_cycles(),但要注意别太频繁,否则GC本身会成为性能瓶颈。
我通常在批处理CLI任务里每处理5000条或10000条记录调用一次gc_collect_cycles(),同时配合memory_get_usage()打日志观察趋势,一步步找到合适的触发频率,而不是无脑每循环一次就调用。
4. 内存泄漏的常见场景与排查实战
4.1 静态数组持续累积
静态属性保存对象是常驻进程里最典型的内存泄漏源。看下面的例子:
php复制class Registry {
public static array $items = [];
}
function register(string $key, object $value): void {
Registry::$items[$key] = $value;
}
在普通Web请求里,请求结束静态属性也会销毁,问题不大。但在Swoole或Workerman常驻进程里,Registry::$items会一直保留,如果业务逻辑不断调用register,数组就会不断膨胀,内存自然只涨不降。排查时用memory_get_usage()观察趋势,缩小范围后重点检查所有static变量、全局变量和单例属性。
我在一个常驻队列消费进程里就遇到过类似问题:每次从队列里取任务后创建了模型对象,对象被塞进一个数组做“去重缓存”,结果缓存只增不减,三天后内存吃到上限。后来给缓存加了个最大长度,超过就弹出旧数据,内存就稳定了。
4.2 闭包与事件回调中的引用捕获
闭包和回调是另一个容易被忽略的内存陷阱。PHP闭包默认会自动绑定$this(如果定义在类方法里),同时use (&$var)按引用捕获变量时,会一直持有该变量的引用,导致变量无法被释放。
php复制class EventBus {
private array $listeners = [];
public function on(callable $listener): void {
$this->listeners[] = $listener;
}
}
class Foo {
private EventBus $bus;
public function __construct(EventBus $bus) {
$this->bus = $bus;
$bus->on(function () {
// 这个闭包自动绑定了 $this
});
}
}
外部没有Foo的引用后,Foo和EventBus通过闭包互相持有,形成循环引用。在请求式生命周期里问题不大,常驻进程里就必须依赖GC处理。排查这类问题最有效的手段是减少闭包对$this的隐式捕获:把闭包做成静态方法,或者显式use需要的变量,避免整个对象被闭包长期持有。
4.3 PHP-FPM与常驻进程的回收差异
很多人问:为什么我在PHP-FPM下从不关心GC也能跑得好好的?因为每个请求结束时,PHP会释放掉整个请求上下文里的所有内存,包括那些循环引用。引用计数减不到0没关系,直接整块内存一起销毁了。所以循环引用导致的“内存泄漏”在PHP-FPM下几乎不会有真实危害,它只影响单次请求内的内存峰值,而不会跨请求累积。
真正需要担心的是:
- CLI长任务脚本:比如数据导入、消息队列消费者。
- Swoole / Workerman 常驻服务。
- PHP内置服务器(php -S)连续处理请求时。
在这些场景里,请求或任务之间共享同一个进程,每一次循环引用累积下来,内存就会像滚雪球一样增长。我见过一个队列消费者跑到第三天内存占用达到2GB,最后OOM被系统杀掉,重启后没两天又掉。最后定位到是消费者里某个模型对象互相引用,导致每处理一条消息泄漏几KB,症状就是内存缓慢而稳定地增长。
4.4 内存监控与排查工具
排查内存问题,我一般按以下顺序来:
- 先用
memory_get_usage(true)在关键节点打点,比如循环每1000次记录一次,观察曲线是趋平还是持续上涨。 - 用
memory_get_peak_usage()看峰值,判断峰值是不是超出预期。 - 用
gc_status()看GC运行状态,确认垃圾回收是否真的在运行。 - 用
debug_zval_dump()或ReflectionReference检查变量引用计数,定位具体是哪个变量被意外持有。
这里有个细节:debug_zval_dump()输出的refcount不是绝对准确的,因为传入函数时本身会增加一次临时引用,所以在对比时要留出这个余量。PHP 7.4以后也可以用ReflectionReference::fromArrayElement()来检查数组元素是否是引用,对排查数组引用问题很有用。
更专业的工具方面,Blackfire和Tideways能看到内存分配和泄漏趋势,但一般的手工排查用上面的方式已经够用了。我的经验是:内存泄漏排查七成靠打点日志定位代码位置,三成靠静态分析找出引用关系,真正需要动态调试器的情况反而不多。
5. PHP版本差异与回收性能调优
5.1 PHP 5到PHP 7的zval革命
PHP 7的zval重构对整个变量管理系统影响深远。PHP 5时代,所有zval都存在堆上,即使一个整型变量也要经过分配、释放的流程;到了PHP 7,整型、布尔等标量直接放在栈上,数组和对象仍然走堆分配,但引用计数的数据结构更紧凑,内存占用大幅下降。
这意味着,PHP 7之后“标量变量”的回收几乎可以忽略不计,你不需要关心$i = 1; unset($i);这类操作的成本。真正需要关心的是数组、对象和字符串。字符串在PHP 7中也不再走引用计数?不对,字符串依然有引用计数机制,但比数组和对象轻量一些。如果你在PHP 5升级到PHP 7的项目中发现内存占用降低了30%以上,不用惊讶,很大程度就是zval重构带来的。
5.2 PHP 8.x对垃圾回收器的优化
PHP 8.x在垃圾回收器上也做了不少内部优化,尤其是一些极端场景:大的循环引用结构、深层嵌套的数组/对象。比如PHP 8.3的GC实现里,标记清除算法进行了调整,处理大量垃圾时系统调用和内存抖动明显减少。对于常规的小请求,你可能根本感受不到差异,但在大数组、深层次对象图、复杂ORM实体关系里,GC带来的停顿和内存占用会有可感知的改善。
如果项目还停留在PHP 5.x或PHP 7.0,内存问题又比较明显,升级PHP版本是最值得做的一件事。不光是性能提升,GC的稳定性也有明显改进。实在不能升级的情况下,至少可以通过开启zend.enable_gc和手动gc_collect_cycles()来缓解循环引用问题。
5.3 实战中的回收策略调整
这里分享一些实战调优思路,你需要根据业务场景做取舍。
第一,阈值调整。根缓冲区默认阈值是10000,zend.gc_threshold可以配置。如果你发现GC频繁触发,每次运行都占用大量CPU,可以考虑调高阈值,比如到20000或50000,减少GC运行频率,代价是垃圾在缓冲区里堆积更久,内存占用会相应增加。反过来,如果内存吃紧,可以调低阈值,让GC更勤快一些,但CPU开销会增加。
第二,按需开关。有些长驻服务在高峰期不想让GC抢占资源,可以gc_disable()暂时关闭,在低峰期或请求结束后再gc_enable()并gc_collect_cycles()。但要记住,gc_disable()只是关闭循环引用的自动收集,不会影响普通引用计数的释放。所以它不会导致变量完全无法释放,只会让循环引用垃圾暂时堆积。
第三,主动清理。在写长循环任务时,每处理一批数据后主动释放大变量,再决定是否调用GC。我常用的模板是:
php复制while ($tasks = getTasks()) {
foreach ($tasks as $task) {
process($task);
}
// 每处理完一批,释放大变量
unset($tasks, $currentResult);
if (($processedCount % 5000) === 0) {
gc_collect_cycles();
error_log(sprintf('Memory: %s', memory_get_usage()));
}
}
这样既能观察内存趋势,也能保证循环引用垃圾不会持续累积。
5.4 面试中关于变量回收的常问点
如果是为了面试准备,以下几个问题几乎必问:
- PHP的变量什么时候被回收?
- 有了引用计数为什么还需要垃圾回收器?
- unset之后变量会立即被释放吗?
- 数组和对象的回收有什么不同?
- Swoole长驻进程里为什么容易内存泄漏?
这些问题都能从这篇文章里找到答案。回答时要着重讲清楚两点:引用计数是基础机制,负责大多数内存释放;GC是补充机制,专门解决循环引用。还要说明PHP-FPM和常驻进程在回收上的本质差异,这往往是考察候选人是否真正做过实际项目的分水岭。
另外还有一个容易忽略的点:变量返回时通过引用计数转移所有权,不会发生复制。比如一个函数返回一个大数组,不会因为返回值而产生内存峰值翻倍。如果面试官问“为什么PHP变量赋值效率这么高”,就解释写时复制和引用计数转移。
个人建议不要把GC当作唯一救命稻草。在常驻进程里,最好的策略是代码层面避免产生循环引用,其次才是依赖GC来兜底。比如ORM实体关联时,能只用ID就不保存完整对象;事件回调里,能用静态方法或纯函数就不用闭包绑定$this;缓存容器加容量上限,避免无界增长。这些措施比调优GC更根本。
最后再分享一个小技巧:我在排查内存问题时,经常给关键函数加上一个简单的内存日志函数,把当前内存和峰值打到日志文件,配合时间戳基本能定位到内存增长是发生在哪个业务阶段。工具不一定多高级,但对定位问题很有帮助。
