普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月7日首页

Chrome偷藏了你的JS!V8引擎到底做了什么?

作者 牛奶
2026年4月7日 09:57

Chrome偷藏了你的JS!V8引擎到底做了什么?

你有没有想过:为什么 JavaScript 能"秒执行"?你写的 console.log('Hello') 到底经历了什么?从 Chrome 偷藏你的代码,到 V8 引擎对你的 JS 做了什么——今天全部揭秘!


原文地址

墨渊书肆/Chrome偷藏了你的JS!V8引擎到底做了什么?


V8 是什么?

JavaScript 引擎

浏览器能执行 JavaScript,全靠 JavaScript 引擎

常见的引擎有:

  • V8 — Chrome、Node.js、Deno 在用
  • SpiderMonkey — Firefox 在用
  • JavaScriptCore — Safari 在用
  • Chakra — 旧版 Edge 在用

V8 是 Google 开发的高性能引擎,用 C++ 编写,让 JS 执行速度可以媲美编译型语言。

V8 的工作流程

你写的 JS 代码,V8 要做的事情很简单:

JS代码 → 解析 → 编译 → 执行

但这中间,V8 做了大量偷跑优化

V8 架构演进

时代 架构 说明
早期 Full Codegen → Crankshaft 快速生成机器码,但维护困难
现在 Ignition → TurboFan 字节码+优化编译器,更高效
最新 Ignition + TurboFan + Sparkplug 新增无解释的 baseline JIT

代码是怎么跑起来的?

从 JS 到机器码

你写了一段代码:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2));

V8 拿到这段代码后,经历了这些阶段:

1. 解析(Parser)
   
   把JS代码变成 AST(抽象语法树)
   
2. 解释(Ignition)
   
   编译成字节码,立即执行
   
3. 优化编译(TurboFan)
   
   热代码被编译成高效的机器码
   
4. 执行

Ignition — 解释器

字节码是什么?

V8 首先用 Ignition 解释器处理代码。

Ignition 会把你的 JS 代码编译成字节码——一种中间代码,比机器码容易生成,但比 JS 容易执行。

// 你写的 JS
function add(a, b) {
  return a + b;
}

对应的字节码(简化版):

# 字节码类似这样
LdaSmi [1]      # 加载小整数 1
StaA [0]        # 存到 [0] 位置(寄存器)
LdaSmi [2]      # 加载小整数 2
AddA [0]        # 加上 [0] 位置的数
Return           # 返回结果

为什么要转字节码?

直接执行 JS 转字节码再执行
每次都要重新解析 字节码更紧凑
无法优化 可以记录执行信息
启动慢 启动更快

Ignition 不只解释执行,还会记录信息——哪些函数被调用多次、参数类型是什么。这些信息给后续优化用。

Ignition 的执行反馈

function add(a, b) {
  return a + b;
}

add(1, 2);      // 第1次:记录类型
add(3, 4);      // 第2次:类型一致,继续记录
add("x", "y");  // 第3次:类型变了!记录下来

Ignition 维护一个 Feedback Vector(反馈向量),记录每段代码的类型信息。


TurboFan — 优化编译器

JIT 是什么?

JIT(Just-In-Time)= 即时编译。

不是提前编译好,而是一边执行一边编译。执行次数多的代码,会被更高效的机器码替代。

TurboFan 优化流程

TurboFan 不是直接生成最优机器码,而是层层优化:

字节码 + 执行反馈
   
Sea of Nodes(中间表示)
    优化 Pass 1: 类型推导
    优化 Pass 2: 内联
    优化 Pass 3: 环路优化
    优化 Pass 4: 寄存器分配
   
机器码

热代码检测

V8 有一套"热点检测"机制:

function add(a, b) {
  return a + b;
}

// 这个函数被调用了10000次
for (let i = 0; i < 10000; i++) {
  add(1, 2);
}
调用次数 < 1000

Ignition 解释器执行(字节码)

调用次数 > 1000

TurboFan 优化编译(机器码)

优化与反优化

TurboFan 很聪明,但也有"翻车"的时候:

function add(a, b) {
  return a + b;
}

// 前1000次调用,参数都是整数
for (let i = 0; i < 1000; i++) {
  add(1, 2);  // TurboFan 优化:整数加法
}

// 第1001次,参数变成字符串
add("hello", "world");  // 反优化!退回字节码

TurboFan 发现类型变了,会反优化(Deoptimization),退回字节码。

常见的优化场景

