PHP 7 在内存管理上做的最核心的一件事,就是把 zval 结构给“砍”了一刀,直接从 PHP 5 时代的 24 字节压缩到了 16 字节。别小看这 8 个字节的差距,在动辄百万级变量的现代 Web 应用中,这能省下海量的内存。那么,这个“瘦身”魔术是怎么变的?
zval 从 24 字节压缩到 16 字节,靠的是结构重排和 union 复用
先说说 PHP 5 的问题。在 64 位系统下,PHP 5 的 zval 实际占用了 24 字节。罪魁祸首是它内部的 zvalue_value 联合体,里面最大成员(比如 zend_object_value)为了对齐,拉高了整个结构的尺寸。再加上独立的 refcount__gc 和 is_ref__gc 字段,冗余自然就大了。
PHP 7 的思路很直接:把这些字段“压扁”进两个 8 字节的块里,总共 16 字节。具体来说,就是拆成 value(8 字节联合体)和 u1+u2(共 8 字节)两个部分。关键动作有三个:
- 引用计数不再住在 zval 里了,而是下沉到具体的数据结构(比如
zend_string、zend_array)自己管。zval 只负责存个指针或者原生值,轻装上阵。 - 类型标识
type和type_flags合并进u1.v.type这一个字节里,用单字节就能区分IS_LONG、IS_STRING等十多种类型,不再需要额外字段。 value联合体里,去掉了 PHP 5 中冗余的str.len这类内嵌字段,字符串长度这种信息,交给zend_string结构自己去管理。
小类型直接存值,大类型只存指针,避免无谓内存分配
接下来看看第二个设计思路:能存直接值的,绝不多绕一层。PHP 7 的 zend_value 联合体决定了“什么该放进去,什么该甩出去”。整数、布尔、浮点这些“小个子”数据,直接塞进 8 字节的 value 空间里;而字符串、数组、对象这类“大块头”,zval 里只存一个指针(比如 zend_string *),真实数据另起内存块。
这意味着:
$a = 42→zval.value.lval直接存 42,零额外分配。$b = "hello"→zval.value.str指向一块独立的zend_string内存,里面包含len、h(hash 缓存)、val[]等。unset($b)后,zval类型变成IS_UNDEF,但背后的zend_string不立即释放——等 refcount 降为 0 才真回收。
栈上预分配 zval,减少高频 malloc/free 开销
PHP 5 里,每次创建变量都要调 MAKE_STD_ZVAL() 从堆上 malloc 一块内存,这在高频场景下开销巨大。PHP 7 改成了在函数栈帧里直接声明 zval val,或者批量预分配一组 zval 池(比如 VM 执行时的 CV 变量表)。这省掉了大量系统调用和堆管理碎片。
有几个实操细节值得注意:
- 扩展开发中写
zval val;是安全的,但若需长期持有(比如存在全局哈希表里),必须确保它指向的数据(如zend_string)有正确 refcount。 ZVAL_COPY()不是简单 memcpy,它会递增被拷贝对象的 refcount,并处理IS_REFERENCE等特殊标记。- 直接赋值
zval new_val = old_val;是浅拷贝,仅复制 16 字节结构,不碰背后数据——这是性能关键,也是引用逻辑的起点。
IS_UNDEF 和 IS_INDIRECT 类型让运行时更轻量
PHP 7 新增了两个“幽灵”类型:IS_UNDEF 表示“已 unset 但尚未清理的槽位”,IS_INDIRECT 用于间接引用(如全局符号表里的 CV 变量)。它们不携带实际数据,zval.value 字段完全闲置,却能避免内存重排或 bucket 删除开销。
典型场景:
- 数组
unset($arr['key'])后,对应 bucket 的zval.u1.v.type设为IS_UNDEF,后续foreach自动跳过,count($arr)也不计入。 - 函数参数绑定时,
IS_INDIRECTzval的value.zv指向真正变量,实现“变量的变量”语义,不用每次都查符号表。 - 这两种类型的存在,使得 hashtable 的 rehash 可以延迟执行,降低突发性内存抖动。
真正难啃的地方不在结构定义,而在 refcount 与类型标记的耦合时机——比如 ZVAL_DEREF() 何时触发解引用、zval_ptr_dtor() 怎么判断是否该释放底层数据。这些边界行为不看源码很容易踩空。