首页
/ PHP 引用计数机制深度解析:从 zend_refcounted_h 到 CoW 分离与循环回收器

PHP 引用计数机制深度解析:从 zend_refcounted_h 到 CoW 分离与循环回收器

2026-09-05 18:32:47作者:申梦珏Efrain

在 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 中。而更简单的类型要么根本不存储值(nullfalsetrue),要么小到可以直接存放在 zval 内(intfloat),因此它们无需引用计数。这一划分与 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.hstruct _zend_string 的第一个成员就是 zend_refcounted_h gc,其后是哈希值 h、长度 len 和弹性字符数组 val[1]Zend/zend_types.hstruct _zend_array 同样以 gc 打头,随后是哈希表桶数量、桶/紧凑数组指针(arHash/arData/arPacked)等字段。由于“引用计数头 + 类型信息”占据头部的 8 个字节,zend_refcounted_h * 可以直接当作各引用计数类型的通用父指针使用——这也是 GC_DTOR 等宏能把 zend_stringzend_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_refcountzend_gc_addrefzend_gc_try_addrefzend_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),因此它对 intbool 等普通值安全;而 zval_addref_p 本身带有 ZEND_ASSERT(Z_REFCOUNTED_P(pz)) 断言(L1399-L1402),即 Z_ADDREF 只应作用于 RC 类型。
  • GC_DTORL861-L869)是引用计数与循环回收器的衔接点:先 zend_gc_delref 减引,若归零则调用 rc_dtor_functype_info 分发到具体类型的析构;若未归零,则调用 gc_check_possible_root,将该值登记进循环回收器的“可能成环”缓冲区。

补充一个调试机制:在 ZEND_RC_DEBUG 开启的构建中,每次修改引用计数都会执行 ZEND_RC_MOD_CHECKL783-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_PGC_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_IMMUTABLEL932)。

使用注意事项(与原文档一致):

  • 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_STRINGGC_RESOURCEGC_REFERENCEGC_CONSTANT_AST 都带 GC_NOT_COLLECTABLE 位,而 GC_ARRAYGC_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 中的 assertL779-L804)。

这些位通过 zval_gc_flagsL759-L761)从 type_info 中按掩码 GC_FLAGS_MASK (0x000003f0) 提取,低 4 位保留给类型(GC_TYPE_MASK),高 22 位(GC_INFO)则用于存放附加信息,如字符串上对类名字符串复用的 fast-class-cache 指针(IS_STR_CLASS_NAME_MAP_PTRZSTR_GET_CE_CACHE 等宏,L925L952-L966)。

八、小结:一张心智地图

  • 谁需要引用计数:字符串、数组、对象、引用、资源——即携带辅助数据或引用类型;null/bool/int/float 不需要。
  • 数据布局zend_refcounted_h { uint32_t refcount; uint32_t type_info; } 恒居 zend_stringzend_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.rstzend_string.rst,以及核心实现文件 Zend/zend_types.hZend/zend_gc.c

登录后查看全文
热门项目推荐
相关项目推荐