1. 一个被内存撑爆的脚本,引出的核心问题
前几天同事跑过来找我,说线上有个PHP脚本跑崩了,每次处理到第2万条数据的时候,内存占用直接冲破1GB,进程被系统杀掉。我一看他的代码,典型的循环里不断往一个大数组追加数据、并且全程没有清理中间结果的写法。这种情况在PHP开发里太常见了,尤其做数据处理、批量导入、定时任务的时候,只要对PHP的变量回收机制理解不到位,内存就会像倒计时炸弹一样,跑到某个临界点直接爆掉。
很多人对PHP的内存管理存在一个思维误区:以为unset()调用了,变量就立即“消失”了,内存就归还给系统了。实际上没那么简单。PHP的变量回收是一个层层递进的过程,涉及zval结构、引用计数、写时复制、垃圾回收周期等多个环节。任何一个环节没搞明白,写出有问题的代码时,你连排查方向都找不到。
这篇文章就是围绕PHP变量回收机制写的完整解码,从底层zval结构讲到引用计数原理,再讲到循环引用的垃圾回收算法,最后落到长驻进程、大数据循环这些真实场景的调优经验。无论你是刚学PHP的新手,还是已经写了三五年业务代码的老手,只要你曾经被内存问题折磨过,这篇内容就值得你从头到尾看一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 变量回收的底层逻辑:从zval和引用计数说起
2.1 变量在PHP内存里的真实形态
要理解变量回收,先得知道变量在内存里到底长什么样。PHP是C语言写的,一个PHP变量在底层对应一个叫zval的结构体。PHP 5时代的zval结构大致长这样:
c复制struct _zval_struct {
zvalue_value value; // 实际的值
zend_uint refcount__gc; // 引用计数
zend_uchar type; // 变量类型
zend_uchar is_ref__gc; // 是否为引用
};
也就是说,一个变量在内存里除了存它的值之外,还要额外记录三样东西:变量类型、引用计数、是否引用。PHP 7以后zval改版了,对比结构变化很大,但核心的引用计数思想被保留了下来,只是被拆分到了独立的结构里。
你不需要把结构体背下来,但你要记住一个核心结论:一个变量所占据的内存,不只是你看到的那个“值”本身,还包含一层管理用的元信息。变量回收的本质,就是对这整块内存的释放和复用。
2.2 引用计数:判断“还有没有人用它”的依据
PHP的变量回收,最基础的手段是引用计数(refcount)。这个机制的逻辑非常直白:每个zval上挂着一个计数器,记录当前有几个变量符号指向这块内存。
- 当一个变量被赋值时,refcount从0变成1
- 当多个变量引用同一块zval时,refcount累加
- 当某个变量被销毁(unset、离开作用域、重新赋值)时,refcount减1
- 当refcount减到0时,说明没有任何变量在用这块内存了,PHP立即将其标记为可回收
举个例子:
php复制$a = 'hello'; // $a指向一个zval,refcount = 1
$b = $a; // PHP不会立即复制一份字符串
// 而是让$b指向同一块zval,refcount变成2
unset($a); // $a没了,refcount减为1
// 但$b还在用着,所以内存不释放
unset($b); // refcount减为0,内存这时候才真正回收
这就是引用计数的直观效果。它让PHP在大多数情况下不需要做昂贵的复制操作,又能保证变量在自己的生命周期结束时及时释放内存。
2.3 写时复制(COW):为什么传数组不会爆内存
接上上面$b = $a的例子,你可能会问:如果$b和$a指向同一块内存,那我修改$b的时候,$a的值不就被污染了吗?
这就是PHP的写时复制机制(Copy On Write,COW)。规矩是:只有当某个变量准备写入数据时,PHP才会检查这个zval的refcount。如果refcount大于1,说明这块内存被多个变量共享,那就先复制一份出来,再把引用计数拆分开,然后再对复制出来的那份做修改。
php复制$a = 'hello';
$b = $a; // refcount = 2,共享内存
$b .= ' world'; // 写时复制:先复制一份给$b,refcount拆成两个1,再修改
echo $a; // 输出 hello,不受影响
这个机制的收益非常明显:在大多数只读传递的场景下,PHP不需要真正复制数据。比如你把一个大数组传给函数,函数内部只是遍历读取而不修改,那整个过程中数组的内存只存在一份,refcount一直在累加。这也是为什么PHP能轻松处理几百MB的数据集,只要你不去写它。
不过聪明的读者应该已经嗅到问题了:如果两个变量指向同一块zval,谁也不释放谁,那unset掉其中一个之后,另一个还占着内存。但如果存在循环引用,refcount永远降不到0,这块内存就永远释放不掉。这就是后面要讲的核心灾难。
3. 回收时机全解析:变量到底什么时候被销毁
3.1 三种最常见的“回收”触发场景
变量不是你想销毁就销毁的,PHP有一套自己的时机判断。最常见的触发场景有三种:
第一,显式调用unset()。这是开发者最主动的回收手段,把变量符号从当前符号表中移除,对应的zval引用计数减1。如果减到0,内存就会被回收。
第二,离开变量的作用域。函数内部定义的局部变量,在函数执行结束时统一销毁。这也是为什么很多人在写长循环逻辑时,喜欢把处理逻辑拆到独立函数里——因为函数一退出,里面的所有临时变量就一次性清零,这是最省心的内存控制方式。
第三,变量被重新赋值。$a = 'old'; $a = 'new';这种写法,旧字符串的zval引用计数会减1,然后变量指向新分配的zval。如果旧值已经没有其他变量引用,它就会被回收。
这三种场景从原理上是一致的:都是让某个zval的refcount发生变化,然后判断是否降到0。
3.2 unset()到底做了什么,以及它的两个坑
unset()是PHP里最被低估也最被高估的函数。说它被低估,是因为很多人不知道它有返回值(其实没有,它返回void);说它被高估,是因为很多人期待unset完内存立刻还给操作系统,这不太现实。
第一个坑:unset变量后,PHP进程的RSS(常驻内存)不一定下降。原因是PHP内部有一套内存池(Zend Memory Manager)。当你unset一个变量,zval占用的内存会退还给PHP的内存池,但内存池是批量向操作系统申请内存的,它不会因为你释放了一小块内存就立刻还给系统,而是留着备用。这在FPM模式下完全没问题,因为一个请求结束后进程会释放所有资源。但在Swoole这种常驻进程模式下,如果循环里产生了大量临时变量,即便每个都unset,内存池里的碎片也可能越积越多,最后整体内存降不下来。
第二个坑:unset一个引用变量并不会立即销毁引用的目标。看这个例子:
php复制$data = [1, 2, 3];
foreach ($data as &$value) {
// 按引用遍历
}
unset($value); // 这行很关键
如果你在foreach里用了&$value,循环结束后一定要unset掉$value,否则$value还保留着对$data最后一个元素的引用。后面你再写$value = 'xxx',会直接改写$data数组里的最后一个元素。这是老生常谈的坑,但从引用计数的角度理解更透彻:不unset,那$value就一直持有一份引用,refcount降不下去。
4. 循环引用:内存泄漏的头号元凶
4.1 循环引用是怎么让引用计数失效的
引用计数这套机制有个致命盲区:处理不了循环引用。什么叫循环引用?就是两个变量或者多个变量互相引用,形成一个环,导致每个zval的refcount永远都大于0。
最典型的场景是对象:
php复制class Node {
public $next;
}
$a = new Node();
$b = new Node();
$a->next = $b;
$b->next = $a;
unset($a);
unset($b);
按道理说,unset($a)后,$a指向的Node引用计数减1;unset($b)后,$b指向的Node引用计数也减1。但问题在于,$a->next还指向$b的Node,$b->next还指向$a的Node。这两个对象互相指着对方,引用计数各自为1,永远降不到0。就在你眼里,这两个变量已经不存在了,但内存里它们还你侬我侬地纠缠在一起,谁也解放不了谁。
数组也能形成循环引用,最常见的就是数组套数组再套回来:
php复制$arr = [];
$arr['self'] = &$arr;
unset($arr);
这种代码在业务里确实不常见,但在写缓存、递归数据结构、图结构的时候,真的会踩到。我在一个做树形菜单的接口里就遇到过:把父子节点互相塞进对方的属性里,最后整批内存释放不掉,跑几万个请求后FPM的内存开销肉眼可见地暴涨。
4.2 新的垃圾回收算法:从根缓冲区开始
PHP 5.3之前,循环引用问题基本无解,只能等脚本结束,进程退出,操作系统统一回收。从PHP 5.3开始,PHP引入了一套基于论文《Concurrent Cycle Collection in Reference Counted Systems》实现的同步垃圾回收算法,专门用来清理循环引用。
这套算法的核心思路是:不改变引用计数的主流程,而是在引用计数降到“可能形成环”的时候,把相关zval放进一个根缓冲区(root buffer)。等缓冲区的条目达到一定数量(默认10000个),就触发一次完整的垃圾回收周期。
那“可能形成环”怎么判断呢?算法针对数组和对象类型做特殊处理:当这类zval的refcount减少时,说明它可能成为某个环的一部分,就把它放入根缓冲区。之后算法会对缓冲区里的每个zval做一次模拟的引用计数减一,看哪些节点的引用计数会因此降到0。如果一个节点在“去掉环内引用”的模拟计算后变成0,那它就是垃圾,可以回收;如果没变成0,说明它还有外部引用,是活数据,放它走。
这个地方我建议你直接动手验证一把:
php复制class Node {
public $child;
}
function createCycle() {
$a = new Node();
$b = new Node();
$a->child = $b;
$b->child = $a;
}
$before = memory_get_usage();
for ($i = 0; $i < 10000; $i++) {
createCycle();
}
$after = memory_get_usage();
echo ($after - $before) / 1024 / 1024 . ' MB';
在PHP 5.2环境里跑,内存会一直涨;在PHP 5.3+环境里跑,因为垃圾回收周期会周期性触发,内存能稳定在一个水平。这个实验我当年亲自做过,对理解GC的威力非常有帮助。
4.3 为什么gc_collect_cycles()不总是生效
PHP提供gc_collect_cycles()函数,可以手动触发垃圾回收。很多人在排查内存问题时喜欢用它,但经常发现“调了也没用”。
原因在于,gc_collect_cycles()能清理的,只是根缓冲区里已经存在的循环引用垃圾。如果当前根缓冲区是空的,或者触发阈值还没到,缓冲区里还没有积累足够多的候选节点,那你调用这个函数就基本没有效果。
另外还有一点容易被忽略:根缓冲区的条目数量不是无限增长的,它默认最大10万个。如果垃圾产生的速度超过了GC的清理速度,缓冲区的条目会溢出,这时PHP会强制触发回收。所以并不是只有达到10000个条目才会回收,在高压力场景下,回收是持续在进行的。
在PHP 7.3之后,可以用gc_status()函数查看GC的实时状态:
php复制print_r(gc_status());
输出里会有runs(已运行次数)、collected(已收集的垃圾数)、threshold(阈值)、roots(当前根缓冲区条目数)等字段。排查内存问题时,先看一眼这些数据,能判断GC到底有没有在工作,而不是瞎猜。
5. PHP 7以来的变化:变量回收机制“翻新”了
5.1 PHP 7的zval大改版
PHP 7被认为是性能飞跃的一代,其中一个关键改动就是zval的重构。PHP 5时代的zval是一个16字节的结构体,所有类型的变量都塞在同一个结构体里。PHP 7以后的zval分为两大部分:值本身(zend_value)和类型信息(u1),同时把引用计数字段移到了独立的数据结构zend_refcounted_h中。
这对变量回收机制的影响是颠覆性的:
第一,标量类型(int、float、bool)现在直接存在zval结构内部,不再参与引用计数。也就是说,$a = 1; $b = $a;不会复制一个指向同一块内存的引用,而是直接复制值。这省掉了大量refcount维护的开销,也是PHP 7快的一个重要原因。
第二,字符串、数组、对象这些复杂类型,它们的zval里存的是一个指针,指针指向堆上真正的内容,引用计数就挂在那个内容上。这意味着一个字符串被100个变量引用,内存里只存一份数据,只有计数在变。
第三,zval的内存布局更加紧凑,CPU缓存的命中率更高。这一点虽然不直接影响回收逻辑,但它减少了内存操作的总开销,长驻进程的稳定性明显提升。
所以你看,同样一个垃圾回收的概念,PHP 5和PHP 7内部实现已经差了一代。用PHP 5时代总结出来的经验去套PHP 7/8的代码,有些已经过时了。比如在PHP 7下,unset()一个循环里的大字符串数组,内存下降的速度比PHP 5快很多,因为标量值不再需要等待引用计数归零。
5.2 现代PHP常驻内存的实践
提到常驻内存,现在绕不开Swoole。Swoole环境下PHP进程可以持续运行几千几万秒,这一点和传统FPM模式有本质区别:FPM模式下每个请求结束,进程就清理所有资源,内存泄漏的后果被“每次请求重启”掩盖了;Swoole常驻模式下,如果你在代码里制造了循环引用,那部分内存就真的永久泄漏了,日积月累之后必然OOM。
我在一个Swoole WebSocket服务里踩过这样的坑:每个连接对象上绑定了闭包回调,闭包里又引用了连接对象,连接断开后没有正确清理,形成了循环引用。高峰期几百个连接,内存以每分钟几十MB的速度上涨,跑了不到两个小时就重启了。后来排查时用了gc_status(),发现roots一直在涨,才定位到问题根源。
长驻进程的内存管理,我总结了几条实用经验:
- 不需要长期保留的大对象,用完后显式
unset(),同时主动把对象内部的大属性清掉 - 避免在回调闭包里使用
use ($this)或者use ($largeObject),尽量只传标量或者ID,需要时再查 - 定时执行
gc_collect_cycles(),因为长驻进程里GC的自动触发频率可能跟业务节奏不匹配 - 用
memory_get_usage()配合gc_status()做监控,设置内存告警线,而不是等OOM了才看
这些经验让我在维护长驻进程时少踩了很多坑。如果你在写Swoole服务,或者做Workerman常驻任务,建议把这些直接当成编码规范。
6. 实战向:大型循环和数组操作的内存优化
6.1 一个容易被忽略的问题:foreach按引用迭代
工作中最常出现的性能隐患,往往不是复杂的对象循环引用,而是看似无害的数组循环。尤其是当数组特别大的时候,一个细节就能决定内存峰值。
先看这一段:
php复制$data = fetchDataFromDB(); // 几万行数据
foreach ($data as $row) {
processRow($row);
}
这段代码在PHP 5时代有个经典问题:foreach按值迭代时,PHP会为每次迭代临时增加$data的引用计数,循环结束后才减少。这本身不算错,但在大数组、大循环场景下,refcount的频繁升降会带来不少额外开销。
真正危险的写法是:
php复制$data = fetchDataFromDB();
foreach ($data as &$row) {
$row['processed'] = 1;
}
unset($row); // 这行一定不能漏
按引用迭代时,$row始终指向$data的最后一个元素。如果你忘记unset($row),那后续代码中任何对$row的写入都会直接修改$data数组的最后一行的值。更严重的是,$row这个引用会一直留在当前作用域里,如果你在一个长驻循环中反复做这个操作,$data的refcount始终恢复不到原始值,内存可能持续膨胀。
我在实际开发中,碰到这种按引用修改数组元素的场景,会额外留一条注释,并且在循环结束的下一行就写unset($row),这是肌肉记忆级别的习惯。
6.2 批量处理大数据时,如何设计变量生命周期
做数据导入、爬虫、批处理脚本时,最常见的内存爆炸模式是这样的:
php复制$allData = [];
for ($i = 0; $i < 100000; $i++) {
$allData[] = getOneItem($i); // 全部堆在内存里
}
foreach ($allData as $item) {
saveToDB($item);
}
写法本身没错,但把所有数据预先装载到内存再统一处理,内存峰值必然很高。更好的方式是设计成流式处理:读一批、处理一批、释放一批。
php复制$batchSize = 1000;
$cursor = 0;
while ($batch = fetchBatchFromDB($cursor, $batchSize)) {
foreach ($batch as $item) {
saveToDB($item);
}
unset($batch); // 处理完一批,立即释放
$cursor += $batchSize;
}
另一种常见情况是:在一个循环里不断用字符串拼接生成报表内容。PHP的字符串是不可变的,每次拼接都会创建新的字符串zval,旧字符串如果不再被引用就会被回收。这个过程本身没问题,但如果你在循环里保存了所有中间结果,比如$pages[] = $pageContent,内存压力就会持续累积。正确的做法是:能写到文件就写到文件,能推到队列就推到队列,别在内存里囤货。
6.3 用工具看透变量回收情况
光靠感觉排查内存问题是不够的,得用工具看到真实数据。
PHP自带memory_get_usage()和memory_get_peak_usage(),一个是当前内存使用量,一个是峰值内存使用量,这是最基础的观测手段。比如你看一个函数是不是有内存问题,就在调用前后各取一次memory_get_usage(),差值就是这个函数消耗的内存。
想看某个变量的引用计数,可以用Xdebug的xdebug_debug_zval()(旧版本是xdebug_debug_zval(),PHP 7+基本都支持):
php复制$a = 'test';
$b = $a;
xdebug_debug_zval('a');
输出里会显示refcount=2,非常直观。这在排查“为什么unset了变量内存还不降”的时候特别有用——很多时候不是没回收,而是你的变量还被别处引用着,refcount根本没归零。
线上环境不方便装Xdebug的话,可以用PHP 7.3+自带的gc_status()加上memory_get_usage()组合判断:如果roots数量持续增长,说明存在循环引用垃圾在积累;如果内存涨了但roots不高,说明可能是正常的大对象持有,需要检查业务逻辑。
在容器化部署时,还可以配合ps命令观察PHP进程的RSS变化:
bash复制ps -o pid,rss,cmd -p <php进程pid>
watch -n 5 'ps -o pid,rss,cmd -p <php进程pid>'
我一般会用watch每几秒刷一次,看内存曲线是稳定持平还是一路向上。这一步能快速区分“内存泄漏”和“内存占用高”的本质区别。
7. 常见问题与排查技巧实录
下面把我在实际开发、答疑过程中碰到的高频问题整理成一个速查表,按“问题–原因–解决方案”的格式列出来,方便你直接对照排查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| unset大数组后,memory_get_usage降了但系统RSS不降 | PHP内存池保留已释放内存,不与操作系统立即同步 | 这属于正常现象;长驻进程可加opcache.preload或使用Swoole的hook机制缓解碎片化 |
| 循环里不断new对象,内存持续上涨 | 可能是对象间形成循环引用 | 用gc_status()看roots数量;定位后unset闭环引用,或手动gc_collect_cycles() |
| foreach用了&$value,循环结束后修改$value会改变原数组 | 引用未释放 | 循环结束后立即unset($value) |
| 大数组传给函数后,函数内部修改导致内存翻倍 | 破坏了写时复制,PHP需要复制整个数组 | 不想复制就在函数签名里加&,或者改用对象传递 |
| Swoole长驻服务内存缓慢增长 | 全局变量/静态属性/闭包持有对象引用 | 排查静态数组和闭包use引用,任务结束后主动清理 |
| 脚本处理到一半被OOM Killer杀掉 | 内存峰值过大 | 改成流式批处理,控制单批数据量,降低峰值内存 |
| gc_collect_cycles()调用后内存没变化 | 根缓冲区没有可回收垃圾,或者垃圾的产生速度高于回收速度 | 用gc_status()观察阈值,配合memory_get_usage()对比,判断是否为真实泄漏 |
再说一个容易被忽略的细节:全局变量和静态变量对回收机制的影响。如果一个变量被声明在全局作用域,或者被static关键字持有,它的生命周期不会受函数作用域结束影响。这意味着即便你在函数里已经unset了局部引用,全局静态变量还在把那一份数据牢牢握在手里。很多人排查内存问题,查了半天循环引用,最后发现是某个单例类的静态属性里存了一个不断膨胀的缓存数组。
所以我的习惯是:对可能长期持有的变量,尤其是长驻进程里的静态属性和全局变量,每隔一段时间主动清理一次,或者干脆用Redis、本地文件做外部缓存,别什么都往进程内存里塞。
最后再分享一个小技巧:当你怀疑代码里有循环引用但找不到源头时,可以用gc_collect_cycles()的返回值来判断。这个函数返回本次回收清除的垃圾数量。如果返回值经常是0,说明你的代码可能没有循环引用问题,内存上涨要从其他地方找;如果返回值偶尔是几十几百,说明确实存在循环引用的垃圾在积累,值得花精力去定位。我在排查一个老项目时,就是靠这个返回值确定问题集中在对象回调注册环节,最后改成了弱引用解耦,内存彻底稳了下来。
PHP的变量回收机制虽然不像算法题那样炫酷,但它是每个PHP开发者吃透语言底层的一条必经之路。把这些原理搞明白,你不仅能在面试时把“PHP垃圾回收机制”这个经典问题讲得清晰透彻,更能在线上内存告警时,第一时间找到问题根源,而不是被动重启进程。