// ✅ 好优化:类型稳定
function length(arr) {
  return arr.length;  // 数组 length 是稳定的
}
length([1, 2, 3]);
length([4, 5]);

// ❌ 难优化:类型不稳定
function getX(obj) {
  return obj.x;  // obj 可能是任意类型
}
getX({ x: 1 });
getX("string");  // 字符串没有 x 属性!

隐藏类 — 快速属性访问

对象属性查找

JS 里访问对象属性很快,这要归功于隐藏类(Hidden Class),也叫 ShapesMaps

const person = { name: 'Tom', age: 18 };

V8 内部会为这个对象创建一个隐藏类:

隐藏类 HC0
├── name: offset 0
└── age: offset 1

属性访问加速原理

当你访问 person.name 时:

// 幕后发生的事情
person.name
  → 通过隐藏类 HC0
  → 直接定位到 offset 0
  → 拿到值 "Tom"

就像图书馆的书有固定编号(隐藏类),管理员知道每本书在哪个书架第几格。

隐藏类转换

对象属性改变时,会产生新的隐藏类:

const obj = { x: 1 };
//   ↓ 添加 y
obj.y = 2;
//   ↓ 修改 x
obj.x = 10;
HC0: { x: 1 }
   添加 y 属性
HC1: { x: 1, y: 2 }
   修改 x 属性(值变化不改变结构)
HC1(不变)

属性顺序很重要!

// 好:属性顺序一致 → 共享同一个隐藏类
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// 差:属性顺序不一致 → 产生多个隐藏类
const p3 = { y: 1, x: 2 };  // 新建 HC1!

多态与全态

// 单态(Monomorphic):一种隐藏类,最快
function getX(obj) { return obj.x; }
getX({ x: 1 });      // HC0
getX({ x: 2 });      // 还是 HC0,命中缓存

// 多态(Polymorphic):2-4种隐藏类,较慢
function getX(obj) { return obj.x; }
getX({ x: 1, a: 0 });    // HC0
getX({ x: 2, b: 0 });    // HC1

// 全态(Megamorphic):5+种隐藏类,最慢
function getX(obj) { return obj.x; }
getX({ ... });  // 每次都是新结构

内联缓存 — 加速函数调用

函数调用有多慢?

函数调用看起来简单:

function getName(user) {
  return user.name;
}

const user = { name: 'Tom' };
getName(user);

但每次调用,V8 都要查找 user.name 在哪里。

内联缓存的原理

V8 第一次执行 getName(user) 时:

第1次调用:
1. 查找 user 的隐藏类  HC0
2. 查找 name 属性在 HC0 的位置  offset 0
3. 返回结果
4. 记录:HC0 的对象调用这个函数,返回 offset 0

之后调用同样的函数,直接跳过查找

第2次调用:
1. 检查隐藏类是 HC0 
2. 直接用记录的 offset 0
3. 返回结果

这就是内联缓存(Inline Cache)——把查找结果"缓存"起来。

IC 的类型状态

Uncached  Monomorphic  Polymorphic(2-4)  Megamorphic(5+)
                                           
 每次查     命中缓存       部分命中         全局查表

垃圾回收 — 内存管理

什么是垃圾?

程序里不再使用的对象就是"垃圾":

function createUser() {
  const user = { name: 'Tom' };
  return user.name;  // user 对象还在用
}  // 但 user 变量没了

createUser();
// 之后再也访问不到这个 { name: 'Tom' } 对象了
// 它就成了"垃圾"

V8 的内存布局

┌─────────────────────────────┐
          新生代                新对象
    (New Space / Semi-Space) 
├─────────────────────────────┤
          老生代                存活久的对象
    (Old Space)              
├─────────────────────────────┤
        大对象区                 无法放入其他区的对象
    (Large Object Space)    
├─────────────────────────────┤
        代码区                   JIT 编译后的机器码
    (Code Space)            
├─────────────────────────────┤
        Cell / Map              特殊对象
    (Cell / Map Space)       
└─────────────────────────────┘

V8内存布局图

V8 的垃圾回收策略

V8 采用分代回收

代际 对象来源 回收频率 算法
新生代 新创建的对象 频繁 Scavenge(复制)
老生代 经历一次 GC 仍存活 较少 Mark-Sweep-Compact

新生代:Scavenge 算法

新生代内存分两半:FromTo

┌─────────────────┬─────────────────┐
│      FromTo        │
│   (使用中)     │   (空闲)       │
└─────────────────┴─────────────────┘

