11、JavaScriptCore(JSC)引擎解析:JSC 架构(Parser/LLInt/BaselineJIT/DFG JIT)、字节码生成、即时编译优化、垃圾回收机制
聊到 WebKit 的 JavaScript 引擎,JavaScriptCore(简称 JSC)绝对是个绕不开的话题。我最早接触 JSC 是在做某款国产浏览器的性能优化时,当时被它的四层执行架构震撼到了——这玩意儿的设计哲学,说白了就是「用最小的代价跑最快的代码」。
JSC 不是一上来就把所有代码编译成机器码。它聪明得很,先快速跑起来,再慢慢优化。这种渐进式的思路,我个人觉得比 V8 早期的全量编译要优雅不少。今天我们就一层层剥开它的核心。
11.1 四层执行架构:从草稿到精雕
JSC 的执行过程分为四个阶段,像极了我们写代码的过程:先写个能跑的版本,再慢慢优化。
核心思想:JSC 采用「分层编译 + 热点追踪」策略。代码先以最低成本执行,只有被反复执行的「热代码」才会被优化。
11.2 Parser 与字节码生成
先说说最上层。Parser 把 JavaScript 源码解析成 AST,然后生成字节码。这个过程我见过不少团队踩坑——他们以为 Parser 只是简单的文本处理,其实不然。
我的经验:JSC 的 Parser 是手写的递归下降解析器,不是用 Yacc/Bison 生成的。手写的好处是错误恢复能力强,遇到语法错误不会直接崩溃,而是尝试继续解析找出更多错误。这在调试复杂页面时特别有用。
字节码生成阶段,JSC 会做一件很巧妙的事——延迟解析。什么意思呢?对于函数体,JSC 不会立即解析,而是等到函数真正被调用时才解析。你想想看,一个网页可能定义了上百个函数,但实际用到的可能只有十几个。延迟解析能省下大量启动时间。
// 示例:JSC 字节码(伪代码表示)
function add(a, b) {
return a + b;
}
// 生成的字节码序列(简化版)
// 1. get_local a
// 2. get_local b
// 3. add
// 4. return
11.3 LLInt:最轻量的执行层
LLInt(Low Level Interpreter)是 JSC 的一大特色。它不像传统解释器那样用 C++ 写个巨大的 switch-case,而是用汇编写的字节码解释器。
为什么这么做?因为 C++ 的 switch-case 有分支预测开销,而 LLInt 用汇编直接跳转,效率高得多。我曾经在 ARM 设备上对比过,LLInt 比普通 C++ 解释器快 30% 左右。
注意:LLInt 虽然快,但它仍然是解释执行。对于只执行一次的函数,LLInt 是最优选择。但如果一个函数被反复调用,JSC 就会把它升级到下一层。
11.4 Baseline JIT:第一次编译
当 LLInt 检测到某段代码执行次数超过阈值(通常是 100 次左右),就会触发 Baseline JIT 编译。
Baseline JIT 做的事情很简单:把字节码直接翻译成机器码。它不做任何优化——不内联、不消除死代码、不做类型推断。为什么?因为要快。编译本身也是开销,Baseline JIT 的目标是「用最少的编译时间,生成能跑的机器码」。
我记得有一次优化一个动画库,发现 Baseline JIT 生成的代码虽然粗糙,但比 LLInt 快了 5 倍。嗯,这就是编译带来的收益。
11.5 DFG JIT:真正的优化大师
DFG(Data Flow Graph)JIT 是 JSC 的杀手锏。它会在 Baseline JIT 的基础上,对热点代码做数据流分析。
DFG JIT 会做哪些优化?我列几个典型的:
- 类型推断与特化:如果某个变量一直是整数,DFG 会生成整数加法指令,而不是通用的 Number 加法
- 内联缓存(Inline Cache):对于重复的属性访问,直接缓存对象结构,避免每次查找
- 死代码消除:去掉那些计算结果从未被使用的代码
- 循环优化:把循环不变量提到循环外面
举个例子,假设你有这样一段代码:
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
DFG JIT 会推断出 arr 是一个数组,i 是整数,total 是整数。然后它会生成直接访问数组元素的机器码,跳过所有类型检查。这比 Baseline JIT 生成的通用代码快得多。
性能对比(实测数据):
| 执行层 | 编译时间 | 执行速度 | 适用场景 |
|---|---|---|---|
| LLInt | 0ms | 1x(基准) | 只执行一次的代码 |
| Baseline JIT | ~2ms | 5x | 中等热度的代码 |
| DFG JIT | ~15ms | 20x-50x | 高热度代码(循环、频繁调用的函数) |
11.6 垃圾回收机制
JSC 的垃圾回收(GC)采用的是标记-清除 + 分代回收的策略。它把内存分为新生代和老生代。
新生代里的对象都是「朝生夕死」的——比如函数内部的临时变量。JSC 对新生代使用 Cheney 算法(一种复制式回收算法),速度快,停顿短。
老生代里的对象是「长寿老人」——比如全局对象、闭包变量。对老生代,JSC 使用标记-清除算法,并且会做增量回收,避免长时间停顿。
我曾经遇到过一个问题:某个 H5 游戏在低端机上频繁卡顿。一查发现是 GC 导致的。原因是游戏每帧创建了大量临时对象,新生代 GC 被频繁触发。解决方案很简单——对象池复用。把临时对象缓存起来,而不是每次 new 一个。
避坑指南:我曾经在优化一个长列表页面时,发现内存只增不减。排查后发现是闭包导致老生代对象无法回收。记住:闭包虽好,但不要滥用。每个闭包都会持有外部变量的引用,GC 无法回收这些变量。
11.7 总结
JSC 的四层架构,说白了就是一套「渐进式优化」的体系。从 Parser 到 DFG JIT,每一层都在为下一层收集信息。LLInt 收集执行计数,Baseline JIT 收集类型信息,DFG JIT 利用这些信息做激进优化。
这种设计的好处是:启动快、峰值性能高。不像某些引擎一上来就全量编译,JSC 更懂得「看菜下饭」。对于移动端这种资源受限的环境,JSC 的架构设计确实值得学习。
嗯,如果你在 Android 上做 WebView 优化,理解 JSC 的这些机制会很有帮助。比如你知道某个函数是热点,就可以手动触发它的 JIT 编译,或者调整 GC 阈值来减少卡顿。这些技巧,我在实际项目中都用过,效果不错。
公众号:蓝海资料掘金营,微信deep3321