Zend 引擎内部与 OPcache/JIT

Zend 引擎内部机制与 OPcache/JIT 实战:源码到字节码的编译流水线(词法/语法/AST/编译器)、zval 结构与引用计数、操作码(opcode)与 Zend VM 执行模型、OPcache 共享内存与驻留字符串、时间戳校验与预加载、JIT 的 tracing/function 两种模式与热循环判定、用 opcache_get_status 与 VLD 观测、性能决策清单。

引言

写 PHP 时我们面对的是语法糖、动态类型和自动内存管理,但真正跑起来的是 Zend 引擎:它把你的源码编译成一串操作码(opcode),交给 Zend 虚拟机(Zend VM)逐条执行,用 zval 结构表示每一个值,用引用计数加环检测管理内存。理解这条链路,才能回答「为什么这段代码慢」「OPcache 到底缓存了什么」「JIT 为什么对我没用」这类问题。

本文从编译流水线讲到 zval、opcode、OPcache 共享内存,再到 JIT 的两种编译策略,最后给出用 opcache_get_status()、VLD 扩展等工具观测引擎内部状态的方法。读完你会知道:哪些优化是「真的在减少 opcode」,哪些只是心理安慰。

前置阅读:性能调优:OPcache、JIT 与内存 、PHP 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 瓶颈。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 图像与文档处理
  2. 流封装与文件系统
  3. gRPC 与 Protobuf 服务