编程

PHP 底层原理之 Zval 内存管理

2 2026-11-04 22:51:00

内存管理

为了正确且高效地使用 zval,理解其内存管理机制至关重要。从广义上讲,我们可以将值分为两类:一类是简单值,如整数,它们直接存储在 zval 内部;另一类是复杂值,如字符串,对于这类值,zval 仅存储一个指向独立结构的指针。

引用计数值

所有复杂值都有一个共用的头部,其结构如下:

typedef struct _zend_refcounted_h {
    uint32_t refcount;
    union {
        uint32_t type_info;
    } u;
} zend_refcounted_h;

这个 header 存储了一个引用计数,用于跟踪此结构被使用的次数。如果此结构在新的 zval(值)中被使用,则引用计数会增加。如果它不再被使用,则引用计数会减少。如果引用计数达到零,我们就知道此结构不再被使用,可以释放它。这是 PHP 内存管理的核心机制。

type_info 字段用于编码附加信息,如结构的类型、多个特定于类型的标志以及垃圾回收根。我们稍后将讨论这些信息的用途。

有一些函数用于创建不同类型的引用计数结构,这些函数会创建初始引用计数为 1 的结构:

zend_string *str = zend_string_init("test", sizeof("test")-1, /* persistent */ 0); // refcount=1
zend_array *arr = zend_new_array(/* size hint */ 0); // refcount=1

// Do something with str and arr.

zend_string_release(str); // refcount=0 => destroy!
zend_array_release(arr); // refcount=0 => destroy!

zend_string_release() 和 zend_array_release() 函数会减少字符串或数组的引用计数,如果引用计数变为零,则销毁该对象。例如,以下代码完全有效:

zend_string *str = zend_string_init("test", sizeof("test")-1, /* persistent */ 0); // refcount=1
zend_hash_add_empty_element(arr, str); // refcount=2
zend_string_release(str); // refcount=1

这会将一个键为 str 的元素添加到数组中,并在之后释放该字符串。但是,zend_hash_add_empty_element()函数会增加字符串的引用计数,因此 zend_string_release() 调用不会销毁它。只有在数组也被销毁且没有对该字符串的引用时,它才会被销毁。

不可变值

虽然所有复杂结构都共享 zend_refcounted_h 头文件,但引用计数并不总是实际使用。字符串和数组可以是不可变的,这意味着包括引用计数在内的整个结构都绝对不能被修改。这样的结构可以在不增加引用计数的情况下重复使用,并且保证至少在请求结束之前不会被销毁。

不可变字符串和数组存在的原因有很多:

存储在 opcache 共享内存中的任何结构都是不可变的,因为它们在多个进程之间共享。你可以通过设置 ini 配置文件中的 opcache.protect_memory=1 来通过 mprotect() 函数强制执行此设置。这将使大多数违反不可变性的行为导致程序崩溃,而不是出现异常行为。

空数组被声明为常量(const),因此通常分配在只读段中。尝试修改它会导致程序崩溃。

在请求外部创建但在请求内部可能使用的持久字符串(如 ini 值)必须是不可变的,因为可能有多个线程并行使用它们。由于 PHP 的引用计数是非原子的,因此执行常规的引用计数操作是不安全的。

最后,虽然上述原因使不可变结构成为一种技术需求,但拥有它们也有助于性能优化,因为在许多常见情况下可以避免引用计数操作。

在使用高级 API(如 zend_string_copy() 或 ZVAL_COPY())时,不可变结构将自动得到正确处理。但是,如果使用低级 API,则需要明确考虑这些结构。

低级接口主要通过以下宏提供:

Macro

描述

GC_TYPE

获取结构类型 (IS_* 常量).

GC_FLAGS

获取 Flag

GC_REFCOUNT

获取引用计数

GC_ADDREF

增加引用计数。结构必须可变。

GC_DELREF

减少引用计数。结构必须可变。如果引用计数到达 0,不会释放结构。

GC_TRY_ADDREF

如果是可变的,则增加计数,否则什么都不做。

Immutable structures set the GC_IMMUTABLE flag (which has a number of aliases like IS_STR_INTERNED and IS_ARRAY_IMMUTABLE), which can be used to determine whether incrementing the refcount is safe:

zend_string *str = /* ... */;

if (!(GC_FLAGS(str) & GC_IMMUTABLE)) {
    GC_ADDREF(str);
}

// Same as:
GC_TRY_ADDREF(str);

// Same as (high-level API):
zend_string_addref(str);

