JavaScript · V8 引擎与 JIT

先解释后优化: 热点函数被编译成机器码, 代价是你必须"形状稳定" — 一旦食言, 去优化当头一棒

热点计数 更热 Deopt: 假设被打破 → 丢弃机器码, 回退字节码重新观察 源码 → AST 词法/语法解析 惰性解析: 函数体先不编 Ignition 解释器跑字节码 启动快 · 顺带收集类型 Sparkplug 基础编译层 几乎无优化的快译 Maglev 中层优化 反馈驱动的中量优化 TurboFan 顶层优化机器码 内联·逃逸分析·消除检查 执行流水线: 启动要快(解释), 跑得多的代码越烧越热, 引擎按热度逐级换上更激进的优化 Hidden Class (形状/Map): 对象的"类"是攒出来的 M0 { x } shape A .y=1 M1 { x, y } shape B .z=2 M2 { x, y, z } shape C 加属性的"顺序"就是形状: 一样顺序的对象共享一个 Map → 属性访问变成固定偏移量的读内存 delete / 乱序添加 / 大量动态键 → 退化为字典模式, 优化全部作废 同一构造器 new 出来的实例天然同形 —— 这就是"形状稳定"的全部含义 内联缓存 IC: 属性访问的"记忆" 单态 Mono 只见过一种形状 最快: 直接读偏移 多态 Poly (≤4) 2~4 种形状 查表, 稍慢 超多态 Mega 形状五花八门 通用慢路径 同一个函数, 传不同"形状"的对象进去, IC 记忆越来越杂 → 逐级降速 经典现场: 统一处理"草稿/已提交/已归档"三种订单对象的热函数 治理: 按形状拆函数 / 传入前归一化形状 / 用 Map 存动态键 // 形状稳定的写法 —— 引擎最爱 class Point { x = 0; y = 0; /* 字段全声明, 顺序固定 */ } const p = new Point(); p.x = 1; // 形状反复横跳的写法 —— 引擎的头号天敌 function bad(o) { return o.x + o.y; } bad({ x: 1, y: 2 }); // shape A bad({ y: 2, x: 1, z: 3 }); // shape B → C, IC 变多态再变超多态 // 一句话: 让同一函数处理同一种形状, 让同一构造器造所有对象 —— JIT 的地基在你手里

解释起步, 热点起飞

  • • Ignition 先解释, 启动快且省内存
  • • 热点函数逐级上 Sparkplug/Maglev/TurboFan
  • • 优化基于"运行时观察到的事实"
  • • 事实变了 = deopt, 退回解释器

形状决定速度

  • • 隐藏类(Map)按"加属性顺序"生成
  • • 同形状 → 属性访问=固定偏移读内存
  • • delete/乱序 → 字典模式, 优化作废
  • • 构造器统一定形是免费的最佳实践

别喂 IC 杂粮

  • • 单态最快, 超过 4 种形状进慢车道
  • • 热函数按形状拆分或归一化输入
  • • 数组同理: 元素类型/稀疏度要稳
  • • 微基准必须预热, 否则测的是解释器

💡 一句话理解

V8 像一家先出餐后精修的餐厅: 菜单一来, Ignition 解释器先把字节码端上桌(启动快); 某道菜被反复点单("热点"), 后厨开始观察它每次的"食材规格"(类型反馈), 观察够了就交给 TurboFan 按这份规格批量预制(机器码+内联), 快到飞起。但预制菜有个前提: 规格不能变 — 你下次突然传了个不同形状的对象, 预制作废,deopt 现场打回解释器重观察。

而"规格"在 JS 里有个正式名字: 隐藏类(Map)。对象不是按"类"定义的, 而是按加属性的顺序攒出形状; 形状相同的对象, 属性读取就是一次固定偏移的内存访问, 快如 C。所以 JS 性能优化的第一性原理只有一句: 让同一种形状出现在同一个热函数里。

🧠 必知必会 必考 & 必会

