PHP 引用计数机制深度解析:从 zend_refcounted_h 到 CoW 分离与循环回收器
在 C 语言中手动管理 malloc/free 极易引发 use-after-free、double-free 与内存泄漏;PHP 引擎用“引用计数(reference counting,简称 refcount 或 RC)”取代了这种手动释放:每个分配出去的字符串、数组、对象等都携带一个引用计数,引用转移时加一、不再需要时减一,计数归零即自动释放。本篇基于 reference-counting.rst 官方文档,结合 php-src 仓库中 Zend/zend_types.h 的真实结构体与宏实现,完整讲解引用计数的数据结构、宏接口体系、写时分离(CoW)语义、不可变(immutable)引用计数类型,以及处理引用环的循环回收器和 GC 标志位,帮助阅读或开发 PHP 引擎代码的人建立可落地的内部知识。
一、为什么需要引用计数
PHP 用户代码通常不需要关心内存管理:引擎会追踪哪些值已不再被使用,并通过引用计数自动完成分配与释放。规则很简单:
- 一个值每被传递/引用给新的持有者,其 RC 加一;
- 持有者不再需要该值时,负责将 RC 减一;
- RC 归零时,引擎确认该值在任何地方都不再被需要,随即释放。
官方文档给出的最小示例:
$a = new stdClass; // RC 1
$b = $a; // RC 2
unset($a); // RC 1
unset($b); // RC 0, free
需要引用计数的类型恰好是那些“携带辅助数据”的类型,官方文档列出的是:
- 字符串(Strings)
- 数组(Arrays)
- 对象(Objects)
- 引用(References)
- 资源(Resources)
从类型性质看,对象、引用、资源是“引用类型”;字符串和数组则是“大类型”,无法直接放进单个 zval 中。而更简单的类型要么根本不存储值(null、false、true),要么小到可以直接存放在 zval 内(int、float),因此它们无需引用计数。这一划分与 zval.rst 中描述的 zval 布局一致:引用计数类型的 zval 存的是指针,而非值本身。
二、共同的数据结构:zend_refcounted_h
所有引用计数类型共享同一段起始结构。文档中给出的定义与当前源码 Zend/zend_types.h 完全一致:
typedef struct _zend_refcounted_h {
uint32_t refcount; /* reference counter 32-bit */
union {
uint32_t type_info;
} u;
} zend_refcounted_h;
以字符串和数组为例,它们都把该头作为第一个成员:
struct _zend_string {
zend_refcounted_h gc;
/* ... */
};
struct _zend_array {
zend_refcounted_h gc;
/* ... */
};
这一点在源码中可以逐一验证:Zend/zend_types.h 中 struct _zend_string 的第一个成员就是 zend_refcounted_h gc,其后是哈希值 h、长度 len 和弹性字符数组 val[1];Zend/zend_types.h 中 struct _zend_array 同样以 gc 打头,随后是哈希表桶数量、桶/紧凑数组指针(arHash/arData/arPacked)等字段。由于“引用计数头 + 类型信息”占据头部的 8 个字节,zend_refcounted_h * 可以直接当作各引用计数类型的通用父指针使用——这也是 GC_DTOR 等宏能把 zend_string、zend_array 统一转换为 zend_refcounted * 的前提。
关于 type_info 字段:它重复了本来存放在 zval 中的部分类型信息,用于“手头没有 zval、只有对象指针”的场景(例如 GC_DTOR 拿到裸指针时仍需知道该调用哪个析构器)。此外,type_info 的高位还存放 GC 标志,具体见第七节 GC flags。
三、宏接口体系:zval 宏与 GC_ 宏
与操作 zval 一样,zend_refcounted_h 的成员不应被直接访问,而应使用官方提供的宏。宏分两组:一组直接作用于引用计数类型(GC_ 前缀),另一组作用于 zval(通常是 Z_ 前缀)。官方文档指出命名并不总是完全一致,并给出了两张对照表,这里完整继承之。
zval 宏
| 宏 | 可用于非 RC 类型 | 说明 |
|---|---|---|
Z_REFCOUNT[_P] |
否 | 返回引用计数 |
Z_ADDREF[_P] |
否 | 增加引用计数 |
Z_TRY_ADDREF[_P] |
是 | 增加引用计数;可作用于任意 zval |
zval_ptr_dtor |
是 | 减少引用计数;若归零则释放值 |
注(继承原文档脚注):“Non-RC”指宏是否适用于非引用计数类型。若适用,操作通常是无害的空操作(no-op);若不适用于却误用,则属于未定义行为。
zend_refcounted_h 宏
| 宏 | 可用于不可变类型 | 说明 |
|---|---|---|
GC_REFCOUNT[_P] |
是 | 返回引用计数 |
GC_ADDREF[_P] |
否 | 增加引用计数 |
GC_TRY_ADDREF[_P] |
是 | 增加引用计数(对 immutable 值为空操作) |
GC_DTOR[_P] |
是 | 减少引用计数;若归零则释放值 |
注(继承原文档脚注):“Immutable”指宏是否适用于第五节描述的不可变引用计数类型。
源码中的实现细节
这些宏在 Zend/zend_types.h 中有清晰的实现,可以印证上表语义:
GC_系列宏(L852-L859)分别映射到内联函数zend_gc_refcount、zend_gc_addref、zend_gc_try_addref、zend_gc_delref等。其中zend_gc_try_addref会先检查GC_IMMUTABLE位,若置位则直接跳过加引(L820-L825);而zend_gc_addref不检查——这正是“对 immutable 值使用GC_ADDREF属于未定义行为”的源码依据。Z_TRY_ADDREF_P的保护逻辑是先用Z_REFCOUNTED_P判断该zval是否为引用计数类型,再决定是否加引(L1370-L1375),因此它对int、bool等普通值安全;而zval_addref_p本身带有ZEND_ASSERT(Z_REFCOUNTED_P(pz))断言(L1399-L1402),即Z_ADDREF只应作用于 RC 类型。GC_DTOR(L861-L869)是引用计数与循环回收器的衔接点:先zend_gc_delref减引,若归零则调用rc_dtor_func按type_info分发到具体类型的析构;若未归零,则调用gc_check_possible_root,将该值登记进循环回收器的“可能成环”缓冲区。
补充一个调试机制:在 ZEND_RC_DEBUG 开启的构建中,每次修改引用计数都会执行 ZEND_RC_MOD_CHECK(L783-L804),断言被修改的值既不是 GC_IMMUTABLE,也不带 GC_PERSISTENT 标志,帮助开发期快速发现对共享/持久内存的非法写操作。
四、分离(Separation)与写时复制(CoW)
PHP 同时存在值类型和引用类型。引用类型通过“指向值的指针”共享,在一处修改即对所有观察者可见(例如写对象属性时,所有引用该对象的地方都能看到新值);值类型在传递给其他方时发生复制,修改互不影响。
在 PHP 中,数组和字符串是值类型,同时又是引用计数类型,这就带来一个关键问题:修改值时必须保证修改对其他持有者不可见。
- 若值的 RC 为 1,自己是唯一持有者,原地修改没有问题;
- 若 RC 大于 1,修改前必须先创建一份新副本再改副本。这个过程称为分离(separation),也叫写时复制(CoW, copy on write)。
官方文档示例:
$a = [1, 2, 3]; // RC 1
$b = $a; // RC 2
$b[] = 4; // 触发 Separation,此时 $a RC 1、$b RC 1
var_dump($a); // [1, 2, 3]
var_dump($b); // [1, 2, 3, 4]
从源码结构看,CoW 的核心判断依据是“RC 是否为 1 且值可原地修改”。Zend/zend_types.h 附近存在将 Z_REFCOUNTED_P、GC_IMMUTABLE/GC_PERSISTENT 标志检查与 Z_REFCOUNT_P(arg) == 1 组合起来的内联判断宏,VM 的写路径(如向数组追加元素、改写字符串)正是依据这类判断决定是否先分离:分离后原引用被加引、RC 保持 2,新副本单独以 RC 1 参与修改,从而保证 $a 的内容不受 $b[] = 4 影响。字符串侧的类似机制可结合 zend_string.rst 进一步了解。
五、不可变的引用计数类型(Immutable)
有时候,引用计数类型实际上也不做引用计数。当 PHP 运行在多进程或多线程环境且启用 opcache 时,引擎会在进程/线程间共享部分常用值以降低内存占用。跨进程/线程共享内存后,修改值通常要求排他访问——其他方必须等待更新完成。为避免这种同步开销,共享值被制成不可变形式:永远不再原地修改、也不再修改其引用计数,其 gc->u.type_info 中置位 GC_IMMUTABLE 标志。
源码印证:Zend/zend_types.h 中,字符串的标志定义直接复用了 GC 标志位:
/* string flags (zval.value->gc.u.flags) */
#define IS_STR_CLASS_NAME_MAP_PTR GC_PROTECTED /* refcount is a map_ptr offset of class_entry */
#define IS_STR_INTERNED GC_IMMUTABLE /* interned string */
#define IS_STR_PERSISTENT GC_PERSISTENT /* allocated using malloc */
也就是说,interned string(内建字符串)正是 GC_IMMUTABLE 语义的典型实例:常量池中的字符串被反复引用,引擎选择“不加引、不释放”,由进程生命周期统一管理。数组侧同样有 IS_ARRAY_IMMUTABLE = GC_IMMUTABLE(L932)。
使用注意事项(与原文档一致):
GC_TRY_ADDREF这类宏会自行保护、跳过 immutable 值;GC_ADDREF等宏不检查 immutable 位,误用属于未定义行为;- 可以以
-d opcache.protect_memory=1启动 PHP,把共享内存标记为只读,任何意外的写尝试都会触发硬件异常(SIGSEGV),便于定位问题代码。
六、循环回收器(Cycle Collector)
引用计数并非万能。考虑如下代码:
$a = new stdClass;
$b = new stdClass;
$a->b = $b;
$b->a = $a;
unset($a);
unset($b);
代码执行完毕后,两个 stdClass 实例的引用计数仍各为 1——因为它们互相引用,这称为引用环(reference cycle)。单纯依赖引用计数,这部分内存将永远无法释放。
PHP 的解决方案是循环回收器:它能检测这类环,释放“仅通过自身引用才能到达”的值。工作机制从 GC_DTOR 的实现即可窥见:每次减引后若 RC 未归零,值会被 gc_check_possible_root 记录到“可能参与环”的缓冲区;缓冲区写满时自动触发一轮回收。也可以在用户代码中显式调用 gc_collect_cycles()。该函数的入口在 Zend/zend_builtin_functions.c:
ZEND_FUNCTION(gc_collect_cycles)
{
/* ... */
RETURN_LONG(gc_collect_cycles());
}
并在 Zend/zend.c 中被绑定为全局可调用函数 gc_collect_cycles = zend_gc_collect_cycles;此外 Zend/zend_execute_API.c 中还存在脚本执行路径上的自动调用点(请求结束前兜底回收)。回收器本体实现在 Zend/zend_gc.c,其中 zend_gc_collect_cycles 返回本轮回收的个数,这也解释了 RETURN_LONG(gc_collect_cycles()) 的返回值语义。官方文档中“Cycle collector”独立章节在文档中仍为占位(todo),本篇仅依据当前仓库可验证的内容描述。
七、GC flags 位域详解
type_info 高位中的 GC 标志定义在 Zend/zend_types.h,与文档一致:
/* zval_gc_flags(zval.value->gc.u.type_info) (common flags) */
#define GC_NOT_COLLECTABLE (1<<4)
#define GC_PROTECTED (1<<5) /* used for recursion detection */
#define GC_IMMUTABLE (1<<6) /* can't be changed in place */
#define GC_PERSISTENT (1<<7) /* allocated using malloc */
#define GC_PERSISTENT_LOCAL (1<<8) /* persistent, but thread-local */
各标志含义(完整继承原文档):
GC_NOT_COLLECTABLE:表明该值不可能参与引用环,是快速判定“无需登记进循环回收器缓冲区”的手段。实际上只有数组和对象可能参与引用环。源码中各类型的type_info初值直接编码了这一点(L893-L899):GC_STRING、GC_RESOURCE、GC_REFERENCE、GC_CONSTANT_AST都带GC_NOT_COLLECTABLE位,而GC_ARRAY、GC_OBJECT不带。GC_PROTECTED:用于内部函数的递归保护。例如var_dump会递归打印值的内容,打印前先给已访问的值打上GC_PROTECTED标记,若再次遇到该值即可识别为递归并停止,避免栈溢出。对应宏GC_PROTECT_RECURSION/GC_UNPROTECT_RECURSION/GC_TRY_*变体(同样跳过 immutable 值)见 Zend/zend_types.h。GC_IMMUTABLE:见第五节,表示值不可原地修改,引用计数永不变化。GC_PERSISTENT:表明值是用malloc而非 PHP 自有分配器(Zend allocator)分配的,通常存活于整个进程生命周期,而非在请求结束时释放。GC_PERSISTENT_LOCAL:表明某个GC_PERSISTENT值只在一个线程内可访问,因而仍可以安全修改;该标志仅在调试构建中使用,用于满足ZEND_RC_MOD_CHECK中的assert(L779-L804)。
这些位通过 zval_gc_flags(L759-L761)从 type_info 中按掩码 GC_FLAGS_MASK (0x000003f0) 提取,低 4 位保留给类型(GC_TYPE_MASK),高 22 位(GC_INFO)则用于存放附加信息,如字符串上对类名字符串复用的 fast-class-cache 指针(IS_STR_CLASS_NAME_MAP_PTR 与 ZSTR_GET_CE_CACHE 等宏,L925、L952-L966)。
八、小结:一张心智地图
- 谁需要引用计数:字符串、数组、对象、引用、资源——即携带辅助数据或引用类型;
null/bool/int/float不需要。 - 数据布局:
zend_refcounted_h { uint32_t refcount; uint32_t type_info; }恒居zend_string、zend_array等结构体之首(Zend/zend_types.h),使父指针强转与统一析构成为可能。 - 接口纪律:只通过
Z_*/GC_*宏操作;区分“TRY 系(对普通值/immutable 安全)”与“强断言系(仅限 RC 类型)”,GC_DTOR是减引与成环登记的总闸口。 - 值语义保障:数组与字符串是值类型,RC > 1 时修改前必须 separation(CoW),RC 为 1 才可原地改。
- 共享内存优化:opcache 共享值打上
GC_IMMUTABLE,永不加引/减引,-d opcache.protect_memory=1可用硬件只读保护兜底。 - 引用环兜底:
gc_check_possible_root登记 + 缓冲区满或显式gc_collect_cycles()(Zend/zend_gc.c)回收仅自环可达的值。 - 标志位速查:
GC_NOT_COLLECTABLE(不成环)、GC_PROTECTED(递归保护)、GC_IMMUTABLE(不可变)、GC_PERSISTENT(malloc 持久)、GC_PERSISTENT_LOCAL(线程局部持久,调试用)。
延伸阅读:data-structures 目录索引、zval.rst、zend_string.rst,以及核心实现文件 Zend/zend_types.h、Zend/zend_gc.c。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00