引言
写 PHP 时我们面对的是语法糖、动态类型和自动内存管理,但真正跑起来的是 Zend 引擎:它把你的源码编译成一串操作码(opcode),交给 Zend 虚拟机(Zend VM)逐条执行,用 zval 结构表示每一个值,用引用计数加环检测管理内存。理解这条链路,才能回答「为什么这段代码慢」「OPcache 到底缓存了什么」「JIT 为什么对我没用」这类问题。
本文从编译流水线讲到 zval、opcode、OPcache 共享内存,再到 JIT 的两种编译策略,最后给出用 opcache_get_status()、VLD 扩展等工具观测引擎内部状态的方法。读完你会知道:哪些优化是「真的在减少 opcode」,哪些只是心理安慰。
前置阅读:性能调优:OPcache、JIT 与内存 、PHP 8 现代特性 。
目录
- 1. 从源码到操作码:编译流水线
- 2. zval:PHP 值的内存表示
- 3. 操作码与 Zend VM 执行模型
- 4. OPcache 的共享内存机制
- 5. 预加载:把编译成本提前到启动
- 6. JIT 原理与两种编译模式
- 7. 观测引擎内部状态
- 8. 优化决策清单
- 延伸阅读
1. 从源码到操作码:编译流水线
1.1 五个阶段
PHP 源码到执行要经过一条固定流水线:
index.php
│ ① 词法分析(lexer / re2c) → token 流
│ ② 语法分析(parser / bison) → 抽象语法树 AST
│ ③ 编译(zend_compile) → opcode 数组(op_array)
│ ④ 缓存(OPcache,可选) → 共享内存中的 op_array
▼ ⑤ 执行(Zend VM / JIT) → 结果
阶段 ①②③ 合称「编译期」,⑤ 是「执行期」。OPcache 缓存的是 ③ 的产物(op_array),而不是源码——所以它能省掉词法、语法、编译三段,但省不掉执行。
1.2 AST 可见性
PHP 8 起 ast 扩展可以 dump 出 AST,用于理解语法糖的展开:
<?php
// php -d extension=ast -r 'print_r(ast\parse_file("a.php", 100));'
$a = $b ?? 'default'; // 编译期展开为 ISSET_ISEMPTY_COALESCE 相关的 opcode
命名参数、match、构造器属性提升这些 PHP 8 特性在编译期就被改写成等价的低层结构,运行时没有额外开销。
1.3 编译期常量折叠
编译器还会做一部分**常量折叠(constant folding)**与死代码消除。写在代码里的纯常量表达式,编译期就算好,运行时只剩一个值:
const SECONDS_PER_DAY = 24 * 60 * 60; // 编译期折叠为 86400,无运行时乘法
$flags = 1 | 2 | 4; // 同样在编译期折叠为 7
但折叠只对「编译期可确定」的表达式生效:24 * 60 * 60 能折叠,$x * 60 * 60 不能。所以「把能提前算的常量提前算」不只是可读性问题,也直接减少 opcode。
// 反例:每次执行都要做三次乘法
$expires = time() + 7 * 24 * 60 * 60;
// 正例:常量先折叠,运行时只加一次
const WEEK = 7 * 24 * 60 * 60;
$expires = time() + WEEK;
记忆:编译流水线 = 词法 → 语法 → 编译 → (OPcache 缓存 op_array)→ 执行;OPcache 缓存的是编译产物,不缓存执行结果;纯常量表达式在编译期就被折叠。
2. zval:PHP 值的内存表示
2.1 结构
PHP 里每个值在底层都是一个 zval,本质是「类型标签 + 联合体 + 附加信息」:
typedef struct _zval_struct {
zend_value value; /* 联合体:long / double / zend_string* / zend_array* ... */
union {
struct {
uint8_t type; /* IS_NULL / IS_LONG / IS_STRING / IS_ARRAY ... */
uint8_t type_flags; /* 是否可被 intern、是否引用 */
} v;
uint32_t type_info;
} u1;
union { uint32_t next; uint32_t cache_slot; } u2;
} zval;
value 是一个 8 字节联合体,u1 存类型与标志位。zval 本身不持有字符串或数组的数据,只持有指向它们的指针。
2.2 引用计数与写时复制
字符串、数组、对象等复杂类型带 refcount,赋值时只增加计数、共享底层数据,直到某一方要修改才复制(Copy-On-Write):
$a = str_repeat('x', 1000); // refcount = 1
$b = $a; // 共享,refcount = 2,无内存复制
$b .= 'y'; // 写时复制:$b 分到新缓冲,$a 不受影响
这就是「PHP 数组传参很便宜」的原因——只要不写,就是共享。
| 类型 | 是否引用计数 | 写时复制 |
|---|---|---|
| null / bool / int / float | 否(直接内联) | 无意义 |
| string | 是 | 是 |
| array | 是 | 是 |
| object | 是(PHP 5 起按句柄) | 否(引用语义) |
| resource | 是 | 否 |
记忆:zval = 类型标签 + 8 字节值;复杂类型靠 refcount + 写时复制共享,赋值便宜、修改才复制。
3. 操作码与 Zend VM 执行模型
3.1 op_array 与 opcode
编译产物 op_array 是一组 opcode 指令序列,每条指令形如:
ASSIGN $a <- 1
ADD $tmp <- $a + 2
ECHO $tmp
RETURN 1
可以用 VLD 扩展把真实 opcode dump 出来:
php -d extension=vld -d vld.active=1 script.php
3.2 Zend VM 的调度循环
VM 核心是一个「取指令 → 分发 → 执行」的循环,PHP 8 之后用 computed goto 优化分发开销:
while (1) {
opcode = *opline; /* 取指令 */
switch (opcode->opcode) { /* 分发 */
case ZEND_ASSIGN: /* ... */ break;
case ZEND_ADD: /* ... */ break;
}
opline++;
}
函数调用、对象方法调用、动态属性访问是 opcode 里最贵的几类——它们涉及符号表查找、哈希查找、可能触发自动加载。
3.3 哪些写法会「多出 opcode」
| 写法 | opcode 影响 |
|---|---|
$arr['a']['b']['c'] | 多次 FETCH_DIM,链式哈希查找 |
字符串拼接 "a".$b."c" | 多次 CONCAT,每次可能分配 |
动态方法名 $obj->$m() | 运行时符号查找,无法缓存 |
| 循环内重复调用纯函数 | 每次 CALL,无内联(除非 JIT) |
把循环不变量提到循环外、把动态访问改成静态属性,都能实打实减少 opcode 数量。
记忆:op_array 是指令序列,VM 逐条执行;函数调用与动态访问最贵,减少 opcode 数量的重构才是真优化。
4. OPcache 的共享内存机制
4.1 缓存什么、存在哪
OPcache 把编译好的 op_array 存进 共享内存(shared memory),所有 FPM/worker 进程共享同一份。首次请求编译一次,后续请求直接从共享内存取,跳过编译。
opcache.enable=1
opcache.memory_consumption=256 ; 共享内存大小(MB)
opcache.max_accelerated_files=20000 ; 可缓存文件数上限
opcache.interned_strings_buffer=16 ; 驻留字符串池(MB)
opcache.validate_timestamps=1 ; 1=每次检查文件是否改动
opcache.revalidate_freq=2 ; 检查间隔(秒)
4.2 时间戳校验的取舍
validate_timestamps=1 时,每个请求都会 stat 源文件判断是否过期,这本身是系统调用开销。生产环境代码不变,可以关掉:
; 生产:代码通过部署流程更新,用 opcache_reset() 或重启 FPM 生效
opcache.validate_timestamps=0
关掉后每次部署必须显式刷新:
# 方式一:重启 FPM
systemctl reload php8.3-fpm
# 方式二:脚本调用
php -r 'opcache_reset();'
4.3 驻留字符串
编译期确定的字符串常量(类名、方法名、常量名)会被「驻留(intern)」到共享的字符串池,所有进程共享同一份内存,避免重复分配。这就是 opcache.interned_strings_buffer 的用途。
记忆:OPcache 把 op_array 存共享内存,全进程共享;生产关
validate_timestamps省 stat,但部署后必须opcache_reset或重启。
5. 预加载:把编译成本提前到启动
5.1 preload 机制
PHP 7.4 引入 opcache.preload:在 FPM/常驻进程启动时把指定脚本(及其依赖的类)全部编译并常驻,之后请求直接用,连「首次编译」都省了。
opcache.preload=/app/preload.php
opcache.preload_user=www-data
<?php
// preload.php:用 Composer 的 classmap 预热整个 vendor
$map = require __DIR__ . '/vendor/composer/autoload_classmap.php';
foreach ($map as $class => $file) {
if (str_contains($file, '/vendor/')) {
opcache_compile_file($file);
}
}
5.2 收益与代价
| 维度 | 影响 |
|---|---|
| 首请求延迟 | 显著下降(无编译) |
| 常驻内存 | 上升(预加载的类常驻) |
| 代码更新 | 必须重启进程才生效 |
| 适用 | 常驻运行时收益最大,FPM 次之 |
在 FrankenPHP/RoadRunner 这类常驻模式下,预加载只需一次,收益尤为明显。
记忆:preload 在进程启动时编译并常驻类,省掉首次编译;代价是内存占用与「必须重启才更新」。
6. JIT 原理与两种编译模式
6.1 JIT 编译的是什么
JIT(Just-In-Time)把热点 opcode 序列直接翻译成机器码,绕过 VM 的逐条分发。注意:JIT 优化的是「执行」阶段,不是编译阶段——它和 OPcache 解决的是不同问题。
opcache.jit=tracing ; 或 function
opcache.jit_buffer_size=128M ; JIT 机器码缓冲区
6.2 tracing vs function
| 模式 | 策略 | 适用 |
|---|---|---|
function | 整个函数编译为机器码 | 函数体简单、调用频繁 |
tracing | 追踪热循环,只编译热点路径 | 计算密集型、循环多 |
tracing 是默认推荐,它在运行中识别「被反复执行的循环」,只对这些热点生成机器码,命中率更高。
6.3 为什么 JIT 对 Web 应用常常无效
Web 请求大多是 I/O 密集:查数据库、调 API、渲染模板——真正 CPU 密集的计算很少,JIT 编译的热点根本不存在。所以:
- 计算密集(图像处理、加解密、数学运算、复杂解析):JIT 有 2~5 倍收益;
- 典型 CRUD Web 应用:JIT 收益接近 0,甚至因编译开销略降。
开启前务必用真实负载压测,别被「PHP 8 的 JIT 快 3 倍」这类脱离场景的数字误导。
记忆:JIT 只对热点 opcode 生成机器码;
tracing适合循环密集,Web CRUD 用不上,计算密集才值得开。
7. 观测引擎内部状态
7.1 opcache_get_status
<?php
$s = opcache_get_status(false);
printf("缓存命中: %.2f%%\n",
$s['opcache_statistics']['opcache_hit_rate']);
printf("已用内存: %.1f MB / %.1f MB\n",
$s['memory_usage']['used_memory'] / 1048576,
$s['memory_usage']['free_memory'] / 1048576 + $s['memory_usage']['used_memory'] / 1048576);
printf("缓存脚本数: %d / %d\n",
$s['opcache_statistics']['num_cached_scripts'],
$s['opcache_statistics']['max_cached_keys']);
printf("JIT 缓冲: %s\n",
($s['jit']['enabled'] ?? false) ? '启用' : '关闭');
关键指标:命中率应接近 100%;num_cached_scripts 逼近 max_accelerated_files 时说明该调大上限;oom_restarts 非零说明共享内存不足。
7.2 命令行速查
php -i | grep -i opcache # 查看当前配置
php -d opcache.enable_cli=1 -d extension=vld -d vld.active=1 script.php # dump opcode
7.3 常见误判
| 现象 | 真实原因 |
|---|---|
| 命中率低 | validate_timestamps=1 + 高频改文件 |
oom_restarts 增长 | 共享内存或文件数上限太小 |
| 开了 JIT 却没变快 | 负载是 I/O 密集,无热点 |
| 预加载后内存暴涨 | 预加载了过多无关类 |
记忆:命中率看
opcache_hit_rate、容量看num_cached_scripts、溢出看oom_restarts;指标先看再调参。
8. 优化决策清单
按「收益/成本」排序,逐项排查:
| 优先级 | 措施 | 收益 |
|---|---|---|
| 高 | 生产关 validate_timestamps | 省每请求 stat |
| 高 | 调大 max_accelerated_files 至覆盖全部文件 | 避免频繁淘汰 |
| 高 | 常驻运行时开 opcache.preload | 省首次编译 |
| 中 | 减少循环内函数调用与动态访问 | 减少 opcode |
| 中 | 用 tracing JIT 试压测 | 计算密集场景 2~5 倍 |
| 低 | 微观语法糖替换 | 通常无感 |
核心结论:先保证 OPcache 配置正确(命中率、容量、预加载),再考虑 JIT;绝大多数 Web 应用的瓶颈在 I/O 与数据库,而不在引擎。
记忆:优化顺序 = OPcache 配置 → 减少 opcode 重构 → JIT 压测验证;引擎优化只解决 CPU 瓶颈,解决不了 I/O 瓶颈。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。