1. From 满了,存活对象复制到 To
2. From 清空
3. FromTo 交换

晋升:经历两次 Scavenge 仍存活的对象,会进入老生代。

老生代:Mark-Sweep-Compact

步骤1:标记(Mark)

遍历所有根对象(全局变量、栈上变量)
    
标记能访问到的对象为"存活"
    
没被标记的就是垃圾

步骤2:清除(Sweep)

回收没有标记的对象的内存

步骤3:压缩(Compact)

存活对象移动到一起

解决内存碎片问题

增量 GC

为了避免长时间停顿(Stop-The-World),V8 使用增量标记:

传统 GC:
████████████████████████████  100% 停顿
     执行时间 ←────────────────→

增量 GC:
███    ████    ███    ██
                    
执行  执行  执行  执行

Orinoco — 并行与并发 GC

现代 V8 使用更先进的 GC 算法:

技术 说明 效果
并行 GC GC 多线程并行执行 充分利用多核 CPU
增量 GC GC 分多次小步执行 减少停顿时间
并发 GC GC 与 JS 执行同时进行 几乎无停顿

深入了解 V8 🔬

V8 执行流程全图

JS代码
    Parser
AST(抽象语法树)
    Ignition
字节码 + Feedback Vector(反馈向量)
    (热代码触发)
TurboFan
   
优化机器码
    (类型不稳定)
反优化  退回字节码

V8执行流程详图

为什么 V8 这么快?

优化手段 作用
JIT 即时编译 热代码用机器码执行
隐藏类 对象属性快速访问
内联缓存 函数调用加速
分代回收 高效内存管理
懒解析 延迟解析,只解析用到的
并行 GC 多核加速垃圾回收

Sparkplug — 无解释的 Baseline JIT

V8 最近引入了 Sparkplug,一个超快的 baseline JIT:

之前:JS  Ignition 字节码  TurboFan 机器码
现在:JS  Ignition 字节码  Sparkplug 机器码  TurboFan 优化机器码

Sparkplug 不做任何优化,直接把字节码转成机器码,比 Ignition 快 2-5 倍。

TurboFan 优化的代码例子

// 优化前:字节码执行
function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) {
    total += arr[i];
  }
  return total;
}

// 优化后:TurboFan 可能生成的机器码
// 1. 使用寄存器代替变量
// 2. 循环展开(Loop Unrolling)
// 3. 预取数据到 CPU 缓存

编写高性能 JS

// ✅ 好:保持属性类型一致
const p1 = { x: 1, y: 2 };
const p2 = { x: 3, y: 4 };

// ✅ 好:避免类型变化
function add(a, b) {
  return a + b;
}
add(1, 2);       // 都是整数
add(3.14, 2.86); // 都是浮点数

// ❌ 差:属性顺序不一致
const a = { x: 1, y: 2 };
const b = { y: 1, x: 2 };  // 新建隐藏类!

// ❌ 差:类型乱变
function example(x) {
  return x.value;  // x 可能是对象,可能是 undefined
}

// ✅ 好:使用固定形状的对象
const cache = {};
for (let i = 0; i < 1000; i++) {
  cache.key = i;  // 每次都用相同的 key
}

V8 性能陷阱

陷阱 说明 解决方案
隐藏类爆炸 对象结构不一致 保持属性顺序一致
类型不稳定 参数类型经常变化 使用多态函数时要小心
内存泄漏 闭包引用大量对象 及时解除引用
大对象 大数组、大对象放新生代 手动管理或拆分

总结

概念 作用 比喻
Ignition 解释器,生成字节码 + 记录反馈 同声传译先听懂意思
TurboFan 优化编译器,生成高效机器码 翻译稿润色升级
JIT 即时编译,热代码加速 多次练习后越说越溜
隐藏类 快速属性访问 图书馆编号系统
内联缓存 函数调用加速 记住常走的路
分代回收 高效内存管理 新书放前台,旧书放仓库
Sparkplug 超快 baseline JIT 不用练习,直接上岗

写在最后

现在你知道了:

  • V8 不是直接执行 JS,而是经过 Parser → Ignition → TurboFan
  • JIT 让热代码越来越快,但类型变化会导致反优化
  • 隐藏类和内联缓存,是 JS 快的秘密
  • 写代码时保持类型一致,能帮助 V8 优化
  • 新生代用复制算法,老生代用标记清除

下次有人说"JS 慢",你可以理直气壮地说:你了解 V8 吗?

❌
❌