名称中包含 TRY 的宏通常表示某个操作只应针对可变结构执行。你会遇到更多类似 Z_TRY_ADDREF 和 GC_TRY_PROTECT_RECURSION 这样的示例,它们的含义是相同的。

持久化结构

PHP 使用了两种分配器:一种是按请求分配器,它在每个请求结束时释放所有内存;另一种是持久化分配器,它在多个请求之间保留分配的内存。持久分配器实际上与普通的系统分配器相同。有关 PHP 分配管理的更多信息,请参阅 PHP 生命周期和 Zend 内存管理器章节。

许多创建引用计数结构的函数都会接受一个持久标志,以确定使用哪个分配器。例如,zend_string_init() 函数的最后一个参数就是这样的一个标志。如果一个函数没有暴露持久标志,那么一个合理的默认假设是使用按请求(非持久)分配器。例如,zend_array_new() 函数总是创建一个每请求数组,而创建持久数组则必须使用更底层的 API。

持久化结构会设置 GC_PERSISTENT 标志,其析构函数会自动使用正确的分配器来释放内存。因此,除了首先使用正确的分配器(通常是每个请求一个)之外,通常无需担心此标志。

然而,理解持久化结构与请求期间执行的代码之间的交互方式非常重要:持久化结构可能会被多个线程使用。由于 PHP 的引用计数是非原子性的,从多个线程执行引用计数会导致数据竞争(进而导致崩溃)。

因此,在请求过程中使用的任何持久结构必须要么是不可变的,要么是线程局部的。可以使用 CFLAGS="-DZEND_RC_DEBUG=1 "编译 PHP 来自动诊断此类问题。这个问题最常影响字符串,在这种情况下,可以通过内部化使字符串成为不可变的。GC_MAKE_PERSISTENT_LOCAL() 宏用于将持久结构标记为线程局部。此宏除了禁用ZEND_RC_DEBUG 验证外,没有其他作用。

Zval 内存管理

在解决了初步问题之后,我们可以讨论内存管理如何与 zval 交互。引用计数结构可以独立使用,但将它们存储在 zval 中无疑是更常见的用例之一。

Zval 本身从不单独进行堆分配。它们要么在堆栈上临时分配,要么作为更大的堆分配结构的一部分嵌入其中。

这个基本示例展示了堆栈分配的 zval 的初始化及其后续销毁过程:

zval str_val;
ZVAL_STRING(&str_val, "foo"); // Creates zend_string (refcount=1).
// ... Do something with str_val.
zval_ptr_dtor(&str_val); // Decrements to refcount=0, and destroys the string.

ZVAL_STRING()  创建一个字符串 zval,而 zval_ptr_dtor() 则负责释放它。稍后我们将讨论不同的初始化宏和析构函数。

堆栈分配的 zval 只能在声明它的作用域内使用。虽然从技术上讲可以返回一个 zval,但你会发现 PHP 从不按值传递或返回 zval。相反,zval 总是通过指针传递。若要返回一个zval,需要向函数传递一个输出参数:

// retval is an output parameter.
void init_zval(zval *retval) {
    ZVAL_STRING(retval, "foo");
}

void some_other_function() {
    zval val;
    init_zval(&val);
    // ... Do something with val.
    zval_ptr_dtor(&val);
}

虽然 zval 本身通常不共享,但可以通过引用计数机制来共享它们所指向的结构。Z_REFCOUNT、Z_ADDREF 和 Z_DELREF 宏的工作方式与相应的 GC_* 宏相同,但它们作用于 zval。重要的是,这些宏只能在 zval 确实指向一个被引用计数的结构且该结构不是不可变的情况下使用。IS_TYPE_REFCOUNTED 类型标志用于判断是否满足这一条件,可以通过 Z_REFCOUNTED 来访问:

void fill_array(zval *array) {
    zval val;
    init_zval(&val);

    // Manually check REFCOUNTED:
    if (Z_REFCOUNTED(val)) {
        Z_ADDREF(val);
    }
    add_index_zval(array, 0, &val);

    // Or use the TRY macro:
    Z_TRY_ADDREF(val);
    add_index_zval(array, 1, &val);

    zval_ptr_dtor(&val);
}

此例将同一值两次添加到数组中,这意味着引用计数必须被增加两次。虽然可以手动检查 zval 是否为 Z_REFCOUNTED,但更推荐使用 Z_TRY_ADDREF,它仅对已计数的结构增加引用计数。

