如果你在排查一个常驻 PHP 进程的内存占用异常,或者曾经盯着debug_zval_dump输出的 refcount 犯过嘀咕,那这篇偏底层、偏实现的文章应该能帮到你。引用计数是 PHP 内存管理的根基,而“引用计数溢出保护”这个 PHP 方案,指的就是 Zend 引擎里一套看着简单、实际非常值得琢磨的饱和保护机制:引用计数一旦到达某个阈值,就永远停在那个数字上,不再增、不再减。我是在一次线上内存问题的排查中被它救过一次,也正是那次经历让我开始认真读这块内核代码。这篇文章会把方案的设计思路、底层宏、实操验证方法和踩坑经验一次讲完。
1. 先弄懂引用计数:PHP 内存管理的那一个整数
1.1 什么变量才会被计数
PHP 里每个变量在底层是一个叫zval的结构体。PHP 7 之后,一个普通 zval 在 64 位环境下占 16 字节:value 存值、u1 存类型标记、u2 存辅助信息。关键点在于,并不是所有 zval 都带“引用计数”这个概念。
整数、浮点数、布尔值、null 这些标量类型的值直接内联在 zval 的 value 里,复制它们就是拷一个 8 字节的整数或浮点数,完全不需要引用计数。真正需要计数的,是数组、对象、字符串、引用类型这些通过指针指向堆内存的复合类型。它们的共同点是有一个独立的、带引用计数头的结构,比如数组对应zend_array,对象对应zend_object,字符串对应zend_string,引用对应zend_reference。这些结构头部都有一个公共的引用计数头zend_refcounted_h,里面就躺着一个uint32_t refcount。
这里有一个容易被新手忽略的细节:字符串在 PHP 7 之后也是引用计数的。早期 PHP 5 时代字符串是“复制即完整复制”,PHP 7 改成引用计数加写时复制,这一改动让大量字符串操作的性能提升了一个量级,也让字符串的 refcount 成为排查时不可忽视的一环。
所以,当你在代码里写下:
$a = [1, 2, 3]; $b = $a;$b = $a并没有复制数组的所有元素,它只是让$b指向了$a原本指向的那块zend_array,然后把这个数组的refcount从 1 变成 2。变量表里有两个变量共享同一块内存,这就是 PHP 能省内存的秘密。
1.2 写时复制与引用分离
共享之后,如果两个变量一直都不修改,这块共享内存可以一直用下去。但只要其中一个尝试修改,问题就来了:不能让一个变量的修改影响另一个变量,所以内核必须执行“分离”动作,也叫写时复制(Copy On Write,COW)。
举个例子:
$a = [1, 2, 3]; $b = $a; // refcount = 2 $b[0] = 99; // 触发分离,数组被复制,a 的数组 refcount 回到 1,b 获得新数组 refcount = 1这个过程对业务代码完全透明,但它是理解引用计数溢出的前提。因为“共享—修改—分离”是 PHP 最频繁的内存操作路径之一,每一次路径上的计数增减如果出现偏差,后果就是内存踩踏。
另一种情况更复杂:显式引用。你写:
$a = [1, 2, 3]; $b = &$a;PHP 7 之后,$a的 zval 会变成IS_REFERENCE类型,里面套一个zend_reference结构。zend_reference自己的 refcount 记为 2,因为变量表里$a和$b都指向这个引用容器。而真正存放数组的zend_array,它的 refcount 可能还是 1。unset($b)时,引用容器的 refcount 减到 1,$a会从引用中抽离,重新变回普通 zval。这种“套娃”结构是理解引用计数另一个极好的入口。
1.3 计数溢出为什么是“要命”的问题
既然 refcount 是uint32_t,它的最大值是0xFFFFFFFF,也就是 42 亿多。如果一个 refcount 从0xFFFFFFFF再增加一次,按无符号整数回绕规则,它会直接变成 0。
0 在引用计数语义里代表什么?代表“没有任何变量持有这块内存”。于是内核会把这块数组、对象或字符串的内存释放掉。可实际情况是,变量表里还存在着一个或多个指向这块内存的变量。释放之后再次访问,就是经典的 use-after-free:轻则崩溃,重则被攻击者利用。
就算没有立刻崩溃,在写时复制场景里,这块已经“被释放”的内存可能又被别的请求分配到了别处。此时变量还在更新这块内存,写入的数据会污染完全不相干的另一个结构,问题会从一个单纯的崩溃演变成难以复现的随机会话异常。
早期 PHP 版本的内核补丁时间线里,确实出现过因为 refcount 回绕导致内存破坏的报告。这个问题的触发条件虽然苛刻——单个变量的引用计数需要堆积到 40 亿次,但在长驻进程、扩展代码、复杂框架中,长期运行不是完全碰不到。更关键的是,这类 bug 一旦发生,你几乎没有办法从业务层把它稳定复现出来。所以内核设计者必须有一个兜底方案,这就是我们下面要讲的饱和保护。
2. PHP 内核的饱和保护方案
2.1 REFCOUNT_SATURATED 的设计
PHP 内核在Zend/zend_types.h里定义了两个非常显眼的常量:
#define REFCOUNT_SATURATED 0x3FFFFFFF #define REFCOUNT_MAX 0x400000000x40000000是 2 的 30 次方,也就是 10 亿多。0x3FFFFFFF比它小 1,等于 2 的 30 次方减一。方案的核心思想是:引用计数不允许超过REFCOUNT_MAX。任何一次递增操作,如果发现 refcount 已经等于REFCOUNT_SATURATED,就什么都不做。换句话说,计数到了0x3FFFFFFF就“饱和”了,这个 zval 或者结构体永远被当作“仍然有人引用”,永远不会被释放。
你可能已经注意到,满值明明可以到 42 亿,为什么偏偏把线划在 10 亿?我在通读源码和实际调试时有过一个判断:这个值的选取是为了给 GC、调试函数以及扩展的临时引用留出足够的余量。比如debug_zval_dump这种调试函数自己会临时 +1,一些扩展实现里也可能出现“先加一次再减一次”的窗口期。如果把业务计数顶到uint32_t的极限,这些额外操作很容易触发回绕。压到 2 的 30 次方以下之后,即使某些代码路径漏掉了饱和检查,也有足够的安全空间,不至于一个临时操作就冲破天花板。
2.2 从宏看“溢出瞬间”发生什么
内核真正干活的是这样一组宏,宏名虽然在不同 PHP 版本里略有差异,但逻辑一直很稳定:
#define ZEND_REFCOUNT(z) (z)->refcount #define ZEND_ADDREF(z) (z)->refcount++ #define ZEND_DELREF(z) (z)->refcount-- #define ZEND_REFCOUNT_SATURATED(z) ((z)->refcount == REFCOUNT_SATURATED) #define ZEND_REFCOUNT_ADDREF(z) do { \ if (!ZEND_REFCOUNT_SATURATED(z)) { \ ZEND_ADDREF(z); \ } \ } while (0) #define ZEND_REFCOUNT_DELREF(z) do { \ if (!ZEND_REFCOUNT_SATURATED(z)) { \ ZEND_DELREF(z); \ } \ } while (0)增量语句ZEND_REFCOUNT_ADDREF和减量语句ZEND_REFCOUNT_DELREF都先检查是否饱和,饱和就跳过。这里有个容易被忽略的细节:减量操作也会跳过饱和检查。也就是说,一个进入饱和状态的 refcount,永远不会再降回0x3FFFFFFF - 1,它是“永久饱和”。这个设计很坚决——宁可让这块内存一直不被回收,也不冒任何可能回绕的风险。
从语义上讲,这与传统“引用计数越界保护”的做法不太一样。很多系统面对计数溢出会选择扩容,比如把 32 位升级到 64 位。PHP 没有走这条路,而是在 32 位计数上增加了一个“安全失败”分支。用一句话概括:饱和状态下的对象不是被正常释放,而是被当作“永远有人持有”,这在内存占用上是有代价的,但在安全性上是绝对稳妥的。
2.3 为什么不用 64 位计数器
从直觉看,把uint32_t refcount改成uint64_t refcount不就让溢出变成不可能了吗?这个方案确实能彻底解决问题,但代价在 PHP 这种畅销语言里是完全不可接受的。
内存开销是最直接的杀手。每个需要计数的结构体,包括数组、对象、字符串、引用容器,都会在头部多出 4 字节。一个 PHP 进程里可能同时存在上百万个这样的结构体,4 字节乘以百万,就是几 MB 的额外占用。但这还不是全部:结构体变大后,CPU 缓存的命中率会下降,内存带宽的消耗会上升,整个语言最核心的变量赋值、COW 分离、函数调用路径都会变慢。PHP 每秒要执行上亿次这样的操作,任何一丝性能损耗都会被无限放大。
还有一个统计上的判断:单个变量引用计数要达到 30 亿次增减,意味着什么?假设你每秒执行一亿次“引用同一个变量”的操作,连续执行 30 秒才可能撞到阈值。PHP 常规 Web 请求往往几十毫秒内结束,进程内变量能接触到的引用次数通常远低于这个量级。饱和保护更像是一种“低成本保险”,而不是为了解决日常高频问题。用 4 字节的冗余字段和无可感知的性能损耗,去换一个几乎不可能触发但一触发就致命的漏洞被兜住,这笔账怎么算都划算。
3. 现场实验:用工具和扩展观察 refcount
3.1 debug_zval_dump 的“+1”问题
想直接看一个变量的 refcount,PHP 自带一个函数叫debug_zval_dump。但如果你真的用它来读数值,会踩到一个经典的大坑:读到的值永远比真实值高 1。
原因在于debug_zval_dump($a)这个函数调用本身,会把$a作为参数传入,这个传参动作会让目标 zval 的引用计数临时增加 1。等函数内部读取 refcount 时,看到的是“业务计数 + 调试计数”的混合值。更麻烦的是,不同 PHP 版本、不同扩展环境下这个额外引用可能不是 1,而是更多。比如在某些启用了额外扩展的环境里,你甚至会看到 refcount 高出 2 到 3 的数字。
这个坑让很多人误判了自己代码里的引用计数。我见过有人因为在循环外调用debug_zval_dump看到 refcount 持续上涨,就认定是内存泄漏,折腾半天才发现只是调试函数自身带来的临时引用没有释放。用它看趋势还行,拿它当精确诊断工具就完全不合适了。
3.2 写一个小扩展直接打印 refcount
想要得到一个可靠值,最好的办法是自己写一个极简的 PHP 扩展。这个扩展不做什么复杂逻辑,就是从参数里取出 zval,然后打印它对应的 refcount:
PHP_FUNCTION(observe_refcount) { zval *zv; if (zend_parse_parameters(ZEND_NUM_ARGS(), "z", &zv) == FAILURE) { RETURN_THROWS(); } if (Z_TYPE_P(zv) == IS_ARRAY || Z_TYPE_P(zv) == IS_OBJECT || Z_TYPE_P(zv) == IS_STRING || Z_TYPE_P(zv) == IS_REFERENCE) { php_printf("refcount=%u\n", Z_REFCOUNT_P(zv)); } RETURN_NULL(); }这里有一个绕不开的理论问题:函数传参本身已经让zv的 refcount 增加了一次,所以输出值依然会比“调用之前的业务计数”多 1。如果要观察绝对精确的值,需要在扩展里通过调用栈找到调用点的原始变量,那复杂度就上去了。对大多数排查场景来说,知道“内部实现 + 一个临时计数”这个规律就够了。
不过这个扩展还有另外一个价值:你可以直接通过修改扩展代码,在返回前对同一个计数执行一次Z_ADDREF_P(zv)或Z_DELREF_P(zv),把饱和保护的实际行为拉出来看。比如手动把 refcount 设置到REFCOUNT_MAX附近,再执行ZEND_REFCOUNT_ADDREF,你会发现计数稳稳停在饱和值上。这种“微观复现”能让你对内核宏的行为有非常直观的理解。
3.3 业务代码里的计数堆积案例
理论上要触发饱和需要 30 亿次引用,但是“引用计数被长期堆积到高位”在业务代码里并不罕见。典型场景是这样的:
$object = new SomeService(); $registry = []; foreach (range(1, 100000) as $id) { $registry[$id] = $object; }每执行一次$registry[$id] = $object,$object对应的zend_object的 refcount 就增加 1。十万次循环后,这个对象的引用计数就是十万多。如果$registry是常驻进程里的静态容器,并且会持续跨请求保留,计数就会只涨不跌。虽然离 30 亿很远,但这说明一个事实:只要你在长周期里反复把一个对象塞进集合、闭包、缓存容器,引用计数的数值是实打实在累积的。
另一个容易积累计数的场景是闭包捕获:
$handle = fopen('/tmp/data.log', 'r'); $callbacks = []; foreach (range(1, 10000) as $eventId) { $callbacks[] = function () use ($handle, $eventId) { // ... }; }每个闭包都会持有$handle的引用,闭包本身是对象,数组里一万个闭包就让$handle的 refcount 增加一万。这种写法在事件监听、异步回调里特别常见。理解引用计数之后,你会自然形成一种代码嗅觉:当一个对象被大量异步任务捕获时,它的生命周期会被无限拉长,这正是引用计数模型的默认行为,不算 bug,但值得警惕。
4. 常见问题与排查技巧实录
4.1 内存只涨不跌,不一定是“泄漏”
遇到常驻进程内存持续上涨,第一反应通常是“哪块内存泄漏了”。但 PHP 的引用计数模型决定了,“内存增长”和“内存泄漏”并不总是同一个概念,很多上涨是因为引用计数没有归零。
我通常的排查顺序是:先跑一段最小化复现脚本,确认是业务逻辑导致,还是扩展导致。然后使用gc_status()看 GC 的 roots 数量和 collected 次数。如果 collected 稳定在某个值而 roots 越来越多,很有可能是循环引用或者引用堆积,而不是真正的分配器泄漏。再结合memory_get_usage(true)和memory_get_usage(false)对比,可以粗略判断是 Zend 内存管理器的缓冲区膨胀,还是底层malloc层面的问题。
这里特别要提醒一点:别一上来就怀疑 refcount 溢出。饱和状态在常规业务里几乎碰不到,但“引用计数高”非常常见。你能在debug_zval_dump里看到某个对象 refcount 为十万,这不代表它有问题,只能说明它被十个或者十万个容器持有。真正的问题是:这些容器为什么没有被释放?
4.2 循环引用与 GC 别和溢出保护混为一谈
两个对象互相引用:A 持有 B,B 持有 A。它们的 refcount 永远不会降到 0,所以引用计数方式回收不了它们。PHP 的解决方式是周期性的垃圾回收器,利用紫色缓冲区和“模拟 refcount 减一”的算法找出这种孤立的循环引用并回收。
这里就要回到我们之前说的饱和保护:如果一个节点进入了饱和状态,它的减量操作被完全跳过,那么在 GC 的视角里,它永远会被当作“仍然存在外部引用”。结果是这种对象不会进入可回收的候选集合。从设计意图看,这也是合理的:饱和本身是极罕见的状态,为它打破 GC 的回收逻辑得不偿失。
排查时最大的误区是把“GC 没有回收循环引用”和“引用计数溢出保护触发”扯上关系。绝大多数情况下,GC 没回收是因为你的对象不是孤立循环引用,而是仍然被某个全局变量、静态属性或容器持有。先把引用链找出来,再谈 GC 问题。
4.3 opcache 下的字符串 refcount 不可信
一个比较容易踩的坑:开启 opcache 之后,大量字符串会变成 interned string,也就是驻留字符串。这种字符串在进程启动时一次性创建,存于独立的内存区,它的 refcount 表现和普通业务字符串不一样,常常固定为 1 或者更特殊的内部值。
如果你拿debug_zval_dump去检查字符串的引用计数,会发现结果和你的预期完全不匹配。比如一个明显被十个变量引用的字符串,输出可能还是个 1。这不是溢出保护生效了,而是字符串在 opcache 管理下走了完全不同的存储路径。排查字符串相关引用时,最好先关闭 opcache 再用独立进程验证。
我在某公司帮忙处理过一个案子,对方写了一大段脚本去统计字符串引用,最后发现统计脚本在 opcache 环境下得出的数值完全不能反映业务逻辑。那个教训让我养成了一个习惯:只要涉及引用计数观察,先声明运行环境,opcache 状态必须写清楚,否则结论毫无意义。
4.4 扩展层引用泄漏的定位思路
如果你排除了业务逻辑,内存还在稳定上涨,下一个怀疑对象就是扩展或者自定义 PHP 模块。扩展层最常见的引用问题,是在生命周期较长的容器中不小心持有了 zval 却忘了释放。典型代码是这样:
Z_ADDREF_P(zv); // 保存到某个全局链表里如果这个全局链表在合适的时机没有对保存的 zval 执行对应的删除和Z_DELREF_P(zv),引用计数就会一直增长。更隐蔽的情况是错误路径上漏了减量:某些扩展在初始化时Z_ADDREF_P,中间抛出异常或提前返回,却把减量忘在了最后一行。
对这种问题的定位思路,我建议从两个方向下手。第一是二分禁用扩展,先确认嫌疑扩展的范围。第二是做代码审查,专门查每个Z_ADDREF_P是否有配对的Z_DELREF_P。我的实际经验是,引用计数泄漏的根因代码通常很短,但隐藏得极深,因为平时不会有人逐行去审查扩展源码。如果你想彻底排查,可以关注zend_observer这类钩子机制,在请求生命周期切换时打印引用计数变化,把泄漏点定位到具体函数层级。
5. 边界场景与“饱和”的工程哲学
5.1 跨版本演进:从 PHP 5 到 PHP 8 一直保留
让我比较感慨的一件事情是,饱和保护这个设计从早期 PHP 5 时代引入之后,一直延续到了 PHP 8。PHP 7 做了一次堪称革命性的内存结构重构,把数组、对象、字符串的表示都重写了,但你知道吗?REFCOUNT_SATURATED和REFCOUNT_MAX这两个常量依然原样保留,连数值都没变。
这说明这套保护机制经受住了大规模重构的检验。PHP 7 在重构时顺手把很多临时分配机制都改了,唯独饱和保护方案被完整继承。究其原因,是整个团队都清楚:内存管理是整个语言的命脉,任何一处的算术操作都可能在万亿次级别反复执行。在这种频率下,宁可多一个“永不触发”的安全分支,也不能指望开发者每次调用都记得做越界判断。
从 PHP 7 到 PHP 8,字符串驻留、对象表示、闭包实现都有调整,但核心引用计数宏的语义一直稳定。这给扩展开发者一个隐性的好处:只要按既有的Z_ADDREF_P/Z_DELREF_P规范写代码,不管运行时怎么升级,内存安全边界都在。
5.2 用“永久不释放”换“绝对不崩溃”
最后聊聊这个方案背后的工程取舍。饱和保护在本质上不是“修复”溢出,而是把溢出的后果从“未定义行为”替换成了“可预期的内存泄漏”。这是一种防御性失败的设计:当系统进入一个极端异常状态时,首先保证不崩溃、不产生安全漏洞,哪怕付出内存占用永久增长的代价。
做个简单的类比:很多平台里的熔断机制也一样。上游依赖不稳定时,与其让请求全部超时堆积,不如直接拒绝一部分请求,宁可业务受损也不拖垮整个系统。饱和保护就是引用计数世界里的熔断器:计数到顶了,别硬闯,先挂起,保住进程,给运维和开发者留出介入时间。
实际开发中,你几乎不可能真正看到0x3FFFFFFF这个数字出现在业务变量上。但如果哪天真出现了,你至少知道,有一个设计早在十几年前就在为这一刻兜底。这种“安安静静、稳定可靠、关键时刻能救命”的特质,其实就是优秀底层方案该有的样貌。
我在那次常驻进程排查最后得出的结论,与 refcount 饱和本身无关——真正的问题是扩展层一个全局容器没有及时释放对象。但整个排查过程让我把引用计数的全链路都过了一遍,也让我从心底认可了饱和保护这个方案的价值。以后你遇到类似的内存困惑,不妨也沿着zval到zend_refcounted_h再到REFCOUNT_SATURATED这条线走一遍,很多看起来很玄的问题,追到这一步基本都会清晰起来。