Ignition 解释器
先编译成字节码解释执行: 启动快、省内存; 执行的同时收集"类型反馈"(见过什么形状、什么类型)。
node --print-bytecode -e 'function f(a){return a+1} f(1)'
// 关键: 能看到 Add/Star 之类的字节码指令序列
分层编译
Sparkplug(快译无优化)→ Maglev(反馈驱动中优化)→ TurboFan(激进优化); 热度不够不升级, 平衡启动与峰值。
node --trace-opt -e 'function f(){}; for(let i=0;i<1e5;i++)f();'
// 关键: 能看到 [marking f for optimization] 时机
热点判定
按调用次数/循环回边计数; 小函数被高频调用更容易被内联进调用者一起优化。
for (let i = 0; i < 1e6; i++) sum += add(a, b);
// 关键: add 这种小函数大概率被内联, 函数调用成本≈0
隐藏类 Map
每个对象背后有形状描述(属性名+偏移+原型); 加属性=可能的形状迁移链, 同顺序即同形状。
const a = {}; a.x = 1; a.y = 2;   // M0 → M1 → M2
const b = {}; b.y = 2; b.x = 1;   // 另一条链, 不同形状!
属性访问与 IC
访问点记录见过的形状: 单态直接偏移读取; 多态查表; 超多态走通用路径, 慢一个量级。
%DebugPrint(obj);      # 需 --allow-natives-syntax
// 关键: 输出里能看到 Map 地址与 elements kind
deopt 触发
优化假设被打破(新形状/类型翻转/原型被改)时发生; 反复 opt→deopt 抖动的函数可能被弃优化。
node --trace-deopt app.js
// 关键: 看 [deoptimizing] 原因: wrong map / not a heap number
内联
被调函数体直接"抄"进调用点, 消除调用开销并为跨函数优化铺路; 小而单一职责的函数最易被内联。
const double = n => n * 2;   // 小函数
[1,2,3].map(double);          // 大概率整体优化成一段循环
逃逸与标量替换
对象若没"逃出"函数, 优化器可把它拆成几个局部变量, 连分配都省了。
function norm(x, y) {
  const p = { x, y };        // 没逃出去
  return Math.hypot(p.x, p.y); // p 可能根本不分配
}
Elements Kind
数组内部有 PACKED_SMI / PACKED_DOUBLE / PACKED_ELEMENTS 等形态, 混入字符串/洞会降级且难回退。
const a = [1, 2, 3];      // PACKED_SMI_ELEMENTS 最快
a[3] = 1.5;                // 降级到 DOUBLE
a[4] = 'x';                // 降级到 ELEMENTS(装箱)
字典模式
delete 或海量动态键让对象退化为哈希字典, 属性访问回到哈希查找, IC 失效。
delete obj.cache;   // 字典模式, 慢
obj.cache = undefined;  // 保持形状, 快
微基准要预热
JIT 有编译延迟, 未预热测的是解释器; 用 Warmup 循环 + 多轮取中位数, 注意不要写死代码被消除。
// 预热
for (let i = 0; i < 1e5; i++) fn();
// 计时多轮取中位, 且消费结果防死码消除
// console.log(total);  ← 消费, 防"没跑就被优化没"
别押引擎内部
优化是引擎私有权: V8 升级可能改判定; 代码应依赖"形状稳定/算法复杂度"这些可移植事实, 不追内部黑魔法。
// 错: 按 V8 某版内部行为硬编码优化
// 对: 稳定形状 + 正确算法 + 真实 profile 驱动

🏭 生产实战 real world

场景 1 · 消息处理函数慢 10 倍: 超多态现场急救

解析函数同时吃 MQTT/HTTP/WS 三种消息对象, IC 超多态。归一化入口形状后性能回升:

// 错: 三个来源对象形状各异, hot 函数 IC 超多态
handle(mqttMsg); handle(httpReq); handle(wsMsg);
// 对: 入口处先归一成同一种"内部消息"形状
handle(toInternal(mqttMsg));    // toInternal 统一 { type, body, ts }
handle(toInternal(httpReq));
// handle 里只见到一种形状 → 单态, 快回 8 倍

场景 2 · 动态字段改 Map: 字典模式的正确去处

埋点对象被动态塞 200 个随机键, 形状爆炸。动态键名走 Map, 固定字段留对象:

// 错: event['attr_' + key] = v;  无限形状
// 对: 固定骨架 + Map 装动态键
const event = { type, ts, attrs: new Map() };
event.attrs.set(key, v);   // Map 天生为动态键而生

场景 3 · 矩阵运算数组降级: SMI 装箱事故

热循环数组被塞进一个 undefined, PACKED_SMI 降级成 HOLEY_ELEMENTS, 每次读都装箱:

const mat = new Array(1e6).fill(0);  // SMI
// 事故: mat[flag ? i : -1] 越界写入 undefined
// 对: 边界检查, 或用 Float64Array 定型数组
const mat = new Float64Array(1e6);   // 无装箱, 无洞
mat[i] = v;   // 稳定 double 视图, 数值计算首选