这里需要考虑的是谁负责增加引用计数。在这个例子中,是 add_index_zval() 的调用者负责增加引用计数。遗憾的是,PHP API 在这方面并不十分一致。一个非常粗略的经验法则是,数组值需要由调用者增加引用计数,而大多数其他 API 则会自行处理。

复制 zval

将 zval 从一个位置复制到另一个位置是很常见的。为此,我们提供了一些复制宏。第一个是 ZVAL_COPY_VALUE():

void init_zval_indirect(zval *retval) {
    zval val;
    init_zval(&val);
    ZVAL_COPY_VALUE(retval, &val);
}

这个(相当愚蠢的)示例初始化了一个堆栈 zval,然后将值移动到返回值参数中。ZVAL_COPY_VALUE 宏执行了一个简单的 zval 复制操作,但不增加引用计数。因此,其主要用途是移动一个 zval,这意味着原始的 zval 将不再被使用(包括它不应该被销毁)。有时,这个宏也被用作一种优化手段,用于复制我们知道不会被引用计数的 zval。

ZVAL_COPY_VALUE 宏与简单赋值(*retval = val)的不同之处在于,它仅复制 zval 的值和类型,而不复制其 u2 成员。因此,将 ZVAL_COPY_VALUE 赋值给一个正在使用其 u2 成员的 zval 是安全的,因为该成员不会被覆盖。

第二个宏是 ZVAL_COPY,它是 ZVAL_COPY_VALUE 和 Z_TRY_ADDREF 的优化组合:

void init_pair(zval *retval1, zval *retval2) {
    zval val;
    init_zval(&val); // refcount=1

    ZVAL_COPY(retval1, &val); // refcount=2
    ZVAL_COPY(retval2, &val); // refcount=3

    zval_ptr_dtor(&val); // refcount=2
}

这个例子将值复制了两次,并两次递增引用计数(如果有的话)。编写这个函数的一种不同且稍微更高效的方式是:

void init_pair(zval *retval1, zval *retval2) {
    zval val;
    init_zval(&val); // refcount=1
    ZVAL_COPY(retval1, &val); // refcount=2
    ZVAL_COPY_VALUE(retval2, &val); // refcount=2
}

这会将值一次复制到 retval1 中,然后再移动到 retval2 中,从而避免了冗余的引用计数增减操作。最后,我们实际编写这段代码时可能会这样写:

void init_pair(zval *retval1, zval *retval2) {
    init_zval(retval1); // refcount=1
    ZVAL_COPY(retval2, retval1); // refcount=2
}

在这里,值被直接赋给 retval1,然后再复制到 retval2 中。这个版本既简单又高效。

ZVAL_DUP 宏与 ZVAL_COPY 类似,但会复制数组,而不仅仅是增加其引用计数。如果你在使用这个宏,那么你几乎肯定在做一些非常错误的事情。

最后,ZVAL_COPY_OR_DUP 是一个非常特殊的复制宏,可以在请求期间从可能持久化的 zval 进行复制时使用。如前所述,在这种情况下增加引用计数是非法的,因为它不是线程安全的。此宏将对非持久化值增加引用计数,但对持久化值则执行完整的字符串/数组复制。

销毁 zval

上述示例已经在使用 zval_ptr_dtor() 来销毁 zval。如果该值是引用计数的,则此函数会递减引用计数,并在引用计数达到零时销毁该值。

然而,这里有一个微妙之处:引用计数不足以检测出作为循环一部分的未使用值。因此,PHP 采用了一种额外的标记和清除式循环垃圾回收器(GC)。当引用计数递减但未达到零,且结构被标记为可能存在循环(未设置 GC_NOT_COLLECTABLE 标志)时,PHP 会将该结构添加到 GC 根缓冲区中。

zval_ptr_dtor_nogc() 函数是一个变体,它不执行垃圾收集(GC)根缓冲区检查,并且只有在你知道被销毁的数据是非循环的情况下才能安全使用。zval_dtor() 是该函数的旧别名。

在内部代码中可能遇到的另一个变体是 i_zval_ptr_dtor(),它与 zval_ptr_dtor() 相同,但使用的是内联实现。i_ 前缀是同时具有内联和外部变体的函数的一般约定。

初始化 zval

到目前为止,我们一直在使用一个抽象的 init_zval() 函数来初始化 zval。PHP 使用大量宏来处理 zval 的初始化,这并不奇怪。简单类型的初始化尤其简单:

zval val;
ZVAL_UNDEF(&val);

zval val;
ZVAL_NULL(&val);

zval val;
ZVAL_FALSE(&val);

