PHP 引擎的 zend_string 深度解析:结构布局、API 与字符串驻留机制
PHP 解释器(php-src)中所有字符串——变量值、数组键、类名、属性名——最终都运行在同一个 C 结构体 zend_string 之上。本文基于仓库文档 zend_string.rst 展开,结合 Zend/zend_string.h、Zend/zend_string.c 与 Zend/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_init 按 len 复制,而不是按 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_alloc(Zend/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_string,l = 长度,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_LITERAL 是 zend_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 参数决定字符串用 malloc(persistent == true,存活到进程结束)还是 PHP 自己的分配器 emalloc(persistent == false,每个请求结束后整体清空)来分配。文档特别强调:用完字符串必须调用 zend_string_release,否则内存泄漏;release 之后不得再访问其任何字段,因为如果你恰好是最后一个使用者,它可能已经被释放。
源码中的 zend_string_release(Zend/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.c 的 zend_interned_strings_init 统一创建(Zend/zend_string.c#L112-L148),全部是 permanent 驻留字符串。
ZSTR_KNOWN 的编号来自宏展开表 ZEND_KNOWN_STRINGS(Zend/zend_string.h#L658-L754),其中收录了引擎内部高频字符串,如 file、class、->、::、include_once、_SERVER、Traversable 以及 "8.0" 到 "8.6" 的版本号等。源码注释提醒:新增已知字符串时同步更新 build/gen_stub.php,因为该列表还被属性注册代码使用。
另有一个值得注意的快速路径 zend_string_init_fast(Zend/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_HASH 与 ZSTR_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_func(Zend/zend_string.h#L555-L654),即 DJBX33A(Daniel J. Bernstein 的 "times 33 with addition"):核心递推是 hash = hash * 33 + str[i],初始值 5381。源码注释引用了 Ralf S. Engelschall 的分析:常数 33 的乘法可以化简为"一次移位加一次加法",计算极快且分布良好。
实现上有几点值得注意:
- 按 8/4/2/1 字节分块展开:在 x86、x86-64 与 aarch64 上使用乘法版本(现代 CPU 上更优),其余平台用
((hash << 5) + hash)的移位版本循环展开 8 次再按尾部长度switch收尾; - aarch64 小端特化:一次
memcpy载入 8 字节 chunk,再用位段提取指令逐字节取出,减少访存次数; - 高位置 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就地改大小(并遗忘哈希);否则分配新串、memcpy前MIN(新长, 旧长) + 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)印证了文档的"先查表、未命中再分配"描述:
- 若传入串本身已驻留,直接返回;
- 计算哈希
zend_string_hash_val(str); - 先查 permanent 表(此时只读,命中则 release 传入串并返回常驻串);
- 再查请求级表,命中则同理返回;
- 都未命中:若传入串 RC > 1,先经
zend_init_string_for_interning复制一份独立分配(保留哈希与可传递的 UTF-8 标志),再zend_add_interned_string加入请求表。
另有三个可替换的全局函数指针 zend_new_interned_string、zend_string_init_interned、zend_string_init_existing_interned(Zend/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_handlers与zend_interned_strings_switch_storage(Zend/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 字符串世界的全部基础设施。
关键参考路径:
- 文档:docs/source/core/data-structures/zend_string.rst、docs/source/core/data-structures/reference-counting.rst
- API 与内联实现:Zend/zend_string.h
- 驻留表与哈希实现:Zend/zend_string.c
- 结构体与 GC 标志:Zend/zend_types.h
- OPcache 集成点:ext/opcache/ZendAccelerator.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 StartedRust0623
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