首页
/ PHP 引擎的 zend_string 深度解析:结构布局、API 与字符串驻留机制

PHP 引擎的 zend_string 深度解析:结构布局、API 与字符串驻留机制

2026-09-05 16:52:42作者:董灵辛Dennis

PHP 解释器(php-src)中所有字符串——变量值、数组键、类名、属性名——最终都运行在同一个 C 结构体 zend_string 之上。本文基于仓库文档 zend_string.rst 展开,结合 Zend/zend_string.hZend/zend_string.cZend/zend_types.h 的实际源码,完整讲解它的动机、内存布局、创建/访问/引用计数 API、哈希实现与驻留字符串(interned strings)机制,并说明 OPcache 介入后行为上的差异。读完你可以独立阅读引擎中任意 ZSTR_* 相关代码,理解 PHP 字符串为何既高效又安全。

为什么 PHP 不用 C 的 char* 表示字符串

C 语言中字符串通常是 char*char[],以 NUL 字符 '\0' 作为结束标志。文档 zend_string.rst 指出了这种表示方式的三个显著缺点:

  • 求长度昂贵:必须从头扫描整个字符串直到找到终止 NUL 字符;
  • 不能包含 NUL 字节:字符串内容里无法合法出现 \0
  • 容易缓冲区溢出:一旦 NUL 缺失,后续读取就越界。

PHP 引擎用 zend_string 结构体作为 char* 的抽象层,显式存储字符串长度,从而把求长度变成 O(1) 的字段读取,并且允许字符串内部出现 NUL 字节(zend_string_initlen 复制,而不是按 NUL 截断)。

结构布局:zend_string 的内存模型

结构体定义

文档给出的结构体如下(对应 Zend/zend_types.h#L379-L384 的真实定义):

struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong        h;  /* hash value */
    size_t            len;
    char              val[1];
};

四个字段的含义:

字段 作用
gc 公共的引用计数头 zend_refcounted_h,用于引用计数与 GC 标志位(见 reference-counting.rst
h 惰性计算的哈希值,供哈希表(如 HashTable/数组)查找使用;为 0 表示"尚未计算"
len 字符串长度,单位是字节(对 UTF-8 内容,这并非字符数)
val 字符串数据本体,以 NUL 结尾(引擎总是多分配 1 字节并写入 '\0'

struct hack:char val[1] 的灵活尾数组

val 为何声明为 char val[1]?这是 C 中著名的 struct hack——允许结构体最后一个元素"变长",使整个结构体的实际大小在运行时由字符串长度决定。文档提到分配时会按 _ZSTR_STRUCT_SIZE 追加足够的字节,源码中的定义印证了这一点(Zend/zend_string.h#L126-L131):

#define _ZSTR_HEADER_SIZE offsetof(zend_string, val)
#define _ZSTR_STRUCT_SIZE(len) (_ZSTR_HEADER_SIZE + len + 1)
#define ZSTR_MAX_OVERHEAD (ZEND_MM_ALIGNED_SIZE(_ZSTR_HEADER_SIZE + 1))
#define ZSTR_MAX_LEN (SIZE_MAX - ZSTR_MAX_OVERHEAD)
  • 分配总量 = 头部大小 + 数据长度 + 1 字节 NUL,并经 ZEND_MM_ALIGNED_SIZE 做内存对齐;
  • ZSTR_MAX_LEN 则防住 len 过大时 len + 1 等运算的整数溢出。

分配函数 zend_string_allocZend/zend_string.h#L188-L197)会一次性 pemalloc 出整个对象,并初始化引用计数为 1、类型标记为 GC_STRING,若为持久字符串则打上 IS_STR_PERSISTENT 标志;zend_string_init 紧接着 memcpy 数据并在末尾写 NUL。

创建字符串的 API

字符串 API 定义在 Zend/zend_string.h。文档列出的创建函数/宏如下(s = zend_stringl = 长度,p = persistent):

函数/宏 说明
ZSTR_INIT_LITERAL(s, p) 从字符串字面量创建新字符串,长度在编译期即可确定
zend_string_init(s, l, p) 从字符缓冲区创建新字符串
zend_string_alloc(l, p) 创建指定长度但不初始化内容的字符串
zend_string_concat2(s1, l1, s2, l2) 拼接两个字符缓冲区,创建非持久字符串
zend_string_concat3(...) 同上,但拼接三个缓冲区
ZSTR_EMPTY_ALLOC() 取得不可变的空字符串,不分配内存
ZSTR_CHAR(char) 取得不可变的单字符字符串,不分配内存
ZSTR_KNOWN(ZEND_STR_XXX) 取得不可变的预定义字符串(如 "class"),不分配内存

ZSTR_INIT_LITERAL 与 persistent 参数

文档中的基本使用示例:

// Allocate the string.
zend_string *string = ZSTR_INIT_LITERAL("Hello world!", /* persistent */ false);
// Write it to the output buffer.
zend_write(ZSTR_VAL(string), ZSTR_LEN(string));
// Decrease the reference count and free it if necessary.
zend_string_release(string);

其中 ZSTR_INIT_LITERALzend_string_init(char *string, size_t length, bool persistent) 的封装,利用 sizeof(s) - 1 在编译期提供长度——源码实现(Zend/zend_string.h#L149):

#define ZSTR_INIT_LITERAL(s, persistent) (zend_string_init(("" s), sizeof(s) - 1, (persistent)))

persistent 参数决定字符串用 mallocpersistent == true,存活到进程结束)还是 PHP 自己的分配器 emallocpersistent == false,每个请求结束后整体清空)来分配。文档特别强调:用完字符串必须调用 zend_string_release,否则内存泄漏;release 之后不得再访问其任何字段,因为如果你恰好是最后一个使用者,它可能已经被释放。

源码中的 zend_string_releaseZend/zend_string.h#L400-L407)确实会自动根据 IS_STR_PERSISTENT 标志选择 pefree 的持久/非持久分支:

static zend_always_inline void zend_string_release(zend_string *s)
{
    if (!ZSTR_IS_INTERNED(s)) {
        if (GC_DELREF(s) == 0) {
            pefree(s, GC_FLAGS(s) & IS_STR_PERSISTENT);
        }
    }
}

注意它首先判断 ZSTR_IS_INTERNED:驻留字符串不参与引用计数,release 是空操作。

零分配捷径与快速路径

三个"不分配内存"的宏在源码中实现得非常直白(Zend/zend_string.h#L114-L124):

static zend_always_inline zend_string *ZSTR_EMPTY_ALLOC(void) {
    return zend_empty_string;
}
static zend_always_inline zend_string *ZSTR_CHAR(unsigned char c) {
    return zend_one_char_string[c];
}
static zend_always_inline zend_string *ZSTR_KNOWN(size_t idx) {
    return zend_known_strings[idx];
}

它们分别返回全局空串、256 个单字符串数组和已知字符串数组。这些对象在引擎启动时由 zend_string.czend_interned_strings_init 统一创建(Zend/zend_string.c#L112-L148),全部是 permanent 驻留字符串。

ZSTR_KNOWN 的编号来自宏展开表 ZEND_KNOWN_STRINGSZend/zend_string.h#L658-L754),其中收录了引擎内部高频字符串,如 fileclass->::include_once_SERVERTraversable 以及 "8.0""8.6" 的版本号等。源码注释提醒:新增已知字符串时同步更新 build/gen_stub.php,因为该列表还被属性注册代码使用。

另有一个值得注意的快速路径 zend_string_init_fastZend/zend_string.h#L219-L228):长度为 0 直接返回 zend_empty_string,长度为 1 直接返回 ZSTR_CHAR(*str),只有长度大于 1 才真正分配——这是文档未展开但源码中真实存在的热路径优化。

访问宏:不要直接读字段

按 php-src 的惯例,不应直接访问 zend_string 的字段,而应使用访问器宏。文档给出的对照表(对应 Zend/zend_string.h#L75-L78):

zend_string 宏 zval 宏 说明
ZSTR_LEN Z_STRLEN[_P] 返回字符串字节长度
ZSTR_VAL Z_STRVAL[_P] 返回 char* 数据指针
ZSTR_HASH Z_STRHASH[_P] 若尚未计算则计算哈希并返回
ZSTR_H 直接返回哈希字段,假定哈希已计算

ZSTR_HASHZSTR_H 的区别正是"惰性哈希"的关键,实现(Zend/zend_string.h#L153-L156):

static zend_always_inline zend_ulong zend_string_hash_val(zend_string *s)
{
    return ZSTR_H(s) ? ZSTR_H(s) : zend_string_hash_func(s);
}

ZSTR_H(s) == 0 表示未计算,zend_string_hash_val 会调用 zend_string_hash_func 计算并回填h 字段;而 zend_string_forget_hash_val 会在字符串被就地改写(如 separate、realloc)时把 h 清零并清除 UTF-8 有效标志,保证哈希与内容永远一致。

哈希算法:DJBX33A 的实现细节

文档提到 h 字段"用于哈希表查找",但没讲哈希本身怎么算。源码中 zend_hash_func 最终调用内联函数 zend_inline_hash_funcZend/zend_string.h#L555-L654),即 DJBX33A(Daniel J. Bernstein 的 "times 33 with addition"):核心递推是 hash = hash * 33 + str[i],初始值 5381。源码注释引用了 Ralf S. Engelschall 的分析:常数 33 的乘法可以化简为"一次移位加一次加法",计算极快且分布良好。

实现上有几点值得注意:

  1. 按 8/4/2/1 字节分块展开:在 x86、x86-64 与 aarch64 上使用乘法版本(现代 CPU 上更优),其余平台用 ((hash << 5) + hash) 的移位版本循环展开 8 次再按尾部长度 switch 收尾;
  2. aarch64 小端特化:一次 memcpy 载入 8 字节 chunk,再用位段提取指令逐字节取出,减少访存次数;
  3. 高位置 1:结果最后强制置最高位(hash | Z_UL(0x8000000000000000),32 位则置 0x80000000),因为哈希值 不能为 0——0 在 zend_string 中是"尚未计算哈希"的哨兵值。

这一设计与驻留字符串表配合:zend_interned_string_ht_lookup 查找时先用 h 走桶链表,命中哈希后仍需 zend_string_equal_content 逐字节比较(Zend/zend_string.c#L177-L195),保证哈希碰撞下依然正确。

引用计数与分离(CoW)

字符串属于引用计数类型(与数组、对象、引用、资源并列,见 reference-counting.rst)。文档列出的引用计数 API:

宏/函数 说明
zend_string_copy(s) 引用计数 +1 并返回同一字符串;驻留字符串不增加计数
zend_string_release(s) 引用计数 -1,归零则释放
zend_string_dup(s, p) 在新分配中创建真实副本;驻留字符串原样返回
zend_string_separate(s) 引用计数大于 1 时复制一份;详见 reference-counting 文档
zend_string_realloc(s, l, p) 改变字符串大小;若引用计数大于 1 或字符串是驻留的,则创建新字符串。必须总是使用该函数的返回值,因为原字符串可能已被移动到新地址

对照源码,这些函数的驻留字符串处理高度一致(Zend/zend_string.h#L230-L320):

  • zend_string_copy 仅对非驻留串 GC_ADDREF
  • zend_string_dup 对驻留串直接返回原指针;
  • zend_string_separate 在"驻留串 或 RC > 1"时分配新串并(非驻留时)delref 旧串,否则就地复用但先 zend_string_forget_hash_val
  • zend_string_realloc/zend_string_extend/zend_string_truncate 都遵循同一模式:非驻留且 RC == 1 时走 perealloc 就地改大小(并遗忘哈希);否则分配新串、memcpyMIN(新长, 旧长) + 1 字节、释放旧串。

这解释了文档中"必须使用返回值"的告诫:就地 realloc 可能使指针本身变化,且驻留/共享场景下指针指向的是全新的分配。由于 PHP 中字符串是值类型(copy-on-write),这套机制保证了 $b = $a; $b .= "x"; 这类代码在底层既共享内存又互不干扰。

字符串比较 API

文档简述了比较函数族:zend_string_equals 做全量比较,zend_string_starts_with 检查前缀,且均有 _ci(大小写不敏感)与 _literal(字面量)变体,"这里不逐一展开,因为用法很直接"。源码中该族相当完整(Zend/zend_string.h#L424-L520),值得补充的要点:

  • zend_string_equals(s1, s2) 先做指针相等判断 s1 == s2,再比较长度与内容;
  • _literal 宏(如 zend_string_equals_literal(str, "foo"))用 sizeof(literal) - 1 免去 strlen;
  • 除了 equals 和 starts_with,源码还提供 zend_string_ends_with_ci 变体;
  • 在 x86/x86-64 上 zend_string_equal_val内联汇编实现Zend/zend_string.c#L460-L497),以 8 字节为步长 movq + xorq 比较并处理尾部对齐;x86 还有 4 字节版。x86-64 版本带 zend_never_inline NOIPA 属性并附 Valgrind 符号替换(源码注释关联 GH-9068),避免插桩器误报。

驻留字符串(Interned Strings)

动机与机制

程序中大量字符串被反复使用——例如声明了 MyClass,每次引用类名都为 "MyClass" 新分配一份字符串是浪费的。为此 php-src 使用字符串驻留:一个保存已有驻留串的 HashTable;创建新的驻留串时先查表,命中就返回已有字符串的指针,未命中才分配并加入表中。文档给出的示例:

zend_string *str1 = zend_new_interned_string(
    ZSTR_INIT_LITERAL("MyClass", /* persistent */ false));

// In some other place entirely.
zend_string *str2 = zend_new_interned_string(
    ZSTR_INIT_LITERAL("MyClass", /* persistent */ false));

assert(ZSTR_IS_INTERNED(str1));
assert(ZSTR_IS_INTERNED(str2));
assert(str1 == str2);

驻留字符串不做引用计数,因为它们预期在整个请求乃至更长时间存活——这也正是前文所有 refcount API 中那些 ZSTR_IS_INTERNED 分支存在的根源。

源码中的两级驻留表:permanent 与 request

Zend/zend_string.c 把驻留表分为两级:

  • interned_strings_permanent:启动阶段驻留的字符串,所有线程共享,直到进程退出才释放(源码注释明确说明它是启动后只读的,zend_interned_string_ht_lookup 的 permanent 分支即依赖这一点);
  • CG(interned_strings):请求级驻留表,由 zend_interned_strings_activate 初始化、zend_interned_strings_deactivate 销毁(Zend/zend_string.c#L372-L380),其中的字符串在请求结束时释放。

zend_new_interned_string_request 的查找顺序(Zend/zend_string.c#L253-L291)印证了文档的"先查表、未命中再分配"描述:

  1. 若传入串本身已驻留,直接返回;
  2. 计算哈希 zend_string_hash_val(str)
  3. 先查 permanent 表(此时只读,命中则 release 传入串并返回常驻串);
  4. 再查请求级表,命中则同理返回;
  5. 都未命中:若传入串 RC > 1,先经 zend_init_string_for_interning 复制一份独立分配(保留哈希与可传递的 UTF-8 标志),再 zend_add_interned_string 加入请求表。

另有三个可替换的全局函数指针 zend_new_interned_stringzend_string_init_internedzend_string_init_existing_internedZend/zend_string.h#L34-L41),默认指向 permanent 实现,进入请求后切换到 request 实现。

GC 标志位:驻留串到底"长什么样"

从源码结构看,驻留/持久状态并非独立字段,而是编码在 gc.u.type_info 的标志位里(Zend/zend_types.h#L924-L929):

#define IS_STR_INTERNED             GC_IMMUTABLE  /* interned string */
#define IS_STR_PERSISTENT           GC_PERSISTENT /* allocated using malloc */
#define IS_STR_PERMANENT            (1<<8)        /* relives request boundary */
#define IS_STR_VALID_UTF8           (1<<9)        /* valid UTF-8 according to PCRE */
  • IS_STR_INTERNED 就是 GC_IMMUTABLE:驻留串被当作不可变值,zend_string_refcount/addref/delref 对它们统一返回 1、不做任何修改(Zend/zend_string.h#L164-L186),这与 reference-counting.rst 中"不可变引用计数类型"一节描述的 GC_IMMUTABLE 语义完全吻合;
  • IS_STR_VALID_UTF8 标记该字符串按 PCRE 标准是合法 UTF-8,且属于"拼接可传递属性"(ZSTR_COPYABLE_CONCAT_PROPERTIES):两个合法 UTF-8 串拼接后结果仍标记为合法 UTF-8(Zend/zend_string.h#L90-L112),避免重复校验。

OPcache 如何改变驻留字符串的行为

文档最后指出:在 OPcache 下,驻留更进一步——字符串在不同进程间共享。例如使用 8 个 worker 的 php-fpm 时,所有 worker 共享同一份驻留串缓冲。更微妙的是:启用 OPcache 后,请求期间实际上并不创建驻留串,而是推迟到脚本被持久化到共享内存时;因此 zend_new_interned_string 在 OPcache 启用时可能并不返回驻留串,"通常你不必为此担心"。

这一点在仓库中有完整的钩子证据:

  • 引擎侧提供了 zend_interned_strings_set_request_storage_handlerszend_interned_strings_switch_storageZend/zend_string.c#L382-L400),允许外部(即 OPcache)替换请求阶段三个驻留函数指针的实现,并在请求进入/离开时切换 permanent/request 存储;
  • OPcache 确实在 ext/opcache/ZendAccelerator.c 中调用 zend_interned_strings_set_request_storage_handlers 安装自己的共享内存版本,并在编译阶段通过 zend_interned_strings_switch_storage(1) 切换存储。

可以推断:OPcache 安装的 request handler 会把驻留操作写入进程间共享的缓存,从而让多个 worker 命中同一块内存中的字符串实例;而引擎默认的 request 实现则是每请求一张私有 HashTable。两条路径对调用方透明——这正是文档建议"通常你不必为此担心"的机制基础。

小结

zend_string 用一个带显式长度、惰性哈希和 struct hack 变长尾部的结构体,同时解决了 C 字符串求长度昂贵、无法包含 NUL、易越界三大痛点;ZSTR_EMPTY_ALLOC/ZSTR_CHAR/ZSTR_KNOWN 等零分配捷径与 zend_string_init_fast 热路径压缩了高频小串的开销;copy-on-write 的 separate/realloc 家族在保持值语义的同时最大化共享;两级驻留表加可替换的函数指针钩子,又让 OPcache 得以把驻留升级到进程间共享。对于阅读或编写 PHP 引擎及扩展代码,掌握 Zend/zend_string.h 中这几组宏与 Zend/zend_string.c 的驻留表逻辑,就等于掌握了 PHP 字符串世界的全部基础设施。

关键参考路径:

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