定型数组(TypedArray)绕开 elements kind 演化, 数值热点稳定 3~5 倍。

场景 4 · 构造器字段全声明: 免费 30%

字段随业务慢慢挂, 不同路径造出的对象形状不同。构造器统一声明字段+顺序:

// 错: 有的实例先有 a, 有的先有 b
const u1 = { }; u1.a = 1; u1.b = 2;
const u2 = { }; u2.b = 2; u2.a = 1;   // 不同形状!
// 对: 构造器定形
class User { a = 0; b = 0; #extra = null; }  // 一条形状链

场景 5 · CPU profile 定位热点: 数据说话

凭感觉优化前先采样, 80% 的"优化"会打在没用的地方:

node --cpu-prof --cpu-prof-dir=./prof server.js
# 得到 .cpuprofile, 拖进 Chrome DevTools → Performance
# 关注: Self Time 排名 + 是否大量存在于 (garbage collector)
# GC 占比高 → 先看分配速率; 函数 Self 高 → 看形状与算法

场景 6 · 反复 opt→deopt 抖动: 抓 trace-deopt

某接口 P99 毛刺。trace 显示 hot 函数每分钟 deopt 两次 — 偶发的"空对象"打破假设:

node --trace-deopt --trace-opt app.js
// [marking optimize] → [deoptimizing: wrong map] 循环
// 根因: 偶发传 {}/null 进热函数
// 对: 入口卫语句提前 return, 让热路径形状单一
if (!msg || !msg.body) return skip;   // 冷路径隔离

场景 7 · 字符串拼接: V8 的 Rope 与 Cons string

现代 V8 对 a + b 用绳结字符串延迟物化, 循环拼接不再必然 O(n²)——但超大 JSON 构建仍建议数组 join:

// 一般拼接: 引擎 rope 优化, 直接 + 即可
let html = '<li>' + esc(name) + '</li>';
// 超大累计(几十万段): join 仍更稳
const parts = rows.map(r => render(r));
const html = parts.join('');

场景 8 · 小函数换性能: 让内联发生

大而全的 process() 内联不动。拆成小函数让 TurboFan 整体优化成一段:

// 错: 500 行巨函数, 复杂到内联与优化放弃
// 对: 拆职责单一的步骤, 热路径上的小函数会被内联
function process(m) { const v = validate(m); const n = enrich(v); return emit(n); }
// 注意: 别为"优化"手动内联牺牲可读性 — 引擎比你内联得更好

场景 9 · 微基准修成可信: bench 在测什么

团队基准显示"新代码快 40%", 上线无感 — 原来没预热且结果被死码消除:

function bench(fn) {
  for (let i = 0; i < 1e6; i++) fn();    // 预热触发 JIT
  const ts = [];
  for (let r = 0; r < 20; r++) {
    const t0 = performance.now();
    for (let i = 0; i < 1e6; i++) acc += fn();  // 消费结果
    ts.push(performance.now() - t0);
  }
  return ts.sort((a, b) => a - b)[10];   // 中位数抗毛刺
}

场景 10 · 用 Object.freeze 的代价评估

冻结配置对象防篡改, 但冻结后的属性访问走特殊路径, 超热路径需权衡:

const CFG = Object.freeze({ timeout: 3000, retries: 3 });
// 冻结对象读取通常没问题; 但把冻结对象当"模板"扩展属性会 deopt
// 建议: 冻结"配置"这类冷数据; 热路径的工作对象不要 freeze

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 热函数喂多形状 — 同一函数处理多种对象, IC 单态→超多态逐级变慢. 正解: 按形状拆函数或入口归一化。
// 错: render(a) / render(b) / render(c) 都进同一个 render
// 对: toInternal(x) 先归一, render 只见一种形状
坑 2 · delete 属性 — 对象退化字典模式, 热路径访问变哈希查找. 正解: 置 undefined 或 Map。
// 错: delete obj.tmp;
// 对: obj.tmp = undefined;
坑 3 · 属性添加顺序随机 — 同字段不同顺序=不同形状, 实例间无法共享 IC. 正解: 构造器统一定形。
// 错: {} 后按需 a.b / b.a 乱序加
// 对: class C { a = 0; b = 0; } new C()
坑 4 · 稀疏数组 — arr[1e7]=1 打出大量洞, 退化 dictionary elements. 正解: 连续索引或 Map。
// 错: const a = []; a[999999] = 1;
// 对: const m = new Map(); m.set(999999, 1);
坑 5 · 数组混装类型 — SMI→DOUBLE→ELEMENTS 只降不升, 读值开始装箱. 正解: 同质数组, 数值计算用 TypedArray。
[1, 2, 'x']   // ELEMENTS, 每次读小整数都装箱
[1, 2, 3]      // PACKED_SMI, 最快
坑 6 · 微基准没预热 — 测的是解释器+编译期, 数字全歪. 正解: 先跑上万次再计时, 多轮取中位。
// 错: 直接 performance.now() 包一层循环
// 对: 预热 1e5 次 → 计时 20 轮 → 中位数
坑 7 · 基准死码消除 — 计算结果没人用, 引擎把整段优化没了, 测出"0ms". 正解: 累加并输出/赋值给外部。
// 错: for(...) math(x);            // 结果被丢
// 对: sink += math(x); console.log(sink);
坑 8 · eval/with/动态 Function — 引擎放弃对该作用域的一切静态优化, 还拖累外层. 正解: 彻底不用, 动态逻辑用数据结构表达。
// 错: eval('obj.' + key);
// 对: obj[key] / map.get(key)
坑 9 · catch 的旧传说过时 — "try 里不能放代码"已是老黄历, 现代 V8 下 try/catch 几乎零成本, 但抛出本身很贵. 正解: try 放心用, 别拿异常当流程控制。
// 对: try 包热路径 ok; 但循环里靠 throw 结束=慢
// 对: 用返回值/哨兵表达"未找到", 别 throw
坑 10 · 优化后的代码被原型改动打回 — 运行中改 Object/Array 原型触发大面积 deopt. 正解: 原型只在初始化时动(polyfill 要检测)。
Array.prototype.last = ...   // 运行中改 → 全局 deopt 风暴
坑 11 · arguments 用法老套 — 泄漏 arguments 到外层曾造成优化失败; 现代 V8 大幅改善, 但新代码统一用 ...rest。
// 对: function f(...args) { }   // 现代写法, 无历史包袱
坑 12 · getter/setter 滥用 — 访问器路径内联更保守, 超热字段别包 getter。
// 错: get x() { return this._x; } 在 1e7 次/帧 的路径上
// 对: 直接字段访问; 访问器留给需要逻辑的字段
坑 13 · 对象当 Map 用装动态键 — 无限形状 + 原型键冲突风险. 正解: 动态键一律 Map。
// 错: cache[userKey] = v;   // key 是 'constructor' 就出事
// 对: cache = new Map(); cache.set(userKey, v);
坑 14 · 忽视 GC 在 profile 里的占比 — Self Time 明明是 GC, 却去优化业务函数. 正解: GC 占比高先查分配速率(每帧 new 大对象)。
// profile 里 (garbage collector) 占 30% → 优化方向是少分配
坑 15 · Number NaN 反复横跳 — 字段一会儿 number 一会儿 NaN/undefined, 优化器保守化处理。
// 错: speed = hasData ? v : undefined;  // 类型漂移
// 对: 统一 NaN 哨兵或 0, 保持 number 通道
坑 16 · 越界读写数组 — 负索引/超界写造成洞与降级. 正解: 边界检查, 环形索引用取模。
// 错: buf[i - 1] 当 i=0 时读 undefined
// 对: buf[(i - 1 + n) % n]
坑 17 · 大 JSON.parse 在热路径 — 一次性分配巨大中间对象, GC 压力+停顿. 正解: 流式解析/分页, 或下沉到 Worker。
// 错: const all = JSON.parse(big) 每请求都来一次
// 对: 按需解析字段 / ndjson 分行
坑 18 · 押注引擎内部行为 — 按 V8 某个版本的"黑魔法"写代码, 升级即碎. 正解: 依赖可移植事实(形状稳定/复杂度), 优化用 profile 验证。
// 错: 模仿某 polyfill 手动展开循环"帮引擎"
// 对: profile 显示瓶颈再动手
坑 19 · 循环内创建函数/闭包 — 每轮新函数对象+环境, 分配速率飙升. 正解: 提到循环外或事件委托。
// 错: for(...) els[i].onclick = () => h(i);
// 对: 委托 + dataset, 一个监听器
坑 20 · 忘了"先测量再优化" — 80% 的优化打在不热的地方, 白白增加形状约束. 正解: 流程固定: profile → 定位 Top1 → 改 → 前后基准对比。
// --cpu-prof → Self Time Top1 → 改 → benchstat 式对比 → 合并