在 PHP 的日常开发中,字符串是最常用的数据类型之一。无论是处理用户输入、拼接 SQL 语句,还是进行模板渲染,字符串操作几乎无处不在。然而,在 PHP 7 之前,字符串的实现并不尽如人意。PHP 7 引入的 zend_string 结构,彻底改变了字符串在内存中的管理方式,通过引用计数、驻留机制和写时复制等策略,显著提升了性能。本文将深入 Zend 引擎内部,剖析 zend_string 的设计与优化。
一、从 zval 到 zend_string:为什么需要新结构
在 PHP 5 中,字符串以 char* 加长度存储在 zval 中,并通过 zend_string 的雏形结构管理。但 PHP 5 的字符串存在几个问题:
- 每次传递字符串都需要复制内存;
- 字符串长度需要额外维护;
- 缺乏统一的引用计数机制,导致大量不必要的内存分配。
PHP 7 重写了 zval 结构,将字符串的元信息集中到 zend_string 中,zval 仅保存一个指向 zend_string 的指针。这样,多个 zval 可以共享同一个字符串,减少内存占用和复制开销。
二、zend_string 的结构定义
zend_string 的定义位于 Zend/zend_string.h 中,简化后如下:
struct _zend_string {
zend_refcounted_h gc; // 引用计数及垃圾回收信息
zend_ulong h; // 字符串哈希值,用于数组键
size_t len; // 字符串长度
char val[1]; // 柔性数组,存储实际字符
};
几个关键点:
- gc:内嵌的
zend_refcounted_h包含引用计数refcount和类型信息。所有可被引用的 zend 数据结构(如数组、对象)都以此开头,便于统一管理。 - h:字符串的哈希值。在 PHP 中,字符串常作为数组键,缓存哈希值可以避免重复计算,提升数组操作性能。
- len:字符串长度,使得
strlen()成为 O(1) 操作。 - val:柔性数组,实际字符紧跟在结构体之后分配,一次内存分配即可完成。
这种设计将元数据与字符数据放在连续内存中,提高了缓存局部性。
三、引用计数与写时复制
zend_string 使用引用计数管理生命周期。当字符串被赋值给变量、作为参数传递或存入数组时,引用计数增加;当变量被销毁或重新赋值时,引用计数减少。当引用计数归零时,内存被释放。
但仅仅引用计数还不够。如果多个变量共享同一个字符串,其中一个变量尝试修改字符串,直接修改会影响其他变量。因此,PHP 采用写时复制(Copy-On-Write) 策略:
- 当尝试修改一个引用计数大于 1 的字符串时,引擎会先复制一份新的字符串,再修改副本。
- 如果引用计数为 1,则可以直接修改,无需复制。
这一机制在 zend_string 的修改函数中实现,例如 zend_string_extend 和 zend_string_realloc。写时复制保证了字符串的不可变性语义,同时避免了不必要的复制。
四、驻留机制(Interning)
在 PHP 中,许多字符串是重复出现的,比如函数名、类名、常量名、数组键等。如果每个字符串都独立分配内存,将造成大量浪费。为此,Zend 引擎引入了字符串驻留(Interning) 机制。
驻留的字符串被存储在一个全局哈希表中,称为驻留字符串缓冲区(Interned String Buffer)。当需要一个驻留字符串时,引擎先查找哈希表,如果已存在则直接返回指针,否则创建并加入哈希表。
驻留字符串具有以下特点:
- 不可变性:驻留字符串的引用计数被设置为特殊值(如
GC_IMMUTABLE),永远不会被释放,也不参与写时复制。 - 永久有效:在请求生命周期内始终存在,甚至可以跨请求持久化(如 opcache 中的字符串)。
- 哈希值预计算:驻留时计算哈希值并存储,后续使用无需重复计算。
哪些字符串会被驻留?主要包括:
- 编译时已知的字面量字符串;
- 函数名、类名、方法名;
- 数组键(在编译时确定的);
- 通过
zend_string_init_interned显式创建的字符串。
驻留机制大幅减少了内存占用,并加速了字符串比较和哈希查找。
五、性能优化细节
除了上述核心机制,zend_string 还包含多项性能优化:
1. 内联哈希值
如前所述,h 字段缓存了字符串的哈希值。在数组操作中,哈希值用于快速定位桶。对于驻留字符串,哈希值在创建时计算一次;对于普通字符串,首次用作数组键时计算并缓存。这避免了每次数组查找都重新计算哈希。
2. 长度前缀
len 字段使得字符串长度获取为 O(1),同时支持二进制安全。与 C 字符串不同,zend_string 不依赖 \0 终止符,可以存储任意二进制数据。
3. 内存分配优化
zend_string 使用 Zend 内存管理器(ZendMM)进行分配。ZendMM 针对小对象做了优化,减少了系统调用开销。此外,字符串的分配大小会按需对齐,提高内存利用率。
4. 字符串拼接优化
PHP 7 对字符串拼接做了专门优化。例如,concat_function 会检查操作数是否可重用,避免不必要的复制。对于 .= 操作,如果左操作数引用计数为 1,则直接扩展内存,否则创建新字符串。
5. 持久化字符串
在 opcache 中,驻留字符串可以被持久化到共享内存,跨请求复用。这进一步减少了每个请求的字符串创建开销,尤其对大型框架效果显著。
六、实战:观察 zend_string 行为
我们可以通过 debug_zval_refcount 或 Xdebug 观察引用计数变化。例如:
$a = "hello";
$b = $a;
xdebug_debug_zval('a'); // refcount=2
$b .= " world"; // 触发写时复制
xdebug_debug_zval('a'); // refcount=1
输出会显示 a 的引用计数从 2 变为 1,因为 $b 的修改触发了复制。这验证了写时复制机制。
对于驻留字符串,可以观察函数名等:
function test() {}
$ref = new ReflectionFunction('test');
// 函数名 'test' 是驻留字符串,refcount 为特殊值
七、总结
zend_string 是 PHP 7 性能提升的关键基石之一。通过引用计数、写时复制和驻留机制,它在内存占用和运行效率之间取得了良好平衡。理解其内部实现,有助于我们写出更高效的 PHP 代码,例如:
- 避免不必要的字符串复制,尽量使用引用传递;
- 利用驻留字符串特性,减少动态字符串创建;
- 在数组键中使用短字符串,降低哈希冲突。
Zend 引擎的优化远不止于此,但 zend_string 无疑是最值得深入研究的模块之一。希望本文能为你打开一扇窥探 PHP 内核的窗口。
未经允许不得转载:任鹏个人博客 » PHP 字符串内核实现:zend_string 的引用计数、驻留机制与性能优化

