深入 zval:PHP 的类型系统与内存模型
前置知识
- ›第 1 篇:架构与请求生命周期
- ›C 语言的联合体、位域与指针运算
- ›引用计数与垃圾回收的基本原理
深入 zval:PHP 的类型系统与内存模型
在第 1 篇中,我们梳理了 php-src 的四层架构,并追踪了请求的完整生命周期。本篇将深入 Zend 引擎的底层,聚焦于在运行时表示每一个 PHP 值的数据结构。无论是阅读编译器、虚拟机、扩展,还是垃圾回收器的代码,你都会在几乎每一行中遇到 zval、zend_string、HashTable 和 zend_object。理解它们的内存布局、引用计数协议以及负责管理它们的分配器,是读懂本系列后续内容的必要基础。
PHP 类型系统设计背后的核心思路是:每个 PHP 值必须恰好占用 16 字节。这一对齐约束决定了其他一切设计。
zval:PHP 的通用值容器
_zval_struct 定义于 Zend/zend_types.h,由三个部分紧凑地打包在 16 字节中:
flowchart LR
subgraph zval["zval (16 bytes)"]
direction TB
subgraph value["zend_value (8 bytes)"]
V["lval: zend_long<br/>dval: double<br/>str: *zend_string<br/>arr: *zend_array<br/>obj: *zend_object<br/>ref: *zend_reference<br/>..."]
end
subgraph u1["u1 (4 bytes)"]
U1["type_info:<br/> type (uint8)<br/> type_flags (uint8)<br/> extra (uint16)"]
end
subgraph u2["u2 (4 bytes)"]
U2["next: uint32 (hash chain)<br/>cache_slot: uint32<br/>lineno: uint32<br/>num_args: uint32<br/>..."]
end
end
zend_value 联合体(8 字节) 存储实际的值。对于 IS_LONG、IS_DOUBLE 这类简单类型,值直接内联存储在 zval 中——整数或浮点数就放在这里,无需堆分配。对于复杂类型(IS_STRING、IS_ARRAY、IS_OBJECT),联合体中存放的是指向堆上分配结构体的指针。
u1 联合体(4 字节) 包含 type_info,将类型标签、类型标志和一个额外字段压缩在一起。type 字节是 IS_* 常量之一。type_flags 字节编码了两个信息:该值是否需要引用计数(IS_TYPE_REFCOUNTED),以及是否可以参与垃圾回收的循环检测(IS_TYPE_COLLECTABLE)。IS_LONG、IS_DOUBLE、IS_NULL、IS_TRUE、IS_FALSE 等简单类型不设置任何标志——它们的值是内联存储的,因此永远不需要引用计数。
u2 联合体(4 字节) 是最巧妙的设计。由于结构体本身需要 16 字节对齐,这 4 字节无论如何都会作为填充存在。引擎借此机会将其复用为"搭载"存储,在不同上下文中承载不同含义:当 zval 位于 HashTable 的桶中时,u2.next 保存冲突链指针;当它是 op_array 中的字面量时,u2.cache_slot 保存运行时缓存偏移量;当用于参数信息时,u2.num_args 存储参数数量。
提示:
u2的复用模式是 php-src 中反复出现的设计思路——与其浪费对齐填充字节,不如让引擎将这些空隙用于存储上下文相关的元数据。同样的做法也出现在zend_string、Bucket和zend_object中。
PHP 类型标签与类型系统
PHP 的类型系统由 Zend/zend_types.h 中的一组整数常量表示:
| 类型常量 | 值 | 引用计数? | 可参与 GC? | 值的位置 |
|---|---|---|---|---|
IS_UNDEF |
0 | 否 | 否 | 无(未初始化) |
IS_NULL |
1 | 否 | 否 | 无(不需要值) |
IS_FALSE |
2 | 否 | 否 | 无(类型即值) |
IS_TRUE |
3 | 否 | 否 | 无(类型即值) |
IS_LONG |
4 | 否 | 否 | 内联于 zend_value.lval |
IS_DOUBLE |
5 | 否 | 否 | 内联于 zend_value.dval |
IS_STRING |
6 | 是 | 否 | 指向 zend_string 的指针 |
IS_ARRAY |
7 | 是 | 是 | 指向 zend_array 的指针 |
IS_OBJECT |
8 | 是 | 是 | 指向 zend_object 的指针 |
IS_RESOURCE |
9 | 是 | 否 | 指向 zend_resource 的指针 |
IS_REFERENCE |
10 | 是 | 是 | 指向 zend_reference 的指针 |
注意 IS_FALSE 和 IS_TRUE 是两个独立的类型,而不是一个带值的 boolean 类型。这消除了一次分支判断:对 if ($x) 求值只是一次类型检查,而不是类型检查加值检查的组合。
zend_type 结构体(用于类型化属性和函数参数)是另一套机制,它将联合类型、交叉类型和可空类型编码为类型标签的位掩码与类条目指针的组合。我们将在第 3 篇介绍编译器时再次遇到它。
引用计数与写时复制
所有堆分配的类型共享一个公共头部:zend_refcounted_h,其中包含 32 位的 refcount 和编码了类型与 GC 标志的 type_info 字段。
在 PHP 中赋值($b = $a)时,引擎不会复制底层数据,而是将被指向结构体的引用计数加一。此后 $a 和 $b 同时指向同一个 zend_string、zend_array 或 zend_object。
flowchart TD
A["$a = 'hello'"] --> |"refcount=1"| STR["zend_string<br/>'hello'"]
B["$b = $a"] --> |"refcount=2"| STR
C["$b .= ' world'"] --> |"refcount > 1?<br/>Yes → separate!"| SEP{{"Copy on Write"}}
SEP --> STR2["zend_string<br/>'hello world'<br/>refcount=1"]
SEP --> STR3["zend_string<br/>'hello'<br/>refcount=1"]
写时复制(COW)路径对性能至关重要。当引擎需要修改一个值时(例如向字符串追加内容、向数组推入元素),会先检查引用计数。如果 refcount > 1,则执行分离操作——复制一份副本,将原值的引用计数减一,然后修改副本。如果 refcount == 1,则直接原地修改。
PHP 引用($b = &$a)采用了不同的机制:zend_reference 包装器。zend_reference 结构体有自己的引用计数头部,内部还包含一个 zval。$a 和 $b 都会变成 IS_REFERENCE 类型的 zval,指向同一个 zend_reference,后者再持有真正的值。这引入了一层额外的间接寻址,但保留了非引用值的 COW 语义。
提示: 这正是 PHP 引用(
&$var)往往会降低性能而非提升性能的原因。它强制引入zend_reference间接层,并阻止写时复制优化的发挥。在现代 PHP 开发中,几乎没有必要使用引用。
zend_string:驻留字符串与持久字符串
zend_string 结构体(定义于 Zend/zend_types.h)不仅仅是一个带引用计数的字符缓冲区:
flowchart LR
subgraph zend_string
direction TB
RC["zend_refcounted_h (8 bytes)<br/>refcount + type_info + GC flags"]
HASH["h: zend_ulong (8 bytes)<br/>Cached hash value"]
LEN["len: size_t (8 bytes)<br/>String length"]
VAL["val[1]: char (flexible array)<br/>Actual string data, NUL-terminated"]
end
哈希值(h)只计算一次并缓存下来。由于字符串被频繁用作哈希键(变量名、函数名、数组键),这避免了每次查找时重复计算哈希的开销。Zend/zend_string.h 中的 ZSTR_H()、ZSTR_VAL() 和 ZSTR_LEN() 宏提供了对应的访问接口。
驻留字符串(interned strings) 是一类特殊存在。函数名、类名、源码中的字符串字面量等常用字符串会被"驻留"——在一个全局表中只存储一份,在整个请求期间不会被释放。它们的引用计数被设置为一个特殊值,使得引用计数的宏操作会跳过加减操作。OPcache 更进一步,将驻留字符串存入共享内存,使其可以在所有 FPM worker 进程之间共享。
持久字符串(persistent strings) 使用系统分配器(malloc)而非按请求分配器(emalloc)进行分配。它们跨请求存活,用于模块级别的数据——例如扩展名称、INI 配置项名称,以及在 MINIT 阶段注册的类名。
HashTable:双模式数组
PHP 的 array 类型是任何编程语言中最通用的数据结构之一——它同时承担列表、字典、有序映射、栈、队列和集合的角色,实现必须在所有这些使用场景下都保持高效。
Zend/zend_types.h 中的 zend_array(别名 HashTable)以两种截然不同的模式运作:
紧凑数组(packed arrays) 适用于键为从 0 开始的连续整数的情况(即常见的 $arr[] = value 模式)。数据以扁平的 zval 数组形式通过 arPacked 存储——无需哈希计算,无需桶结构,无需冲突链。其性能与 C 数组相当。
哈希数组(hash arrays) 适用于键为字符串或非连续整数的情况。数据存储为 Bucket 结构体数组(每个桶包含键、哈希值和 zval),并通过哈希索引表实现 O(1) 查找。
flowchart TD
subgraph packed["Packed Array Mode"]
direction LR
P_HT["HashTable header<br/>nTableSize, nNumUsed, nNumOfElements"]
P_DATA["arPacked: zval[]<br/>[0]: zval<br/>[1]: zval<br/>[2]: zval<br/>..."]
P_HT --> P_DATA
end
subgraph hash["Hash Array Mode"]
direction LR
H_HT["HashTable header<br/>nTableSize, nNumUsed, nNumOfElements"]
H_IDX["Hash Index Table<br/>(grows BACKWARDS from arData)<br/>[-1]: idx<br/>[-2]: idx<br/>..."]
H_DATA["arData: Bucket[]<br/>[0]: {h, key, val}<br/>[1]: {h, key, val}<br/>[2]: {h, key, val}<br/>..."]
H_HT --> H_DATA
H_IDX -.-> H_DATA
end
哈希数组的内存布局颇具创意。哈希索引表与桶数组共享同一块内存分配。桶数组(arData)从基指针向高地址增长,而哈希索引表则从同一基指针向低地址反向增长。也就是说,arData[-1]、arData[-2] 等是哈希索引槽,而 arData[0]、arData[1] 等是桶。一次 emalloc 调用同时分配两者,一次 efree 调用同时释放两者。
冲突解决采用链式法,通过 Bucket.val.u2.next 实现——正是 zval 中那个"免费"的 u2 字段。每个桶的 u2.next 指向冲突链中下一个桶的索引,-1(HT_INVALID_IDX)表示链表终止。
Zend/zend_hash.h 中定义的 HashTable 标志用于追踪数组状态:HASH_FLAG_PACKED 表示紧凑模式,HASH_FLAG_UNINITIALIZED 表示懒分配的表,HASH_FLAG_STATIC_KEYS 表示所有键均为驻留字符串。
zend_object 与类实例
在 PHP 中执行 new Foo() 时,引擎会分配一个 zend_object,其布局在编译阶段就根据类的声明确定:
classDiagram
class zend_object {
+zend_refcounted_h gc
+uint32_t handle
+zend_class_entry *ce
+zend_object_handlers *handlers
+HashTable *properties_table
+zval properties_table[]
}
class zend_class_entry {
+char type
+zend_string *name
+HashTable function_table
+HashTable properties_info
+int default_properties_count
+zval *default_properties_table
}
class zend_object_handlers {
+read_property()
+write_property()
+get_method()
+call_method()
+clone_obj()
+compare()
+cast_object()
+...26 more handlers
}
zend_object --> zend_class_entry : ce
zend_object --> zend_object_handlers : handlers
properties_table 是结构体末尾的弹性数组成员,槽位数量由 ce->default_properties_count 决定。已声明的属性会获得固定槽位(编译为数值偏移量),因此 $obj->name 是直接按索引访问,而不是哈希查找。在运行时动态添加的属性则会溢出到一个单独的 HashTable 中。
handlers 虚函数表(Zend/zend_object_handlers.h 中的 zend_object_handlers)是 PHP 对象系统支持运算符重载、属性访问拦截和自定义比较的核心机制。扩展可以替换其中的单个 handler 来定制对象行为——ArrayObject、SplFixedArray 和 PDO statement 对象正是通过这种方式实现的。
内存分配器:emalloc 及其家族
PHP 的按请求内存分配器定义于 Zend/zend_alloc.h,实现于 Zend/zend_alloc.c,是 PHP 高效处理基于请求的工作负载的关键所在。
分配器采用三级策略:
flowchart TD
REQ["emalloc(size)"] --> CHECK{"size category?"}
CHECK -->|"≤ 3072 bytes"| SMALL["Small allocation<br/>30 size-class bins<br/>Pre-allocated pages<br/>Free-list per bin"]
CHECK -->|"3073 – page size"| LARGE["Large allocation<br/>Page-aligned chunks<br/>Best-fit search"]
CHECK -->|"> page size"| HUGE["Huge allocation<br/>Direct mmap()<br/>Tracked separately"]
SHUTDOWN["Request Shutdown"] --> BULK["Bulk deallocation<br/>Free all chunks at once<br/>No per-object free needed"]
小分配(≤ 3072 字节,覆盖绝大多数分配场景)使用带有 30 个大小级别分箱的池分配器。每个分箱有若干预分配的内存页,划分为固定大小的槽位。分配操作为 O(1):从空闲链表弹出一个槽位;释放操作同样为 O(1):将槽位压回空闲链表。
大分配 在预分配的内存块中使用基于页的分配器。超大分配 则直接调用 mmap()。
真正的妙处在于请求结束时:引擎不需要逐一释放每一次分配,而是可以整块地批量释放内存。这将清理开销均摊到极低的水平,也意味着即使 PHP 代码"泄漏"了内存(没有显式 unset() 变量),按请求分配器也能在请求结束时全部回收。
其 API 与标准 C 分配器一一对应:emalloc()、efree()、erealloc()、ecalloc() 和 estrdup()。pemalloc(size, persistent) 变体则根据第二个参数在 emalloc(按请求)和 malloc(持久/跨请求)之间切换。
垃圾回收器:循环引用收集
引用计数处理了 PHP 中的大部分内存管理工作,但它有一个经典的弱点:循环引用。如果对象 A 引用对象 B,而 B 又引用 A,那么即使它们已经不可达,引用计数也永远不会降至零。
Zend/zend_gc.c 中的循环收集器(类型定义于 Zend/zend_gc.h)使用了 Bacon-Rajan 算法的变体:
- 收集根节点: 当一个带引用计数的值的 refcount 被减少但未降至零时,它成为潜在的循环根。引擎将其加入根缓冲区。
- 触发时机: 当根缓冲区填满(默认 10,000 个条目)时,GC 开始运行。
- 标记灰色: 从每个根出发,递归地对所有可达子节点的引用计数执行模拟减一。若某个根在这次模拟收集后引用计数降至零,则它可能是垃圾。
- 标记白色: 再次扫描——模拟引用计数为零的值标记为白色(垃圾);仍被循环外部引用的值恢复为黑色。
- 收集白色节点: 释放所有白色值。
只有带有 IS_TYPE_COLLECTABLE 标志的类型(数组和对象)才会被视为潜在的循环根。字符串和资源虽然也使用引用计数,但不可能形成循环。
提示: 可以通过
gc_status()监控 GC 活动。如果发现大量根节点被频繁收集,很可能是某个循环中存在创建循环引用的代码——可以考虑用WeakReference或显式unset()来打破循环。
下一步
至此,我们已经打好了基础:PHP 如何在内存中表示值、如何管理内存,以及如何回收内存。在第 3 篇中,我们将跟随一个 PHP 源文件走完整个编译流水线——从原始字符到 token 流,经过 AST,最终生成虚拟机将要执行的 opcode。本篇关于 zval 的知识将是不可或缺的前提:字面量会成为 op_array 中的 IS_CONST 类型 zval,每一个编译后的变量槽位也都持有一个 zval。