zval val;
ZVAL_TRUE(&val);

zval val;
ZVAL_BOOL(&val, zero_or_one);

zval val;
ZVAL_LONG(&val, 42);

zval val;
ZVAL_DOUBLE(&val, 3.141);

For strings, there are quite a few initialization options. The most fundamental is the ZVAL_STR() macro, which takes an already constructed zend_string*:

zval val;
ZVAL_STR(&val, zend_string_init("test", sizeof("test")-1, 0));

As creating a zend_string from a string literal or an existing string is so common, there are two convenience wrappers:

zval val;
ZVAL_STRINGL(&val, "test", sizeof("test")-1);

zval val;
ZVAL_STRING(&val, "test"); // Uses strlen() for length.

The ZVAL_STR macro will set the IS_TYPE_REFCOUNTED flag based on whether the string is immutable or not. There are two optimized variants that can be known if it is known in advance whether the string is interned:

// This string is definitely not interned/immutable.
zval val;
ZVAL_NEW_STR(&val, zend_string_init("test", sizeof("test")-1, 0));

// This string is definitely interned.
zval val;
ZVAL_INTERNED_STR(&val, ZSTR_CHAR('a'));

Empty strings have a separate helper:

zval val;
ZVAL_EMPTY_STRING(&val);

The ZVAL_STRINGL_FAST macro can be used to avoid a zend_string allocation if the string is empty or has a single character, as such strings always have interned variants that can be fetched quickly:

zval val;
ZVAL_STRINGL_FAST(&val, str, len);

Finally, the ZVAL_STR_COPY macro is a combination of ZVAL_STR and zend_string_copy, where the latter increments the refcount of the string:

zval val;
ZVAL_STR_COPY(&val, zstr); // Refcount will be incremented.
// More efficient/compact version of:
ZVAL_STR(&val, zend_string_copy(zstr));

For arrays, we thankfully only have to consider two initialization macros:

zval val;
ZVAL_ARR(&val, zend_new_array(/* size_hint */ 0));

zval val;
ZVAL_EMPTY_ARRAY(&val);

The first one initializes an array zval to an existing zend_array* structure, while the latter initializes an empty array in particular. Note that while both of the above examples initialize an empty array, they are not the same. ZVAL_EMPTY_ARRAY() uses an immutable shared empty array, while zend_new_array() creates a new one. If you plan to modify the array directly afterwards, you should be using the zend_new_array() variant.

Object zvals are initialized using ZVAL_OBJ:

zval val;
ZVAL_OBJ(&val, obj_ptr);

zval val;
ZVAL_OBJ_COPY(&val, obj_ptr); // Increments refcount

While these are somewhat common when dealing with already existing objects, object_init_ex() is the typical way to create an object from scratch. This will covered in a later chapter on objects.

Finally, resources are initialized using ZVAL_RES:

zval val;
ZVAL_RES(&val, zend_register_resource(ptr, le_resource_type));

Separating zvals

In PHP, all values follow by-value semantics by default. This means that if you write $a = $b, then modification of $a will have no effect on $b and vice versa. At the same time, $a = $b is essentially implemented as:

zval_ptr_dtor(a);
ZVAL_COPY(a, b);

That is, $a and $b will both point to the same structure with an incremented refcount. This means that a naive modification of $a would also modify $b.

This is where the copy-on-write concept comes in: You are only permitted to modify structures that you exclusively own, which means that they must have a refcount of one. If a structure has a refcount greater than one, it needs to be separated first. Separation is just a fancy word for duplicating the structure.

In practice “structure” can be replaced with “array”. While in theory the concept also applies to strings, strings are almost never mutated after construction in PHP. As such SEPARATE_ARRAY() is the main separation macro, which can only be applied to IS_ARRAY zvals:

zval a, b;
ZVAL_ARR(&b, zend_new_array(0));
ZVAL_COPY(&a, &b);

SEPARATE_ARRAY(&b); // b now holds a separate copy of the array.
// Modification of b will no longer affect a.

The SEPARATE_ARRAY() macro takes care not only of shared arrays, but also of immutable ones:

zval val;
ZVAL_EMPTY_ARRAY(&val); // Immutable empty array.
SEPARATE_ARRAY(&val); // Mutable copy of empty array.

The SEPARATE_ZVAL_NOREF() macro separates a generic zval, but is only rarely useful, as separation typically directly precedes a modification, and you need to know the zval type to perform any meaningful modification anyway.

Objects and resources do not require separation, as they have reference-like semantics.