1. 一次线上内存告警引发的思考
上个月我负责的一个PHP后端服务突然收到内存告警,峰值从平时的800MB一路飙到2GB以上,而且重启FPM进程后恢复,但过几个小时又会涨上去。查了一圈业务代码,没发现死循环、没有超大结果集查询,最后把目光落在了一段处理批量数据的脚本上——那段脚本用了一个看似无害的foreach,却在循环体内反复构造对象并相互引用。
这逼着我重新把PHP变量回收机制从头到尾捋了一遍。说实话,很多PHP开发者写了几年业务代码,对unset的认识就是"释放变量",对内存回收的理解就是"PHP有垃圾回收(GC)",但真正遇到内存持续上涨时,却不知道从哪个方向排查。这篇文章不打算从源码逐行讲,也不抄官方文档的流水账,而是把变量回收机制的核心原理、执行时机和实战中会踩的坑一次性说清楚,尤其是循环引用和引用计数这两个最容易被忽略但又最影响内存行为的部分。
如果你是面试前临时抱佛脚,这篇文章能帮你建立起一个完整的答题框架;如果你是在生产环境被内存问题折磨的工程师,文中的排查链路和调优思路可以直接拿去复现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 变量回收的底层基础:zval与引用计数
2.1 每个PHP变量背后都有一个"小标签"
在深入回收机制之前,先得明白PHP的变量在底层到底是什么。PHP 7之后,每个变量在底层对应一个zval结构体,你可以把它理解成一个"小标签"——标签上记录了三类信息:
- 变量的类型(整型、浮点型、字符串、数组、对象、NULL等);
- 变量的值(直接存储值,或者指向堆内存的指针);
- 变量的引用计数(
refcount)。
其中refcount就是回收机制的核心。它表示当前有多少个"符号"(也就是变量名)指向同一个zval。
举个最直观的例子:
php复制$a = "hello";
$b = $a;
在这段代码中,$a和$b两个变量实际指向底层同一个zval,而不是拷贝一份字符串。refcount从1变成2。只有当其中一个变量发生变化时,才会触发"分离"操作(后面细讲)。所以PHP在变量赋值上的内存开销比很多人想象的小得多。
2.2 refcount归零,变量立即"死亡"
当一个变量被unset、离开作用域、或者重新赋值时,它的引用计数会减1。当refcount减到0,说明没有任何变量再指向这块zval了,这块内存就会被立即释放——这就是最常见的变量回收路径。
看一下这段代码:
php复制function test() {
$data = range(1, 100000);
// 此时 $data 的 refcount 为 1
unset($data);
// refcount 变为 0,立即释放
}
$data是100000个元素的数组,如果不unset,函数结束后$data离开作用域,引用计数同样会减到0并释放。所以PHP的常规变量的生命周期管理是自动完成的,并不需要我们每次手动unset。
注意:
refcount归零释放的是"内存引用关系",也就是这个变量不再存在。但如果你在数组里存了一个值,又被其他变量引用,情况就复杂了——这会在后面写时复制和循环引用部分展开。
2.3 变量容器:数组和对象是特殊的"复合引用"
数组和对象在zval中存储的是一个指向内部结构(HashTable或object)的指针。也就是说,数组中的每个元素、对象中的每个属性,自己又可以是另一个zval。这样嵌套的结构导致一个问题:引用关系不是简单的树,可能是图。
举个例子:
php复制$a = ['name' => '张三'];
$b = $a; // 两个变量指向同一个数组zval,refcount = 2
$b['age'] = 18; // 写时复制触发,$b 分离出新的数组
到这里还没有循环引用的问题,但如果数组里存了一个指向自身的引用:
php复制$a = [];
$a['self'] = &$a;
数组内部的某个元素的zval和外部$a的zval构成了环,这时refcount永远无法自然归零,内存就"卡住"了。这就是PHP需要引用计数之外的"垃圾回收器"来兜底的原因。我们后面专门聊这个。
3. 写时复制:PHP变量"抠门"背后的高明设计
3.1 为什么赋值不直接拷贝?
很多从C语言转过来的开发者,一开始会惊讶于PHP里$b = $a并没有复制内存。实际上,zval在PHP内部是共享的,只有在写操作发生时才会真正复制——这种策略叫写时复制(Copy On Write,简称COW)。
这个设计的优势很明显:大多数时候我们只是把变量传来传去、赋来赋去,并不修改它们。如果每次赋值都深拷贝一份数据,一个1MB的变量被赋值10次,就要占10MB内存;有了COW,赋值10次只占1MB,直到某一个变量被真正修改时才各自独立。
3.2 什么时候触发"分离"?
当两个或多个变量共享同一个zval时,如果其中一个变量的值要发生改变,引擎会把zval复制一份,再对新副本进行修改。这个操作叫"分离"(separation)。
php复制$config = ['host' => '127.0.0.1', 'port' => 3306];
$copy = $config; // $copy 和 $config 共享同一个 zval,refcount = 2
$copy['port'] = 3307; // 这里触发分离,$copy 得到新数组,refcount 各自变为 1
代码里看不到任何特殊函数,整个过程由PHP引擎自动完成。也就是说,COW在底层帮我们节省了大量内存,这是PHP能够在共享主机上以较低成本跑起来的重要基础。
3.3 一个高频陷阱:循环中的"引用残留"
COW本身很智能,但和引用(&)在一起时就容易出问题。比如在foreach中使用引用遍历:
php复制$data = [1, 2, 3, 4];
foreach ($data as &$value) {
// 这里 $value 是 $data 中元素的引用
}
// 循环结束后,$value 仍然指向 $data 的最后一个元素!
unset($value); // 如果不 unset,后面再修改 $value 就会影响 $data 最后一个元素
这个坑很多老手都踩过。关键在于引用遍历之后,变量$value仍然存在于当前作用域,并且指向$data[3]这个zval。如果你不unset($value),后续如果有代码对$value重新赋值,$data[3]的值也会被改变,而且底层的refcount一直保持一个额外的引用计数,导致该zval无法在预期时机回收。
这个问题的本质就是**"引用计数没有在应该归零的时候归零"**。排查思路是:遍历完引用循环后,立刻unset掉那个引用变量,让引用计数恢复为1,内部数据才能正常管理。
4. 循环引用:GC机制的由来与工作流程
4.1 循环引用为什么"杀不死"?
普通变量的回收依赖refcount归零,但循环引用让refcount永远无法归零。典型场景是对象之间的相互引用:
php复制class Node
{
public $parent;
public $child;
}
$a = new Node();
$b = new Node();
$a->child = $b;
$b->parent = $a;
unset($a);
unset($b);
$a对象内部有一个child属性指向$b对象,$b对象的parent属性指向$a对象。即使我们unset了$a和$b这两个变量名,两个对象依然互相持有对方,每个对象的refcount都还是1。这就像一个房间里有两个人互相指着对方说"他还在",结果谁都无法离开房间,内存也就永远无法释放。
数组也有同样的问题:
php复制$x = [];
$x['self'] = &$x;
unset($x);
$x内部的元素指向自己,unset之后外层变量名没了,但内部对自己的引用还在,refcount无法归零。
如果这种循环引用发生在请求量很大的长生命周期进程中(比如常驻的Worker、Swoole、消息队列消费者),内存就会一点点"涨"上去,最终导致OOM。这正是文章开头那段批量处理脚本的内存告警根源——批量构建互相关联的对象数组,循环结束后对象全部形成不可达的循环引用团块,普通回收机制无法处理。
4.2 GC是怎么发现并清除循环引用的?
PHP为解决循环引用,引入了一个垃圾回收器(Garbage Collector,简称GC)。它的核心思路是:周期性地扫描那些可能形成循环引用的zval,找出其中"不可达"的循环引用团块并释放。
关键点在于,GC不是每次都全量扫描,而是维护了一个可能根缓冲区(root buffer)。当一个数组或对象的refcount减少但不是归零(比如从2减到1),它就可能是一个循环引用的"根部",会被放入根缓冲区。当缓冲区满(默认10000个可能根),就会触发一次GC扫描。
GC扫描的过程可以简化理解成两步:
- 模拟删除:遍历根缓冲区中每个可能根节点,把它的引用计数减1,模拟"如果外面没人引用它"的情况;
- 恢复与清除:如果模拟删除后某个节点的引用计数变成0,说明这个循环引用的团块没有被任何外部变量引用,是可以回收的垃圾;如果引用计数大于0,说明外部还有变量引用着它,恢复原来的引用计数。
这个"先减后看"的操作方式,保证了GC不会误删仍然活跃的对象。
4.3 手动触发GC的时机
默认情况下,PHP会在根缓冲区满时自动运行GC。但我们也可以在关键节点手动触发:
php复制gc_collect_cycles();
比如在一个循环创建大量临时对象的脚本里,每隔一定次数调用一次gc_collect_cycles(),可以防止根缓冲区长期处于高水位,避免在请求结束时一次性扫描带来的性能尖峰。
注意:
gc_collect_cycles()返回本次回收的循环引用数量,可以用来判断是否真的存在循环引用。如果返回0,说明当前没有可回收的循环垃圾。
关于GC还有一个容易被误解的点:GC并不能完全替代引用计数。常规变量的释放靠的是引用计数即时归零,GC是兜底方案,专门清理循环引用。所以优化内存的第一优先级仍然是减少不必要的引用关系、及时清理大对象。
5. 高频场景中的变量回收实战排查
5.1 场景一:循环体内累积内存增长
处理大量数据时,最常见的失误是在循环体内创建大数组、大对象,但没有及时清理。看下面这段简化示例:
php复制$users = getUsersFromDb(); // 假设有100万条用户数据
foreach ($users as $user) {
$profile = getProfileById($user['id']); // 每次生成一个较大的对象
// 没有对 $profile 做任何处理,只是暂时查询
}
虽然循环结束后$profile会被下一次循环覆盖,但这个过程存在两个问题:
- 每次循环结束后,旧的
$profile变量被新的值覆盖,旧值的refcount归零释放,理论上是能被回收的; - 但如果
$profile内部存在循环引用(比如对象内部互相引用),那么它在$profile变量被覆盖后并不会立即释放,而是进入GC根缓冲区,等待GC扫描。
在CLI脚本里,如果整个脚本运行时间很长,积累的循环引用垃圾可能会在脚本结束时才被回收,导致内存峰值异常高。解决办法是:在大循环内适时调用gc_collect_cycles(),或者干脆用unset($profile)显式释放,并减少对象之间的循环引用设计。
5.2 场景二:反序列化过程中的对象创建与回收
PHP反序列化经常和变量回收扯上关系,因为反序列化会一次性创建大量对象,形成复杂的对象图。比如我们把一个模型从缓存中取出并unserialize,如果模型对象内部有关联关系,比如Order对象包含多个OrderItem对象,而OrderItem对象又引用了Order,这就是一个典型的循环引用。
php复制$cacheData = $cache->get('order:123456');
$order = unserialize($cacheData);
// 执行完业务逻辑后
unset($order);
如果这个循环请求是普通FPM模式,请求结束后所有对象都会随着进程内存释放而释放,问题不大。但在常驻进程中,unset($order)之后只是让外部变量名消失,内部的Order和OrderItem互相引用形成的循环团块并不会立即释放。正确的做法是在unset前后配合gc_collect_cycles(),或者在设计对象的序列化结构时,尽量避免反向引用。
5.3 场景三:处理超大数组时峰值内存失控
有人说"不要在大数组上使用array_merge",这不是没有根据的。来看这个片段:
php复制$result = [];
foreach ($largeSets as $set) {
$result = array_merge($result, $set);
}
每执行一次array_merge,都会生成一个全新的数组,旧的$result数组如果没有被其他变量引用,就会触发一次"归零释放"。但问题在于这个过程中存在大量的中间拷贝,内存峰值可能是最终结果的数倍。如果我们了解refcount机制,就会知道更好的做法是直接遍历赋值:
php复制$result = [];
foreach ($largeSets as $set) {
foreach ($set as $key => $value) {
$result[$key] = $value;
}
}
后者不会产生大量临时数组,内存占用显著下降。类似地,字符串拼接用.=比用$str = $str . $x更省内存,因为后者会先构造一个临时字符串变量。
5.4 场景四:周期性任务中的"假性泄漏"
我见过不少同事在排查内存泄漏时,用memory_get_usage()观察内存持续增长,结论是"PHP泄漏了"。但很多时候是逻辑本身让数据无限累积。比如:
php复制while (true) {
$messages = $queue->pop();
$log[] = processMessage($messages); // 把每一条处理结果都存起来
if (count($log) > 1000) {
$log = []; // 手动清空
}
}
如果注释掉清空分页的代码,$log会无限增长,这当然不是"变量回收"能解决的问题,因为所有元素都是可到达的——外部变量$log还在引用着整个数组。这种"假性泄漏"的排查关键是:先判断增长的内存在当前作用域中是否存在引用路径。 如果存在,是代码逻辑问题;如果不存在但仍然增长,才是GC层面需要处理的问题。
5.5 内存泄漏排查工具
分享几个我实际用过的工具和方法:
memory_get_usage()与memory_get_peak_usage():最基础的观测手段,能定位内存增长发生在哪个阶段。gc_status():PHP 7.3+提供的一个函数,能返回GC运行状态,包括根缓冲区数量、扫描次数等。定位是否积累了大量待回收的循环引用非常直观。Xdebug的xdebug_debug_zval():可以查看变量的引用计数,适合深入分析某一个变量的引用情况。PHP-FPM慢日志和pm.status_path:排查周期性内存问题的重要手段。Valgrind:极端情况下用来分析PHP自身的C内存问题,不过需要编译时启用debug和valgrind支持,成本和门槛较高,一般场景不一定需要。
6. 变量回收机制在实际项目中的调优经验
6.1 不要盲目写unset
网上很多博客说"用完变量后要手动unset释放内存",这句话并不完全正确。普通变量在离开作用域时,引用计数自然归零,内存就会被释放,手动unset在大多数场景下并不会带来额外的收益——反而增加代码噪音。真正值得unset的场景只有这么几种:
- 长循环内的临时大变量:如果不
unset,变量会一直被当前作用域引用,直到下一次循环重新赋值; - 引用变量用完以后:这是必须的,否则会遗留对原变量的引用,影响后续代码和回收;
- 大型对象图用完以后:比如反序列化出来的大对象,主动
unset并触发GC,可以让常驻进程的内存水位数更快回落。
6.2 合理使用引用减少内存拷贝
COW已经很智能了,但知道机制之后,我们可以在关键路径上"人为"降低拷贝次数。例如传递大型数组给函数时,如果能确定函数不会修改数组,可以直接传值,COW会阻止实际拷贝;如果函数内部一定会修改数组,可以考虑显式传引用,避免"分离+拷贝"的开销:
php复制function transform(array &$items) {
foreach ($items as &$item) {
$item = trim($item);
}
unset($item);
}
不过这里要谨慎:滥用引用会破坏COW机制,让共享zval的实例变少,反而增加内存占用和排查难度。经验法则是:先在正确的设计中依赖COW,只有性能分析确证瓶颈后再用引用优化。
6.3 PHP版本差异对回收机制的影响
如果你是PHP 5.x时代的开发者,需要知道PHP 7/8的变量回收机制有了巨大变化。PHP 7之前的zval在堆上分配,变量赋值时的引用计数行为更"重";PHP 7之后zval在栈上分配,标量类型大多直接内联存储,数组和对象依然通过引用计数管理,但整体内存占用大幅下降。
PHP 8.0之后,GC引擎又进一步优化,特别是在扫描数组和对象的内部结构时效率更高,WeakReference和WeakMap也为解决循环引用问题提供了新的手段。比如我们可以用WeakMap缓存对象关联数据,既避免强引用导致的循环引用,又能保留缓存语义。
6.4 memory_limit设置与OOM的关系
最后提一下memory_limit。这个配置常常被当作"避免内存泄漏的防线",但它只是一个上限,不是回收机制。CLI脚本默认是-1(无限制),而FPM模式默认是128M。一个合理做法是:
- 开发环境调高至
256M或512M,方便定位大内存消耗点; - 生产环境根据实际业务设置
256M~1G,并在达到上限前通过日志观察内存增长曲线; - 对长驻进程,重点看
memory_get_usage()持续走高还是能够回落,这决定了问题在逻辑层还是GC层。
之前排查的那次内存告警,最终定位就是批量构建对象时的相互引用形成了循环引用团块。修复方案是在每个批次的末尾显式清理所有临时对象引用并调用一次gc_collect_cycles(),运行一整天后内存稳定在900MB以内。
7. 最后再分享一个排查小技巧
如果你怀疑项目里存在循环引用泄漏,但又不想翻源码逐段看,可以写一个简单的探针脚本:
php复制// probe.php
$count = 0;
while (true) {
$objA = new stdClass();
$objB = new stdClass();
$objA->b = $objB;
$objB->a = $objA;
// 模拟业务结束,不再使用对象
unset($objA, $objB);
$count++;
if ($count % 1000 == 0) {
$gc = gc_status();
echo "运行次数: {$count}, 内存: " . memory_get_usage() . ", GC根缓冲区数量: {$gc['rootBufferLength']}, 运行次数: {$gc['runs']}\n";
gc_collect_cycles();
}
}
先在CLI环境下运行这个简单版,确认循环引用确实会导致内存增长;然后在真实业务代码里用二分法逐步注释可疑块,配合gc_status()观察根缓冲区长度变化。根缓冲区长度如果一直上涨,并且gc_collect_cycles()返回的回收数持续大于0,说明循环引用是真实存在的。
这个方法帮过我多次快速定位线上内存问题,比直接看代码快很多。变量回收机制看起来只是一个底层的技术细节,但它决定了PHP进程在长时间运行下是稳定收敛还是无限膨胀。花点时间把引用计数、写时复制和GC这三件事弄清楚,排查内存问题的效率至少翻一倍。
