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
