Hermes 源码解析
深入 Meta 开源的 Hermes JavaScript 引擎 —— 从词法分析到字节码生成,从 SSA 中间表示到并发垃圾回收,逐层解析一个为移动端优化的 JS 运行时的设计哲学与工程实现。
一、什么是 Hermes
Hermes 是 Meta(前 Facebook)开源的 JavaScript 引擎,专门为 React Native 应用的快速启动而设计。与 V8 和 JavaScriptCore 不同,Hermes 不追求峰值执行性能,而是将优化重心放在 启动时间 和 内存占用 两个对移动端至关重要的指标上。
Hermes 的核心设计理念是提前编译(Ahead-of-Time Compilation):在应用构建阶段就将 JavaScript 编译为紧凑的字节码,运行时直接加载执行,跳过传统引擎在启动时的解析和编译开销。
React Native 应用在冷启动时需要加载和编译大量 JavaScript 代码。V8 的 JIT 编译器为长时运行的 Web 应用优化,但在移动端冷启动场景中反而成为瓶颈 —— 启动时花费大量时间在 JIT 编译上。Hermes 通过将编译前移到构建阶段,将这个成本从运行时完全消除。
从源码规模来看,Hermes 是一个中等规模的系统级项目:
| 模块 | 语言 | 核心目录 |
|---|---|---|
| 编译器(Parser → IR → Bytecode) | C++ | lib/Parser, lib/IR, lib/BCGen |
| 优化器 | C++ | lib/Optimizer |
| 虚拟机(Runtime + GC) | C++ | lib/VM |
| 标准库实现 | C++ / JS | lib/VM/JSLib, lib/InternalJavaScript |
| Android 集成 | Java / C++ | android/ |
| 调试支持 | C++ / Python | lldb/ |
二、整体架构
2.1 编译流水线
Hermes 的编译流水线是一条清晰的线性管道,每个阶段的输入输出都有明确定义:
2.2 与 V8/JSC 对比
理解 Hermes 的设计取舍,最好的方式是与 V8 和 JavaScriptCore(JSC)对比:
| 维度 | V8 | JavaScriptCore | Hermes |
|---|---|---|---|
| 编译策略 | 多层 JIT(Ignition → TurboFan) | 多层 JIT(LLInt → DFG → FTL) | AOT 编译 + 解释执行 |
| 优化时机 | 运行时热点检测 | 运行时热点检测 | 构建时静态优化 |
| 启动性能 | 中等(需要 JIT 预热) | 中等 | 极快(直接加载字节码) |
| 峰值性能 | 最高(TurboFan 机器码) | 高(FTL 机器码) | 中等(解释执行) |
| 内存占用 | 较高(JIT 代码缓存) | 中等 | 最低(紧凑字节码) |
| 目标场景 | 浏览器 / Node.js | Safari / iOS | React Native 移动端 |
| GC | Orinoco(并发标记 + 增量压缩) | Riptide(并发) | Hades(并发标记 + 自由链表) |
Hermes 的选择是一个清晰的工程权衡:放弃峰值性能换取启动速度和内存效率。对于 React Native 应用而言,这通常是正确的取舍 —— UI 框架的渲染瓶颈很少在 JS 执行速度上。
三、解析器
3.1 词法分析
Hermes 的词法分析器(Lexer)实现在 lib/Parser/JSLexer.cpp 中。它将 JavaScript 源码转换为 Token 流。
Lexer 面临的一个经典难题是 JavaScript 中 / 的歧义 —— 它既可以是除法运算符,也可以是正则表达式的开始。Hermes 通过 Parser 上下文来消歧:
// JavaScript 中的 / 歧义
let x = 10 / 2; // 除法
let re = /pattern/g; // 正则表达式
// Lexer 需要 Parser 的上下文才能正确判断
// 当期望的是表达式(如赋值号右侧),/ 开始正则
// 当期望的是运算符(如数字后面),/ 是除法
Lexer 还支持 Magic URL(特殊注释中的源映射 URL)和 Token 存储(用于工具支持)。
3.2 语法分析
Hermes 的 Parser 采用经典的递归下降 LL(1) 设计,实现在 lib/Parser/JSParserImpl.h 中。核心类是 JSParserImpl,它将 Token 流转换为 ESTree 格式的 AST。
Parser 的一个精巧设计是 Param 参数封装。JavaScript 的语法在不同上下文中有不同的行为(例如 in 在 for-in 循环中是关键字,在其他地方是运算符),Parser 使用位标志来高效传递这些上下文:
// Param 类使用位运算组合多个布尔参数
class Param {
unsigned flags_;
// 位标志示例:
// AllowIn = 1 << 0 // 是否允许 'in' 运算符
// AllowYield = 1 << 1 // 是否在生成器上下文中
// AllowAwait = 1 << 2 // 是否在 async 上下文中
// AllowReturn = 1 << 3 // 是否允许 return 语句
};
// 通过位运算组合和传递参数
Param paramIn = Param::AllowIn | Param::AllowYield;
parseExpression(paramIn);
Parser 支持多种 JavaScript 方言的扩展:
- Flow:Meta 自家的类型注解系统(
lib/Parser/JSParserImpl-flow.cpp) - JSX:React 的 XML 语法扩展(
lib/Parser/JSParserImpl-jsx.cpp) - TypeScript:微软的类型系统(
lib/Parser/JSParserImpl-ts.cpp)
产出的 AST 遵循 ESTree 规范,这是 JavaScript AST 的事实标准。
四、中间表示 (IR)
4.1 类型系统
Hermes 的 IR 采用 SSA(Static Single Assignment) 形式,每个变量只被赋值一次。这是现代编译器优化的基础。
IR 的类型系统定义在 include/hermes/IR/IR.h 中,包含 14 种基本类型:
// Hermes IR 类型系统(简化表示)
enum class Type : uint16_t {
Empty, // TDZ(Temporal Dead Zone)标记
Uninit, // 未初始化
Undefined, // JavaScript undefined
Null, // JavaScript null
Boolean, // true / false
String, // 字符串
Number, // IEEE 754 双精度浮点
BigInt, // 任意精度整数
Symbol, // ES6 Symbol
Environment, // 闭包环境
PrivateName, // 类私有字段名
FunctionCode, // 函数代码对象
Object, // JavaScript 对象
};
// 类型联合通过位掩码实现
// 例如 "string | number" 表示为:
Type stringOrNumber = Type::String | Type::Number;
位掩码类型联合的设计使得类型推断可以高效地进行集合运算(并集、交集、子集判断),这对优化器至关重要。
4.2 指令集
IR 指令定义在 include/hermes/IR/Instrs.def 中,涵盖了 JavaScript 语义的完整表达。指令分为几大类:
| 分类 | 代表指令 | 语义 |
|---|---|---|
| 控制流 | Branch, CondBranch, Switch, Return | 基本块间跳转和函数返回 |
| 栈操作 | AllocStack, LoadStack, StoreStack | 局部变量的栈分配和访问 |
| 帧操作 | LoadFrame, StoreFrame | 闭包捕获变量的访问 |
| 对象操作 | AllocObject, LoadProperty, StoreProperty | JavaScript 对象创建和属性访问 |
| 函数调用 | CallInst, CreateFunction, CallBuiltinInst | 函数创建、调用和内建函数优化 |
| 异常处理 | TryStartInst, TryEndInst, CatchInst, ThrowInst | try/catch/finally 语义 |
| 生成器 | CreateGenerator, SaveAndYield, ResumeGenerator | Generator 函数支持 |
| 类型转换 | AsNumberInst, AsInt32Inst, ToPropertyKeyInst | JavaScript 的隐式类型转换 |
| 浮点优化 | FUnaryMath, FBinaryMath, FCompare | 已证明为数值类型时的快速路径 |
IR 的良构约束(Well-formedness Constraints)是保证编译正确性的关键:
- 每个指令必须被其操作数支配(dominator 关系)
- 每个基本块必须以且仅以一条终止指令结束
- PHI 节点必须出现在基本块开头,且对每个前驱块都有对应条目
AllocStack指令只能出现在入口块中
IR 生成由 9 个专门化的 C++ 文件实现(lib/IRGen/ESTreeIRGen-*.cpp),按 JavaScript 语法类别分工:表达式、语句、函数、异常处理、类定义等。
五、优化器
5.1 优化策略
Hermes 的优化器包含 27 个优化 Pass,位于 lib/Optimizer/Scalar/ 目录下。Pass 管理器采用分层固定点迭代的架构:
// 优化 Pass 管理器的执行策略(概念性伪代码)
function optimizeModule(module: Module) {
// 模块级 Pass
resolveStaticRequire(module); // 静态 require 解析
// 函数级 Pass 循环
for (const func of module.functions) {
let changed = true;
let iterations = 0;
while (changed && iterations++ < MAX_ITERATIONS) {
changed = false;
changed |= typeInference(func); // 类型推断
changed |= simplifyMem2Reg(func); // 栈变量提升
changed |= scopeElimination(func); // 作用域消除
changed |= constantFolding(func); // 常量折叠
changed |= deadCodeElimination(func); // 死代码消除
changed |= simplifyCFG(func); // 控制流简化
changed |= codeMotion(func); // 循环不变量外提
changed |= inlining(func); // 函数内联
}
}
}
Hermes 优化器遵循三条设计原则(来自 doc/Optimizer.md):
- 单一职责:每个 Pass 只做一件事
- 确定性:相同的输入产生相同的输出,迭代顺序可预测
- 编译时效率:避免指数复杂度算法,保证编译速度
核心优化 Pass 详解:
TypeInference(类型推断)是最关键的 Pass。JavaScript 是动态类型语言,但通过静态分析可以推断出大量类型信息。当推断出一个变量确定是 Number 类型时,就可以用 FBinaryMath 替换通用的加法指令,跳过类型检查。
ScopeElimination(作用域消除)解决的是闭包变量的堆分配问题。JavaScript 中被闭包捕获的变量需要分配在堆上的 Environment 中。但如果分析表明一个变量虽然在嵌套函数中被引用,但该函数不会逃逸当前作用域,就可以将变量降级为栈变量。
Inlining(函数内联)将小函数的函数体直接嵌入调用点,消除函数调用开销。Hermes 的内联策略偏保守,只在确定内联收益大于代码膨胀成本时才执行。
5.2 React Native 特化
Hermes 的一些优化是专门针对 React Native 代码模式设计的:
- ResolveStaticRequire:静态解析
require()调用,将模块依赖在编译时确定 - ObjectMergeNewStores:合并连续的对象属性初始化(React 组件的 props 赋值模式)
- LowerBuiltinCalls:将标准库函数调用替换为优化的内建指令
六、字节码生成
6.1 字节码格式
Hermes ByteCode(HBC)是最终的执行格式。字节码生成器位于 lib/BCGen/HBC/,核心流程是寄存器分配 + 指令选择。
HBC 采用寄存器式字节码格式(而非 Java/.NET 的栈式)。操作数类型包括:
// HBC 操作数类型(来自 BytecodeList.def)
Reg8 // 8-bit 寄存器引用(最多 256 个虚拟寄存器)
Reg32 // 32-bit 寄存器引用(扩展模式)
UInt8 // 8-bit 无符号立即数
UInt16 // 16-bit 无符号立即数
UInt32 // 32-bit 无符号立即数
Addr8 // 8-bit 相对跳转偏移
Addr32 // 32-bit 相对跳转偏移
Imm32 // 32-bit 有符号立即数
Double // 64-bit IEEE 754 双精度浮点数
HBC 的一个独特设计是双变体指令:每条指令同时有标准版本和 Long 版本。标准版本使用紧凑的 8-bit 操作数,Long 版本使用 32-bit 操作数。大多数函数只需要标准版本,从而显著减小字节码体积。
// 示例:GetById 指令的两个变体
GetById reg8, reg8, uint8, uint16 // 标准版:4 字节
GetByIdLong reg8, reg8, uint16, uint32 // 长版本:8 字节
// 编译器在寄存器分配后决定使用哪个变体
// 如果所有操作数都在 8-bit 范围内,使用标准版
6.2 压缩策略
对移动端来说,字节码体积直接影响应用包大小和下载时间。Hermes 在多个层面进行压缩:
- 字符串池化:所有字符串常量去重后存入全局字符串表,字节码中只存引用
- Literal Buffer 去重:对象字面量和数组字面量的结构去重
- 紧凑编码:标准/长双变体,大部分代码使用 8-bit 操作数
- 字节码级窥孔优化:消除冗余的 Load/Store 序列
寄存器分配采用两种策略:
- 小函数(< 250 条指令):快速线性扫描分配
- 大函数:完整的图着色分配,内存限制 10MB
七、虚拟机
7.1 Runtime 架构
Runtime 是 Hermes 虚拟机的核心类,定义在 include/hermes/VM/Runtime.h 中。它是整个执行环境的总管:
// Runtime 的核心组件(概念性描述)
class Runtime {
GCBase *gc_; // 垃圾回收器(Hades 或 Malloc)
IdentifierTable ids_; // 字符串驻留表(属性名去重)
SymbolRegistry symbols_; // ES6 Symbol 注册表
// 预定义对象和原型链
PinnedHermesValue objectPrototype;
PinnedHermesValue arrayPrototype;
PinnedHermesValue functionPrototype;
// ...
// 调用栈管理
StackFrame *currentFrame_;
StackRuntime stack_;
// 属性访问缓存(Inline Cache)
PropertyCacheEntry propertyCache_[];
// 模块系统
vector<RuntimeModule*> runtimeModules_;
};
解释器的核心循环实现在 lib/VM/Interpreter.cpp 中,采用字节码线程派发(Threaded Dispatch)机制执行指令。
7.2 NaN-Boxing 编码
Hermes 使用 NaN-Boxing 技术将 JavaScript 值编码在 64 位中。这是一种利用 IEEE 754 浮点数 NaN 空间的巧妙编码:
Hermes 还支持 HV32 模式(32-bit 压缩指针),用于 Android 上节省内存。在 HV32 模式下,对象指针被压缩为 32 位偏移量(相对于堆基址),Double 值通过"装箱"存储在堆上。
压缩指针节省了内存,但每次访问 double 值都需要一次额外的堆解引用。这是一个经典的空间-时间权衡:Android 设备通常内存更紧张,所以 Hermes 默认在 Android 上使用 HV32。
八、垃圾回收
垃圾回收(GC)是 JavaScript 引擎中最复杂的子系统。Hermes 提供两种 GC 实现,核心是 Hades 并发回收器。
8.1 分代垃圾回收(GenGC)
GenGC 是 Hermes 的早期 GC 实现,基于分代假说:大多数对象在创建后很快变为垃圾。
- 年轻代(Young Generation, YG):固定 4MB 段,Bump-pointer 分配,频繁回收
- 老年代(Old Generation, OG):多段,标记-压缩算法,不频繁回收
GenGC 的问题是回收时需要 Stop-The-World 暂停 —— 整个 JavaScript 执行必须停止等待 GC 完成。在老年代回收时,暂停可能达到数十毫秒,导致明显的 UI 卡顿。
8.2 Hades 并发回收器
Hades(lib/VM/gcs/HadesGC.cpp)是 Hermes 的现代 GC,设计目标是最小化暂停时间。
Hades 的核心创新是将老年代的标记阶段移到后台线程并发执行。Mutator(执行 JavaScript 的线程)和 GC 线程通过锁和写屏障协调:
Hades 的几个关键设计决策:
自由链表分配(而非压缩):传统的标记-压缩 GC 需要移动对象来消除内存碎片,这需要更新所有指向被移动对象的引用 —— 在并发场景中极其复杂。Hades 选择了大小分隔的自由链表(size-segregated free list),按对象大小分桶,分配时直接从对应大小的桶中取空闲空间。这避免了对象移动,简化了并发控制。
SATB(Snapshot-At-The-Beginning)写屏障:并发标记最大的挑战是 Mutator 在标记过程中可能修改对象图。SATB 通过在标记开始时"快照"对象图状态,确保标记阶段看到的是一致的视图。
8.3 写屏障
写屏障是 GC 正确性的基石。每当 Mutator 修改一个堆对象的引用时,写屏障代码会被执行:
// 写屏障的概念性实现
void writeBarrier(GCCell *parent, HermesValue newValue) {
// 1. SATB 屏障:记录被覆盖的旧值
// 确保并发标记不会遗漏旧的引用目标
HermesValue oldValue = parent->slot;
if (gcPhase == MARKING && isPointer(oldValue)) {
markQueue.push(oldValue.getPointer());
}
// 2. 代际屏障:记录 OG → YG 的引用
// YG 回收时不需要扫描整个 OG
if (isInOldGen(parent) && isInYoungGen(newValue)) {
cardTable.mark(parent);
}
// 3. 实际写入
parent->slot = newValue;
}
Hermes 的 Card Table 是一个位图,每个位对应堆中 512 字节的区域。当 OG 对象引用了 YG 对象时,对应的 Card 被标记为"脏"。YG 回收时只需要扫描脏 Card 对应的区域,而不是整个 OG。
Hermes 在 C++ 层面设计了完整的 GC 安全编码模式。Handle<T> 是 GC 安全的指针包装器,确保 GC 移动对象时引用自动更新。PseudoHandle<T> 是临时的非安全持有,必须在下一次可能触发 GC 的操作前转换为 Handle。这些模式在 doc/GCSafeCoding.md 中详细文档化。
九、React Native 集成
Hermes 与 React Native 的集成通过 JSI(JavaScript Interface) 实现。JSI 是一个轻量级的 C++ 抽象层,定义了 JavaScript 引擎需要实现的最小接口:
// JSI 接口的核心抽象(简化)
class Runtime {
// 执行 JavaScript 代码
Value evaluateJavaScript(Buffer code, string sourceURL);
// 对象操作
Object createObject();
Value getProperty(Object, string name);
void setProperty(Object, string name, Value);
// 函数调用
Value call(Function, Value thisVal, Value[] args);
// 原生函数注册
Function createFunctionFromHostFunction(HostFunction);
};
JSI 的设计使得 React Native 可以无缝切换底层引擎(Hermes、V8、JSC),上层代码完全不需要修改。
TurboModules 是 React Native 新架构中的原生模块系统,它通过 JSI 直接调用 C++ 代码,避免了旧架构中 JSON Bridge 的序列化开销。Hermes 对 TurboModules 的支持是原生的 —— 两者共享同一个 JSI 层。
Hermes 的字节码版本与 React Native 版本严格锁定。这意味着 Hermes 编译器生成的字节码只能由同版本的 Runtime 执行 —— 版本不匹配会导致应用崩溃。这是一个务实的工程决策:通过放弃字节码向后兼容性,Hermes 可以在每个版本中自由优化字节码格式。
十、调试基础设施
Hermes 提供了完整的调试支持:
Source Map 支持(lib/SourceMap/):字节码到源码的映射。当应用崩溃时,堆栈追踪可以还原到原始 JavaScript 源码的行列号。Source Map 在构建时生成,不打包到最终应用中。
LLDB 调试扩展(lldb/):Hermes 为 LLDB 提供了 Python 扩展,支持在 C++ 调试器中直接检查 JavaScript 对象状态。这对引擎开发者调试 GC 问题和 Runtime 崩溃至关重要。
Chrome DevTools 集成:Hermes 实现了 Chrome DevTools Protocol(CDP),允许通过 Chrome 浏览器调试运行在手机上的 React Native 应用。支持断点、单步执行、变量检查、调用栈查看。
字节码中嵌入的调试信息包括:
- 每条指令到源码位置的映射(行号、列号)
- 局部变量名到寄存器的映射
- 作用域层次结构
- 异常处理区间表
十一、跨平台构建
Hermes 使用 CMake 作为构建系统,支持多个目标平台:
| 平台 | 架构 | 构建配置 |
|---|---|---|
| Android | arm64-v8a, armeabi-v7a, x86_64 | NDK + CMake toolchain |
| iOS | arm64, arm64 simulator | CocoaPods (hermes-engine.podspec) |
| macOS | x86_64, arm64 | CMake native |
| Linux | x86_64 | CMake native |
| Windows | x86_64 | MSVC + CMake |
| WebAssembly | wasm32 | Emscripten |
跨平台构建的关键挑战包括:
- 原子操作兼容性:Hades GC 的并发操作需要 64-bit 原子读写。在 32-bit ARM(armeabi-v7a)上不保证原子性,Hades 在这些平台降级为增量模式(在 Mutator 线程上分步执行标记)
- NaN-Boxing 变体:HV64(64-bit 平台默认)和 HV32(Android 上用于节省内存)的选择在构建时确定
- JIT 编译器:目前仅支持 arm64 平台的 JIT,其他平台使用解释器
十二、性能特征
Hermes 的性能优势主要体现在移动端场景:
启动时间:由于跳过了解析和编译阶段,Hermes 的应用启动时间通常比 V8/JSC 快 30-50%。在低端 Android 设备上,差异更加显著。
内存占用:紧凑的字节码格式(比源码小 50-75%)加上 HV32 压缩指针,使 Hermes 的内存占用通常比其他引擎低 20-30%。
GC 暂停:Hades 的并发标记将 GC 暂停时间从 GenGC 的数十毫秒降低到亚毫秒级。这对保持 60fps 的 UI 流畅度至关重要 —— 16ms 的帧预算中不能有长暂停。
但 Hermes 也有明显的性能弱点:
- 峰值计算性能:纯解释执行比 V8 的 TurboFan JIT 编译码慢 5-10 倍
- 正则表达式:Hermes 的 Regex 引擎不如 V8 的 Irregexp 优化
- eval() 和动态代码:AOT 架构对动态代码执行不友好
Hermes 的设计选择告诉我们一个重要的工程原则:不要优化错误的指标。对移动端应用来说,用户感知的"快"来自启动时间和 UI 响应性,而不是 benchmark 中的计算速度。Hermes 通过选择正确的优化目标,在实际用户体验上超越了峰值性能更高的引擎。
十三、总结与思考
Hermes 是一个教科书级的 "约束驱动设计" 案例。它的每一个架构决策都源于对目标场景(移动端 React Native)的深刻理解:
- AOT 而非 JIT:移动端不需要长时运行的 JIT 热身,但极其需要快速启动
- 字节码而非源码:移动端带宽和存储珍贵,紧凑格式比源码节省 50-75%
- 并发 GC 而非 STW:60fps UI 不容许毫秒级暂停,并发标记是唯一选择
- 自由链表而非压缩:避免对象移动简化了并发控制,碎片问题通过大小分桶缓解
- NaN-Boxing 双模式:HV64 追求速度,HV32 节省内存,平台决定
从源码角度来看,Hermes 最值得学习的工程实践包括:
- 清晰的编译管道:Parser → IR → Optimizer → BCGen,每一层的输入输出都有明确的接口定义
- 指令定义的宏驱动:
Instrs.def和BytecodeList.def通过 X-Macro 模式从单一定义生成所有相关代码 - GC-Safe 编码规范:
Handle/PseudoHandle/PinnedHermesValue的类型系统在编译时就能防止大部分 GC 相关 bug - 测试基础设施:Lit 集成测试 + GoogleTest 单元测试 + Test262 规范合规测试
- 文档与代码并行:
doc/目录中的设计文档与实现代码保持同步
入口一:lib/Parser/JSParserImpl.h —— 理解递归下降 Parser 如何处理 JavaScript 的复杂语法。
入口二:include/hermes/IR/Instrs.def —— 理解 IR 指令集的设计,以及如何通过宏展开生成代码。
入口三:lib/VM/gcs/HadesGC.cpp —— 理解并发 GC 的核心难题:如何在不停止世界的情况下安全回收内存。
入口四:include/hermes/VM/Runtime.h —— 理解 VM 的整体结构,所有子系统如何在 Runtime 中协作。