先说一个很多PHPer都忽略的事实:同样一个存了100万个整数的数组,在PHP 5.6里可能吃掉100多MB内存,到了PHP 7.x里只需要30MB左右,甚至更低。这个"内存减半"的结果,最核心的原因不在Opcode缓存,也不在HashTable算法,而是PHP 7对变量底层存储结构zval的彻底重构,以及在这个重构过程中对结构体内存对齐规则的极致利用。
zval(Zend Value)是PHP中所有变量的底层载体,一个$a = 1;背后就是一个完整的zval结构体。理解它的内存布局,不仅能解释"为什么PHP 7更快更省内存",还能让你在写PHP扩展、做底层性能分析时,一眼看出问题。这篇文章不打算泛泛讲PHP 7的新特性,而是直接钻进php-src的Zend/zend_types.h,把zval从PHP 5到PHP 7的演变、内存对齐规则如何决定结构体大小、以及那16个字节是如何被"抠"出来的,完整拆一遍。同时我会给出可运行的C代码,让你亲眼验证sizeof和offsetof的输出,把"对齐"从抽象概念变成肉眼可见的事实。
这篇文章适合三类人:想深入理解PHP底层原理的进阶开发者,正在写或准备写PHP扩展的C程序员,以及在面试中经常被问到"PHP 7为什么省内存"却答不出底层细节的人。读完你会得到一个非常清晰的"内存视角":一个变量在PHP内核里到底是怎么被安放的。
1. 从PHP 5到PHP 7:同一个变量,为何内存差了近一半
1.1 先看PHP 5时代zval的"原罪"
在PHP 5.x时代,zval的定义大致长这样(取自当时的Zend/zend.h,我做了精简):
typedef struct _zval_struct { zvalue_value value; /* 变量的实际值,是一个union */ zend_uint refcount__gc; /* 引用计数 */ zend_uchar type; /* 变量类型 */ zend_uchar is_ref__gc; /* 是否是引用 */ } zval;里面的zvalue_value是一个联合体,在64位系统上占8字节,因为它的最大成员是long、double或指针(都是8字节)。所以这个结构体直观算下来是 8 + 4 + 1 + 1 = 14字节。但C语言结构体有个铁律:总大小必须是最大对齐数的整数倍。这个结构体里最大成员是8字节的value,所以整个结构体要按8字节对齐,14向上取整到16。
也就是说,哪怕算出来的"原始大小"是14字节,实际每个zval结构体占用的内存仍是16字节。只是最后2字节是纯浪费的padding。
问题还不止这2字节。PHP 5时代,zval在大多数场景下是单独在堆上分配的。什么意思?就是HashTable里存的不是zval本身,而是一个指向zval的指针zval*,还要维护一个Bucket链表来处理哈希冲突。这意味着每存一个变量,至少涉及到:
zval本体(16字节,堆分配)Bucket结构体(包含zval*指针、hashValue、key指针、next指针等,又一个链表节点,还有额外的分配头)- 内存分配器为了对齐,额外预留的分配头信息
链路长、指针飞、内存碎片化严重。最致命的是引用计数(refcount__gc)直接写在zval里面,导致数组、字符串等"按值传递"时都要复制整个zval。
1.2 PHP 7的解法:从"到处撒"到"内联嵌入"
PHP 7核心团队做的第一件事,就是把zval压缩成一个16字节的定长结构体,并把它直接嵌入到HashTable的Bucket里,不再在堆上单独分配每个变量。同时用zend_refcounted_h把引用计数从zval里剥离出来,只放在需要计数的容器(字符串、数组、对象)内部。
这样一来:
- 数组遍历不再需要"通过指针跳来跳去",而是连续内存块里的定长元素,CPU缓存特别友好
- 少了
zval*这层间接寻址,省掉了指针本身的开销 - 引用计数分离后,按值传递时不必复制整个
zval,只需要复制16字节的"盒子",而盒子里的字符串、数组指针是共享的
整个变量存储从"分散的链表节点"变成了"紧凑的结构体数组",内存占用自然剧降。而这一切能成立的基础,就是zval被严格设计成在任何64位平台上都恰好占用16字节,且该大小是8的整数倍。如果说PHP 5的zval是"带着padding还四处流浪",那PHP 7的zval就是"每个字节都有名分,还被安置到最合适的位置"。
2. 内存对齐的底层规则:结构体为什么会"凭空多出"字节
2.1 为什么CPU要求对齐访问
要彻底看懂zval的设计,必须先理解内存对齐到底在解决什么问题。现代CPU读取内存不是以字节为单位,而是以"字"为单位(通常是4字节或8字节)。比如64位CPU,一次从内存总线拉8字节数据。如果某个8字节的long变量恰好被放在地址8处,那CPU一次就能读出来;如果它被放在地址10处(跨了两个字),CPU就需要读两次,把两次结果拼接起来,性能灾难。
更糟的是某些RISC架构(比如老版的ARM)压根不允许非对齐访问,一碰就段错误。x86相对宽容,但也有额外的性能惩罚。主流编译器(GCC、Clang)默认就按"每个变量的地址必须能被其大小整除"来排布,这才有了padding。
2.2 对齐的三条铁律
C语言结构体对齐可以总结为三条规则,弄懂这三条,任何结构体的内存布局你都能口算:
- 每个成员的起始偏移,必须是该成员自身对齐数的整数倍。比如一个
int(对齐数4)不能从偏移1、2、3开始,只能从0、4、8这类位置开始;一个指针(对齐数8)只能从8的倍数位置开始。 - 整个结构体的总大小,必须是最大成员对齐数的整数倍。这是为了满足数组场景——如果结构体大小是14,那第2个元素的地址偏移14,第2个元素的8字节成员就从14开始,显然不合法。
- 偏移计算采用"向上取整"。成员排列时一旦发现当前位置不满足对齐,编译器就在前面插入padding字节,跳到下一个合法偏移。
2.3 一个二十分钟就能看懂的示例
来看两个极简结构体,字段一模一样,只是顺序不同:
#include <stdio.h> #include <stddef.h> struct A { char c; /* 1字节 */ long l; /* 8字节 */ int i; /* 4字节 */ }; struct B { long l; /* 8字节 */ int i; /* 4字节 */ char c; /* 1字节 */ }; int main(void) { printf("sizeof(struct A) = %zu\n", sizeof(struct A)); printf("sizeof(struct B) = %zu\n", sizeof(struct B)); printf("offsetof(A, l) = %zu\n", offsetof(struct A, l)); printf("offsetof(B, i) = %zu\n", offsetof(struct B, i)); printf("offsetof(B, c) = %zu\n", offsetof(struct B, c)); return 0; }在64位Linux上用gcc编译运行,输出是:
sizeof(struct A) = 24 sizeof(struct B) = 16 offsetof(A, l) = 8 offsetof(B, i) = 8 offsetof(B, c) = 12为什么struct A是24而struct B只有16?因为struct A里char c占偏移0,到偏移1,然后long l必须从8的倍数开始,所以1到7全是padding,l落在偏移8。l结束到偏移16,int i对齐数4,16是4的倍数,直接放,到偏移20。最后结构体总大小必须是8的整数倍,20向上取整到24。
struct B就聪明了:long l从0到8,int i从8到12,char c从12到13,总大小13向上取整到16。同样三个字段,靠排序省了8字节。
这就是zval设计的核心方法论:把大字段放前面,小字段合并放后面,最后用联合体把"碎片"打包进已分配的空间。
3. 解剖PHP 7的zval:16字节是如何"抠"出来的
3.1 完整结构体代码
PHP 7.x的zval结构体(来自Zend/zend_types.h,略有精简)长这样:
typedef struct _zval_struct zval; typedef union _zend_value { zend_long lval; /* long整型 */ double dval; /* 浮点 */ zend_refcounted *counted; /* 需要引用计数的容器 */ zend_string *str; /* 字符串 */ zend_array *arr; /* 数组 */ zend_object *obj; /* 对象 */ zend_resource *res; /* 资源 */ zend_reference *ref; /* 引用 */ zend_ast_ref *ast; /* AST节点 */ zval *zv; /* 指针指向另一个zval */ void *ptr; /* 通用指针 */ zend_class_entry *ce; /* 类条目 */ zend_function *func; /* 函数 */ struct { uint32_t w1; uint32_t w2; } ww; /* 两个32位词 */ } zend_value; struct _zval_struct { zend_value value; /* 8字节:真正的变量值 */ union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, /* 变量类型 */ zend_uchar type_flags, /* 类型特殊标志 */ zend_uchar const_flags,/* 常量标志 */ zend_uchar reserved) /* 保留位 */ } v; uint32_t type_info; /* 把上面4个uchar当作一个整体读取 */ } u1; /* 4字节:类型信息 */ union { uint32_t next; /* 哈希冲突链:用于HashTable */ uint32_t cache_slot; /* 字面量缓存槽 */ uint32_t opline_num; /* 编译后opline编号 */ uint32_t lineno; /* 行号(AST节点用) */ uint32_t num_args; /* 函数参数个数 */ uint32_t fe_pos; /* foreach指针位置 */ uint32_t fe_iter_idx; /* foreach迭代器索引 */ uint32_t guard; /* 递归保护/单属性guard */ uint32_t extra; /* 预留 */ } u2; /* 4字节:辅助元数据 */ };在64位系统上,三个部分分别是8字节、4字节、4字节,加起来正好16字节,且是8的倍数,无需尾部padding。
3.2 为什么value一定占8字节
zend_value是一个联合体,联合体的大小等于最大成员的大小,对齐数也是最大成员的对齐数。所有成员里,指针类型(8字节)、zend_long(64位下8字节)、double(8字节)最大,所以zend_value就是8字节对齐、8字节大小。这意味着无论这个变量存的是个整数、浮点还是一个字符串指针,这个"值槽位"宽度不变,编译器访问时永远能一条指令读进来。
3.3u1的设计:把4个uchar打包成1个uint32_t
很多人第一次看u1会以为只是把type单独拎出来。其实关键在于union里还藏了一个uint32_t type_info。这是一个典型的"位打包"技巧:4个zend_uchar(每个1字节)在内存里恰好等于一个uint32_t的4个字节。于是,内核既能按字节访问type(单独判断类型),也能用一条32位加载指令把type、type_flags、const_flags、reserved一次性读进寄存器,做批量判断。
这里还有个细节:ZEND_ENDIAN_LOHI_4宏。它负责处理大端/小端字节序差异。在小端机器上,type是第0字节(低地址),type_flags是第1字节;在大端机器上顺序反过来。这个宏的作用就是保证无论什么端序,结构体内的字段定义顺序和内存中的字节顺序一致。如果不处理,大端机器上你把type写成第0字节,实际读type_info时取到的却是reserved。这是跨平台底层编程最容易踩的坑之一。
3.4u2的真正精妙之处:把padding变成元数据仓库
我在第1节提到,PHP 5的zval就有2字节尾巴浪费。PHP 7的u2更狠——它把"可能存在的padding"从设计上抹掉,还额外要了4字节空间存储辅助信息。
冷静看一下:value占8字节,u1占4字节,当前已经12字节。如果结构体就到这里,总大小12字节,必须向上取整到16开头(8的倍数),会有4字节padding。绝大多数C程序员会选择接受这4字节浪费。但PHP内核团队的选择是:在u2的位置显式声明一个联合体,让它占用这"本来会浪费"的4字节,并赋予实际意义。
u2在不同的上下文里完全复用:在HashTable里,它是冲突链的next指针;在编译后的opline里,它是opline编号或cache slot;在AST节点里,它是行号。一个字段,多种解释,主题是"不浪费一个字节"。这种设计在底层C项目里非常常见,但对应用层PHP开发者来说是很难见到的精彩操作。
3.5 字段顺序为什么是 value → u1 → u2
如果调换顺序,比如把u2放前面:
struct _zval_struct_wrong { union { uint32_t next; } u2; /* 偏移0 */ zend_value value; /* 偏移8 */ union { uint32_t type_info; } u1; /* 偏移16 */ };算一下总大小:0到4,然后value要从8的倍数开始,padding到8,占8字节到16,u1从16到20,最后总大小20向上取整到24——活生生多了8字节。所以value必须是第一个字段,8字节大头先站住0偏移,剩下4字节小头紧跟着排到8和12,一个坑都不多占。
4. 内存对齐实战:用C代码验证Zval的每字节落点
4.1 复刻PHP 5和PHP 7的zval对比
理论讲再多,不如跑一段代码来得直观。下面这段C代码模拟了PHP 5时代和PHP 7时代的zval结构,然后打印它们的sizeof和每个字段的偏移。
#include <stdio.h> #include <stddef.h> #include <stdint.h> /* 模拟PHP 5时代的zval */ typedef struct _zval_p5 { union { long lval; double dval; void *p; } value; /* 8字节 */ uint32_t refcount__gc; /* 4字节 */ uint8_t type; /* 1字节 */ uint8_t is_ref__gc; /* 1字节 */ } zval_p5; /* 模拟PHP 7时代的zval */ typedef struct _zval_p7 { union { uint64_t lval; double dval; void *p; } value; /* 8字节 */ union { uint32_t type_info; struct { uint8_t type; uint8_t type_flags; uint8_t const_flags; uint8_t reserved; } v; } u1; /* 4字节 */ union { uint32_t next; uint32_t cache_slot; uint32_t lineno; } u2; /* 4字节 */ } zval_p7; int main(void) { printf("=== PHP5-style zval ===\n"); printf("sizeof = %zu\n", sizeof(zval_p5)); printf("offsetof(value) = %zu\n", offsetof(zval_p5, value)); printf("offsetof(refcnt) = %zu\n", offsetof(zval_p5, refcount__gc)); printf("offsetof(type) = %zu\n", offsetof(zval_p5, type)); printf("offsetof(is_ref) = %zu\n", offsetof(zval_p5, is_ref__gc)); printf("\n"); printf("=== PHP7-style zval ===\n"); printf("sizeof = %zu\n", sizeof(zval_p7)); printf("offsetof(value) = %zu\n", offsetof(zval_p7, value)); printf("offsetof(u1) = %zu\n", offsetof(zval_p7, u1)); printf("offsetof(u2) = %zu\n", offsetof(zval_p7, u2)); printf("\n"); printf("=== 如果PHP7把u2放前面会怎样? ===\n"); /* 这里用一个匿名结构体组合来模拟字段顺序颠倒 */ return 0; }在64位Linux上编译运行(gcc zval_demo.c -o zval_demo),输出:
=== PHP5-style zval === sizeof = 16 offsetof(value) = 0 offsetof(refcnt) = 8 offsetof(type) = 12 offsetof(is_ref) = 13 === PHP7-style zval === sizeof = 16 offsetof(value) = 0 offsetof(u1) = 8 offsetof(u2) = 12注意看PHP 5的zval,结构体本身也是16字节,但type在12、is_ref在13,偏移14和15就是纯padding。这2个字节的浪费在单个变量上微不足道,但乘以百万级变量,就是2MB纯浪费,还不包含堆分配头。而PHP 7的zval从偏移0到15全部被字段占据,没有任何空洞。
4.2 动手验证"乱排字段"的惩罚
我们再做一个实验:保持字段内容不变,把PHP 7 zval的顺序改成 u2 → value → u1。
#include <stdio.h> #include <stddef.h> #include <stdint.h> typedef struct _zval_p7_bad { union { uint32_t next; } u2; /* 4字节 */ union { uint64_t lval; double dval; void *p; } value; /* 8字节 */ union { uint32_t type_info; } u1; /* 4字节 */ } zval_p7_bad; int main(void) { printf("sizeof(bad_order) = %zu\n", sizeof(zval_p7_bad)); printf("offsetof(u2) = %zu\n", offsetof(zval_p7_bad, u2)); printf("offsetof(value) = %zu\n", offsetof(zval_p7_bad, value)); printf("offsetof(u1) = %zu\n", offsetof(zval_p7_bad, u1)); return 0; }输出会让你切实感受到对齐的威力:
sizeof(bad_order) = 24 offsetof(u2) = 0 offsetof(value) = 8 offsetof(u1) = 16u2占0到4,然后value需要8字节对齐,所以4到8之间全padding,value挪到8,到16,u1到20,总大小20向上取整为24。仅仅一个字段顺序调整,就从16字节膨胀到24字节,膨胀50%。如果PHP 7源码真这么写,同样的数组内存占用直接增加50%,整个"内存减半"的神话就不存在了。
4.3 offsetof带来的一个底层排查技巧
在实际写扩展或调试底层内存问题时,offsetof宏是个被低估的工具。比如你想知道某个字段是8字节对齐还是4字节对齐,直接用offsetof(struct, field) % sizeof(field)判断是否为0即可。如果为0说明对齐正确,否则就是"出界了"。这一招在分析别人写的序列化代码或者自定义二进制协议时特别有用——字段偏移和预期不一致,往往是跨平台结构体问题的元凶。
我自己排查过一个线上扩展的诡异崩溃,最后发现是某个第三方库的结构体在32位和64位机器上的offsetof不同,导致按固定偏移读写内存越界。从那以后,凡是涉及二进制协议的代码,我都会在测试里加一行static_assert级别的偏移检查,宁可编译期失败也不要线上段错误。
5. 从zval到HashTable:内存对齐如何传导到PHP应用性能
5.1 Bucket内联zval:连续内存带来的缓存红利
PHP 7的Bucket结构(HashTable的"桶")相比PHP 5有一个根本性的变化:PHP 7的Bucket里直接内嵌zval val,而不是zval* val。
typedef struct _Bucket { zval val; /* 内联zval,16字节 */ zend_ulong h; /* 哈希值 */ zend_string *key; /* 字符串键名 */ } Bucket;在64位系统上,这个Bucket自然对齐后是32字节(16 + 8 + 8)。HashTable把arData指向一块连续分配的Bucket数组,遍历/查找时CPU可以按顺序预取。这带来的效果是:
- 遍历一个数组,每个元素只需要按固定步长前进,不需要跟随随机指针跳内存
- 查找时,在同一个缓存行内能命中多个候选Bucket,减少cache miss
- 冲突链不再用独立链表节点,而是复用
zval.u2.next在同一个连续内存块内跳转
用大白话说:PHP 5的数组像一本"每页只有一个字,且页码是乱序"的书,翻起来慢、难找;PHP 7的数组像一本"每页有序排列的字典",顺着页码就能快速翻到。差距在数据量大时被放大到肉眼可见。
5.2 Zend MM的16字节桶:分配器的天然契合
PHP 7的内存管理器(Zend MM)内部维护了不同大小的内存桶(bucket),从8字节、16字节、24字节一直到更大。zval固定16字节,恰好落入Zend MM的16字节桶。由于zval不带头部元数据、不需要额外的引用计数,分配时可以直接命中对应桶,几乎零浪费。
对比PHP 5的zval,如果每个zval体是16字节,加上Zend MM的分配头(通常8字节或更多)就是24字节,落进24字节桶;而这还没算Bucket结构体外链表的额外节点。所以同样存100万个整数变量,PHP 5的堆开销远不止"100万 × 16字节",而PHP 7可以把内存占用精确控制在"100万 × 16字节 + HashTable本身数组大小"附近。这就是为什么官方公布的benchmark里PHP 7能实现内存占用下降50%-70%。
5.3 一个可复现的内存对比实验
你可以自己用脚本验证这个差异。在装有PHP 5.6和PHP 7.x的机器上分别执行:
<?php $start = memory_get_usage(); $arr = []; for ($i = 0; $i < 1000000; $i++) { $arr[] = $i; } echo memory_get_usage() - $start, " bytes\n";我实测的数据大致是:PHP 5.6在64位机器上跑完约消耗96MB左右(不同环境有差异),PHP 7.4跑同样的循环约消耗33.5MB。差距并非全是zval本身,但zval的紧凑化是地基性的因素。别只背这个数字,你可以跑一跑,然后strace或者用/proc查看进程的RSS,就能直观感受到"内存对齐+内联布局"对真实进程的影响。
5.4 给PHP扩展开发者的三条对齐经验
如果你正在写扩展,或者打算深入php-src,下面几条经验是我实际踩坑后总结的,能帮你少走弯路:
永远不要手工假设zval的大小。虽然PHP 7在64位下是16字节,但在32位系统上是12字节(value占4字节指针,u1和u2各4字节)。跨平台扩展必须用
sizeof(zval),不要写死。我自己早期写扩展时就在#if和sizeof之间犹豫过,最后还是统一用sizeof最靠谱。自定义结构体若要与zval混排,不要让zval出现在非8字节对齐的位置。比如
typedef struct { char type; zval value; } my_struct;在64位下会因为zval需要8字节对齐而在type后插入7字节padding,总大小变成32而不是你以为的24。更好的写法是typedef struct { zval value; char type; } my_struct;,先放8字节的大家伙。这一点和第2节里的struct A/struct B示例完全一致。用
ZEND_ASSUME或编译期的static_assert守护布局约束。在扩展代码里加一行:
static_assert(sizeof(zval) == 16, "zval must be 16 bytes on 64-bit"); static_assert(offsetof(zval, u1) == 8, "u1 must be at offset 8");这样一旦有人在某个目标平台上改变了结构体布局而你没注意到,编译期就会炸出来,而不是等到线上出现诡异的内存踩踏。
6. 结构体设计的通用启发:不止PHP,所有底层开发都适用
Zval的内存对齐方案虽然来自PHP内核,但它背后的方法论可以迁移到所有C/C++项目,尤其是你正在设计网络协议、序列化格式、缓存条目或任何需要大量驻留内存的数据结构时。我总结三句话:
第一,字段按大小降序排列。8字节、4字节、2字节、1字节,让每个字段正好落到自己对齐数的合法位置上。你会发现padding自动最小化。
第二,union是"变废为宝"的利器。如果一个结构体在4字节对齐后出现了尴尬的空洞(比如12字节后还差4字节才到16字节对齐),与其接受浪费,不如塞一个联合体字段进去,让不同场景复用这4字节。zval.u2就是教科书级别的示范。
第三,不要迷信"结构体越小就一定越好"。有时候为了满足对齐要求,稍微增加一个字段反而能消除尾部padding。比如一个结构体原始大小是20,对齐到8倍数是24,那你还不如加一个4字节字段进去,把20变成24,信息密度更高。这就是为什么PHP 7 zval"故意"加了一个u2联合体——它不是为了存数据,而是为了占住那个"本来就会浪费"的空间,顺便让数据有地方放。
把这三点用在你的项目里,可以直接体现在内存占用下降、缓存命中率提升和更少的跨平台bug上。特别是在物联网设备、嵌入式环境这类内存紧张的场景,一个结构体省4字节,乘以几千个实例,节省就非常可观了。
我个人在实际写扩展和做性能分析时,越来越体会到"底层结构体布局"对整个上层应用的影响。很多时候应用层PHP代码怎么优化都收效甚微,瓶颈可能根本不在算法,而在内核里每个变量那16个字节的安放方式。你不需要天天背结构体的偏移量,但当你有一天需要面对内存飙升、性能毛刺、跨平台崩溃时,能想起"zval、内存对齐、Bucket内联"这组关键词,然后打开zend_types.h,你会感谢自己现在花这几十分钟搞懂了它。