二. Ignition解释器(下一)
1. 前文总结 和 运行期前置知识
这个系列文章,已经写了一少半了,现在终于到了动态执行阶段了。
我们首先需要梳理一下知识,这部分内容,相对独立,但是都算是比较重要的知识点。
-
预编译的说法为什么不建议使用
在我们平时看文章,看资料,甚至是看一些比较权威的文档时,预编译 这个术语非常常见。但是,在js中,预编译 是个伪术语,是一些教材教程在以前的js教学中,为了解释变量提升等一些问题,生造出来的一个词语,后来,只要是运行期以前的 甚至是在和运行期交织发生的一些动作流程,统统装进了 预编译 这个大口袋里。大部分人,也就不求甚解的接受并使用了这个说法。但是,这是一个不规范且容易引发歧义的词汇。在传统编译语言中,预处理、编译与执行通常有明确的时间边界;在现代 JavaScript 环境,这些阶段高度交织。规范(ECMAScript (ECMA-262))并不使用“预编译”一词,而是通过“执行上下文的创建阶段(creation / declaration instantiation)”来描述声明的注册与初始化。实际引擎(例如 V8)则采用惰性解析与按需编译:先做必要的解析与作用域分析,再由解释器生成字节码(如 Ignition)或在运行时将热点编译为机器码(由优化器完成)。
对于js,可以分为如下四个宏观的阶段:
词法分析:把源代码分成记号(tokens)。
语法分析(Parsing):构建抽象语法树(AST),确定静态作用域结构。
执行上下文创建阶段(Creation / Declaration Instantiation):为全局或每次函数调用登记标识符(函数声明整体被绑定;
var注册并初始化为undefined;let/const注册但处于 TDZ)。这一步决定了变量可见性和提升行为,但不等于把所有代码预先编译成机器码。执行阶段:逐条执行语句;遇到函数调用重复执行上一 步。现代引擎会在此阶段对运行行为收集反馈,并按需触发优化编译。
-
全局创建阶段和函数创建阶段的区别
无论是全局还是函数,在代码真正执行前都会经历“创建阶段”(进行变量和函数声明的提升),但两者有本质区别:
作用域范围:
-
全局阶段:影响整个程序,声明的变量和函数最终挂载到全局环境(浏览器中为
window)。 -
函数阶段:每调用一次函数,生成一个完全独立的执行上下文,仅对函数体内部有效,互不干扰。
变量遮蔽(shadowing):
- 在函数内部,如果存在与全局同名的变量,函数内的局部变量会“遮蔽”全局变量。即使全局变量在早期的全局阶段已经存在,函数内部在自己的创建阶段会优先登记局部标识符。
-
-
四个宏观的阶段
JavaScript 代码的完整生命周期分为以下四个阶段:
1. 词法分析(Lexical Analysis)
- 目的:将源代码字符串分解成一系列记号(Tokens)。
- 内容:识别关键字、标识符、操作符、数字、字符串、注释等最小语法单元。
2. 语法分析(Syntax Analysis / Parsing)
- 目的:将记号序列转换成抽象语法树(AST)。
- 内容:检查代码结构是否符合语法规则,构建反映代码静态结构的蓝图。
3. 执行上下文创建阶段(Creation/Instantiation Phase)
- 全局上下文创建:
- 创建全局对象(Global Object)。
- 扫描全局代码:将函数声明整体提升;将
var变量注册并初始化为undefined;将let/const注册,但置于“暂时性死区(TDZ)”。 - 建立全局词法环境,其外部引用为
null。 - 计算
this绑定。
- 函数上下文创建(每次调用时触发):
- 确定外部环境引用(Outer Environment Reference),构建作用域链。
- 创建局部词法环境,绑定形参与实参,创建
arguments对象。 - 扫描函数体,处理内部的变量和函数声明(规则同上)。
- 根据调用规则(普通调用、方法调用、
new调用等)计算并保存当前函数的this值。
4. 执行阶段
- 逐条执行语句,完成真实的赋值操作和表达式求值。遇到函数调用时,重复步骤 3。
- 主线程同步代码结束后,进入事件循环处理异步任务。无闭包引用的上下文将被垃圾回收。
-
静态结构AST和动态运行执行阶段的关系
这是理解 JS 闭包和作用域链最核心的关键。
1. 逻辑结构(AST 阶段:静态分析)
在语法分析结束后,AST 已经固化了代码的静态结构(Lexical Scope)。作用域的层级、变量的引用关系在这个阶段已经完全确定。
- 注意:AST 仅确定作用域链的结构蓝图,它不包含任何运行时值或内存绑定。这也是我们在第一部分解析篇,和AST部分中,反复说过无数遍的。
2. 物理实现(运行时阶段:动态绑定)
具体的词法环境实例(Lexical Environment)是在代码执行阶段动态创建的。
- 函数对象的创建:函数声明(FunctionDeclaration)通常在执行上下文的创建阶段就被绑定为可调用的函数对象,而函数表达式(FunctionExpression)则是在运行时执行到表达式处时才生成函数对象。
- 闭包的落地:虽然闭包的静态依赖关系可以从 AST 中推导出来,但真正的闭包(在堆内存中实际捕获并保存外部函数的词法环境)是在函数被执行并返回后,由运行时的执行上下文和作用域链动态构建的。
AST 阶段就像是建筑设计图,明确了房间的布局(作用域)和走廊的连接关系(静态作用域链)。而运行时相当于实际建造,根据设计图动态分配水泥建材(内存),并让住户(变量值)真正住进去。
闭包形成的动态实例:
JavaScript
function outer() {var a = 10;function inner() {console.log(a); // 引用了 outer 的变量 a}return inner; } var closureFunc = outer(); closureFunc();
-
语法分析阶段:AST 记录了标识符
a的引用关系,随后的作用域分析(Scope Analysis)会基于 AST 建立变量解析的静态链接。 -
执行
outer()时:创建新的执行上下文和词法环境(包含a)。inner函数被创建时,捕获当前词法环境并存入其[[Environment]]。 -
执行
closureFunc()时:inner执行,虽然outer的上下文已销毁,但inner通过自身的[[Environment]]依然保留着对outer词法环境的物理引用,真正的闭包在此刻发挥作用。
-
词法环境和作用域链
这两个概念非常容易混淆:
- 词法环境(单个节点):是一个存储变量和函数声明的具体环境。全局脚本开始、函数调用、进入块级作用域(
{})时都会实例化对应的词法环境。 - 作用域链(链式结构):是由多个词法环境通过
Outer Reference(外部引用)串联而成的查找路径。
如果把作用域链比作一面“墙”,那么每一个词法环境就是砌成这面墙的“砖块”。词法环境负责“存储变量”,作用域链负责提供“查找路径”。
这里需要特别注意,前面 尤其是解析篇中 我们反复强调了 蓝图 这个说法,在ast生成以后,作用域已经形成,这里要注意,是结构的形成,我们可以知道,某个变量可以到哪里寻找,但是,这只是蓝图 ,并不是实例的形成。 真正的可操作的作用域/链,是在执行阶段动态创建的。
- 词法环境(单个节点):是一个存储变量和函数声明的具体环境。全局脚本开始、函数调用、进入块级作用域(
-
执行上下文的模型
一、 执行上下文的抽象模型
在 ECMAScript 规范中,一个执行上下文(Execution Context)记录可以抽象为如下结构:
JavaScript
Execution Context Record = {LexicalEnvironment: {EnvironmentRecord: { ... }, // 当前词法环境中的绑定 (let/const/function/class)Outer: <reference to outer env> // 外部环境引用},VariableEnvironment: {EnvironmentRecord: { ... }, // 专门存储 var 声明的绑定Outer: <reference to outer env>},ThisBinding: <the value of this>, // 当前上下文的 this 值PrivateEnvironment: <optional record> // 用于类的私有字段(#private)}环境记录的类型与功能:
-
DeclarativeEnvironmentRecord(声明性环境记录): 用于存放命名绑定(
let、const、function等),并跟踪每个绑定的内部状态(如是否已初始化、是否可变)。let/const的 TDZ(暂时性死区)正是通过在绑定创建后、初始化前,将该绑定底层标记为“未初始化(uninitialized)”来实现的。 -
ObjectEnvironmentRecord(对象环境记录): 将一个普通对象包装成环境记录。典型场景是全局环境(将
globalThis作为绑定载体)或被废弃的with语句。它的查找是通过直接的对象属性访问来实现的。 -
FunctionEnvironmentRecord(函数环境记录): 声明性环境记录的特化版,专职负责管理函数的参数、
arguments对象,以及处理this、super的绑定状态。二、 词法环境和变量环境的区分
在函数初始执行时,LexicalEnvironment 和 VariableEnvironment 通常指向同一个环境记录实例。但规范特意将它们物理分离,是为了在“绑定创建阶段”区分不同声明的处理策略:
-
历史和兼容: 在 ES5 及之前,声明以函数作用域为准(
var)。ES6 引入了块级作用域(let/const)。规范通过VariableEnvironment负责var,LexicalEnvironment负责块级声明,完美实现了旧行为与新特性的并存。 -
var 的处理(变量环境):
var声明会在 VariableEnvironment 上被创建并立刻初始化为undefined。这就是为什么在声明前读取var变量会得到undefined(即“变量提升”)。 -
let/const 的处理(词法环境): 它们在 LexicalEnvironment 上被创建,但并不初始化。在实际执行到声明语句之前,访问这些绑定会触发 TDZ,抛出
ReferenceError。三、 上下文完整实例
我们通过一段经典代码,观察环境及闭包的情况:
JavaScript
console.log(foo); var foo = 10;function outer() {let a = 1;function inner() {console.log(a);}return inner;}const closureFunc = outer(); closureFunc();
1. 全局创建阶段
foo 注册到变量环境,初始为 undefined。outer 函数对象创建,其内部槽 [[Environment]](闭包的环境指针)指向当前的全局词法环境。
注意:ES6 后的全局环境是复合的,包含一个“全局声明性环境”(存 let/const)和一个“全局对象环境”(存 var 和全局函数,映射到 globalThis)。在 ES Modules 模式下,顶层绑定则由专属的 Module Environment Record 接管,不再使用 globalThis。
2. 执行全局代码
console.log(foo) 输出 undefined,因为 foo 的 var 绑定已在创建阶段完成初始化。随后 foo 赋值为 10。
3. 调用 outer() 并进入其创建阶段
注册局部变量 a(处于 TDZ)。创建 inner 函数对象,将其 [[Environment]] 指向 outer 的词法环境。随后执行赋值 a = 1(解除 TDZ),并返回 inner 函数。
注意:此时如果在 a = 1 之前尝试读取 a,会立刻触发 TDZ 报错。
4. 调用 closureFunc()(即 inner)
创建 inner 的执行上下文。在其自身的词法环境中找不到 a,顺着 [[Environment]] 构成的作用域链,向外查找到 outer 环境中的 a,输出 1。
闭包的真实情况:
inner 的 [[Environment]] 保存的是对 outer 词法环境的引用,而不是当时绑定值的快照!闭包捕获的是“绑定本身”。因此,如果 outer 后续修改了 a 的值,inner 再次执行时读取到的必然是最新的修改值。这也解释了为什么在 for 循环中使用 var 创建闭包,所有闭包会共享同一个循环变量绑定(最终输出相同的值),而使用 let 则会为每次迭代创建独立的绑定环境。
补充内容:
This 绑定(ThisBinding)
this 的值并非由执行上下文自动决定为某个固定值,而是严格由调用方式在运行时动态决定:
- 直接调用 (
fn()):非严格模式指向全局对象,严格模式为undefined。 - 方法调用 (
obj.method()):指向调用者对象(基值obj)。 - 显式绑定 (
call / apply / bind):由传入的第一个参数决定。 - 构造调用 (
new Fn()):指向内部新创建的实例对象。 - 箭头函数:没有自己的
this,它会穿透当前上下文,从创建时的外层词法环境中继承this(Lexical This)。因此箭头函数无法被new,也不能被bind改变指向。
私有环境(PrivateEnvironment)
这是规范专为支持类私有成员(如 #x)引入的机制。在类定义阶段,私有标识符会被登记到私有环境中。访问时,引擎只在当前类的私有环境中查找对应绑定。对外表现为:无法通过 obj['#x'] 访问,也不会出现在 Object.keys 的枚举中。
优化与性能
现代 JavaScript 引擎对闭包和作用域链有极强的优化(例如 V8 的逃逸分析),闭包本身并不总是天然低效。但需要注意,如果无意中让闭包捕获了大型外部数据结构(或庞大的 DOM 节点),会导致这些环境记录的生命周期被强行延长,阻碍垃圾回收,从而造成内存泄漏。因为闭包会让被捕获的外部绑定“活得更久”,所以在高性能场景需谨慎管理引用。
-
重要总结一
前面我们讲了,js中,预编译是个伪术语,尽量不要使用。 那么,除了使用规范中的术语,我们在工程实现中,可以使用 编译期 这个术语。
一段源码要想跑起来,只要经历了“词法分析 -> 语法分析 -> 生成 AST -> 生成某种中间代码(如字节码)”的过程,这个过程在计算机科学中就被标准的定义为“编译(Compilation)”。 既然 V8 引擎确确实实做了这些事情,那把它称为“编译期”是名正言顺的。
但是需要注意:一是 传统语言的“编译期”和“运行期”可能相隔很长的时间(开发者在电脑上编译好,发给用户运行)。而 JS 的“编译期”和“运行期”是首尾相连、紧密贴合的。引擎通常在接收到代码后,立刻进行编译,随后立刻交由解释器执行。二是 在现代 V8 引擎中,纯粹的“编译期”通常指 Ignition 将 AST 转换为字节码的过程。但在“运行期”中,TurboFan 编译器依然会在后台将热点字节码再次编译成机器码。所以 JS 的“编译”行为基本上是贯穿了运行的始终。
-
重要总结二
在前面我们讲了上下文 讲了词法环境 环境记录 等等概念,很多朋友肯定会有疑问:
这些所谓的上下文、环境记录,到底是完全虚构出来的抽象概念,还是在物理内存中真实存在的结构?
关于这个问题,或者说 关于类似的问题,我们需要从两个方面来看,一是规范 二是实现,而这种思考方式,是我们从开篇就一直贯彻使用的。
-
规范
前面列出的包含了
LexicalEnvironment、Outer引用的对象结构,还有环境记录,还有之前的let的for循环等等等等,实际上是 ECMAScript 规范定义的一种抽象机制(Abstract Mechanism)。 规范委员会(TC39)只负责制定语义上的“规则条文”:他们规定了代码跑起来后,变量查找必须遵循什么顺序、闭包必须保留什么数据,但规范绝不干涉引擎在内存中必须使用何种底层数据结构来实现这些规则。 -
实现
V8 引擎作为极致追求性能的“实现者”,通常不会在内存里一对一地去“照搬”或者
new出规范中描述的那种深层次嵌套的庞大对象。相反,它会使用栈帧(Stack Frame)、寄存器(Register)、堆上对象(Heap Object)等极其底层的机制,来“实现/模拟/达到语义要求”并提供相同的行为表现。
下面,我们从规范层和实现层来学习一下这几个概念
1. 执行上下文 (Execution Context) 和 全局执行上下文
- 【规范层:抽象级别 - 最高】
- 规范定义:一个用来跟踪代码执行进度的“抽象记录(Abstract Record)”或“容器”。规范赋予了它词法环境、变量环境、This绑定等语义属性,这是纯粹的“规则文本”。
- 【V8层:物理表现形式与载体】
- 函数上下文的物理表现:函数调用栈帧(Frame-like 结构)。
- 真实存在方式:当函数被调用时,V8 会在底层的调用栈(Call Stack)上开辟一块连续的内存空间(栈帧)。在 V8 内部,这对应着随着版本不断演进的 C++ 栈帧实现(如曾经的
StandardFrame、JavaScriptFrame等)。这块内存里压入了:返回地址、参数、接收者(this)、以及分配给局部变量的寄存器槽位。函数一return,栈帧出栈,其物理状态瞬间回收。 - 进阶(关于全局执行上下文):全局上下文的生命周期是跟随进程/页面的。它的物理实现并不是一个“永远压在栈底不弹出的常驻栈帧”。相反,全局相关的数据(全局对象 Global Object 与全局词法环境)通常常驻于堆内存(Heap)中。浏览器标签页存活时,这些堆结构就一直存在,依靠堆内存来维持全局语义。
2. 词法环境 (Lexical Environment) 和 变量环境 (Variable Environment)
- 【规范层:抽象级别 - 高】
- 规范定义:一种用来定义标识符和变量值映射关系的嵌套结构(包含环境记录与外部引用)。规范特意区分词法环境和变量环境,是为了在语义上兼容 ES6 块级作用域(
let/const)与老旧的函数级作用域(var)。
- 规范定义:一种用来定义标识符和变量值映射关系的嵌套结构(包含环境记录与外部引用)。规范特意区分词法环境和变量环境,是为了在语义上兼容 ES6 块级作用域(
- 【V8层:物理表现形式与载体】
- 物理表现:引擎根本不会去创建一个名叫
Environment的统一 C++ 对象。相反,V8 会对绑定进行极其精明的按需分流:- 非逃逸(局部)绑定:被直接编译为栈帧上的寄存器/栈槽,访问极快。
- 逃逸(闭包捕获)绑定:当绑定必须在当前栈帧销毁后继续存活时,才会被搬到堆内存的
Context结构中。
- 进阶(var 与 let/const 的精细差异):在底层物理分配时,虽然它们在函数内部都受“是否逃逸”规则的支配,但语义表现截然不同:全局的
var往往直接映射为全局对象的属性(Property Cell),而全局的let/const则属于声明式记录;且var没有 TDZ 标记。引擎通过不同的底层操作指令来严格区分这两种语义。
- 物理表现:引擎根本不会去创建一个名叫
3. 环境记录 (Environment Record)
这是反差最大的一个概念。在规范里它像个哈希表,但在 V8 底层,它被分化成了三种截然不同的物理形态:
- 形态A:完全虚无化(针对 Declarative ER 中的非逃逸变量)
- 物理载体:无独立运行时查找载体。化身为编译器分配的寄存器/栈槽。
- 解释:在编译/生成字节码时,引擎知道变量的固定位置,直接硬编码(如存入寄存器
r0)。执行时没有运行时的字符串查找,只有纯粹的内存/寄存器读写指令。
- 形态B:堆内存槽位(针对 Declarative ER 中的逃逸变量/闭包)
- 物理载体:V8 Heap(堆内存)中的
Context/Slot结构。 - 解释:这是一个类似
FixedArray(固定数组)或包含Cell引用的结构。闭包变量以固定的槽位索引(Slot Index)存储。访问时通过“基地址 + 偏移量”极速拿取,而非哈希查找。 - 进阶(惰性分配):V8 非常抠门内存。它不一定在 AST 解析完就立刻
new出这个堆数组。通常在运行时或编译阶段,借助强大的逃逸分析(Escape Analysis),引擎会尽量延迟甚至消除这种堆分配,只有在无可避免(真正创建闭包引用)时才在堆上开辟空间。
- 物理载体:V8 Heap(堆内存)中的
- 形态C:复杂的对象/字典结构(针对 Global ER / Object ER)
- 物理载体:全局对象(Global Object)或 Property Cell。
- 解释:因为全局对象(如
window)的属性可以被动态增删,无法提前确定数组大小,引擎通常使用更通用的字典结构或 Property Cell 来存放,这在语义上最接近传统的哈希表。
4. 外部环境引用 (Outer Reference) / 作用域链
- 【规范层:抽象级别 - 低】
- 规范定义:一个指向父级词法环境的引用指针。
- 【V8层:物理表现形式与载体】
- 物理表现:真实的 内存指针/引用。
- 真实存在方式:在上述堆内存的
Context结构中,会保留一个指向父Context的指针(通常位于特定的槽位中)。当当前上下文查找未命中时,引擎会沿着这些真实的物理指针,按索引继续向外层查找,从而在物理内存中串联起一条真正的作用域链(Scope Chain)。
5. 函数的内部插槽
[[Environment]]- 【规范层:抽象级别 - 低】
- 规范定义:函数对象身上的一个隐藏属性,保存创建该函数时的词法环境。
- 【V8层:物理表现形式与载体】
- 物理表现:C++ 对象内部的真实字段。
- 真实存在方式:在 V8 的实现中,函数对象(例如
JSFunction的实例)会包含一个专属的字段(在源码中常见的命名如context_)。这个字段保存着指向创建时词法环境(堆上的Context对象)的内存引用,这就是闭包能够“记住”外部环境的物理铁证。
6. TDZ (暂时性死区) 与 未初始化的物理实现
-
【规范层:抽象级别 - 逻辑态】
- 规范定义:
let/const绑定已创建但未初始化,此时访问将抛出ReferenceError。
- 规范定义:
-
【V8层:物理表现形式与载体】
- 物理表现:特殊的 内部哨兵值(Sentinel Value)。
- 真实存在方式:为了实现 TDZ 语义,V8 会在相应的内存槽位(寄存器或 Context 槽中)放置一个内部定义的哨兵标记(例如常被称为
the_hole的特殊 Tagged Value)。 - 运行机制:当引擎的指令尝试读取该内存时,如果发现读出的是这个特殊的哨兵值,就会立刻触发
ReferenceError。一旦代码执行到了真实的赋值语句,真实的数据就会覆盖掉这个哨兵值,TDZ 随之在物理层面上被解除。 - 这个会吹哨子的警卫,我们已经讲过无数次了。。。
-
在前面学习字节码生成的时候,我们使用了导演 场务 记录员 这个比喻,随着我们的学习深入,很有必要扩展一下我们的 片场宇宙 ,下面我们把片场宇宙的整体设定,以表格的形式固定下来,这个设定,应该足以支撑我们的后续学习了。而且 在记忆点,在准确性 等方面,也是挺合适的。 这是我的原创丫,保留版权。盗版会被追杀的。 嘿嘿嘿。。。
一、 基建与环境
| 片场比喻 | V8 底层实体 | 核心职责与表现 |
|---|---|---|
| 大老板 / 制片人 | Host Environment (宿主:Chrome/Node.js) | 掌握生杀大权。负责出资建厂,并在一切准备就绪后扣动 Execution::Call 扳机,下达全场开机指令。 |
| 独立制片厂 | Isolate | 进程内的独立工业园区。拥有专属土地和主线程。不能擅自串门,所有跨厂通信须通过宿主提供的 IPC 桥接机制(如 postMessage / embedder bridge),以保证隔离策略与安全边界。 |
| 拍摄场域 | Realm | 对应一套完整的全局内置对象体系。有助解释不同脚本/模块之间的原型链与全局隔离等高级语义(如 iframe 之间的差异)。大多数情况下,Realm(规范概念) = Context(V8 物理实现) |
| 逻辑摄影棚 | Context | 搭建在制片厂内的执行环境。提供基础道具(如当前的 window/global 实例)。同厂内可有多棚,互不串戏。 |
| 预制构件厂 | mksnapshot (快照机制) | 编译期打包好的引擎原生初始化对象与初始堆状态。开新棚时“拎包入住”。(注意:并不等同于把用户的运行时代码或业务脚本提前编译为机器码)。 |
| 清道夫 / 场地清理队 | GC (垃圾回收器) | 分两队:新生代突击队(Scavenge)用复制算法把还在用的道具完整搬到新片场,旧片场一键清空;老生代重型拆迁队用 Mark-Sweep 清理废弃垃圾,并用 Mark-Compact(标记压缩)把还在用的别墅统一挪到地块前排,消除内存碎片。 |
| 道具仓库管理员 | Object Factory | 制片厂专属库管。负责统一创建、分配所有 JS 对象、字符串、数组等道具,确保所有出库道具严格符合定妆照标准。 |
二、 剧组班底与工作人员
| 片场比喻 | V8 底层实体 | 核心职责与表现 |
|---|---|---|
| 原著编剧与审核员 | Parser & Syntax Checker | 拆解源代码并同步查错(如括号不匹配、非法语法)。剧本不合格直接打回,导演休想开工。 |
| 导演 | BytecodeGenerator (字节码生成器) | 掌控全局的大佬。拿着 AST 原稿,决定指令走向,画出最初的分镜头脚本。 |
| 场务 | BytecodeRegisterAllocator | 抠门的空间管理大师。编译期负责精打细算分配椅子(寄存器),算出“最高水位线”,打下 Frame Size 物理钢印。 |
| 记录员 / 老编辑 | BytecodeArrayBuilder | 手速如飞的记录员。自带“窥孔优化(Peephole)”职业病,听到导演喊了废话(如冗余存取)直接在脑子里抹掉。 |
| 无情的男一号 | Ignition 解释器 | 极速执行机器与 V8 默认入口。哪怕特效师临时救场,全场的最终兜底权永远在男一号手里。 |
| 海关 / 双向安检员 | JSEntry & CEntry Stub | 驻守 C++ 与 JS 边界。砸下防爆门,并在 Entry Frame 中保存返回地址与调用约定,确保 C++ 与 JS 间的调用契约被完整维护,防止异常穿透。 |
| 后期特效师 | TurboFan (优化编译器) | 激进的赌徒。只接“跑热了”的戏份(执行次数超阈值),冷剧本绝不碰。 赌定演员的定妆照(Map)绝对不变。 |
三、 核心道具与约定
| 片场比喻 | V8 底层实体 | 核心职责与表现 |
|---|---|---|
| 分镜头原稿 | AST (抽象语法树) | 导演看的分镜头原稿,上面画满了变量的作用域归属(住栈上还是住别墅)。 |
| 公共图纸 | SharedFunctionInfo (SFI) | 主要存放静态元数据的图纸(包含字节码与函数签名)。同一份图纸可供多个剧组实体(JSFunction)共用。 运行时会在上面挂载情报小本本。 |
| 活着的剧组实体 | JSFunction (闭包对象) | 运行期动态诞生的活物。体内缝合两根指针:一根指向公共图纸(SFI),一根持有出生地摄影棚的钥匙(Context 的引用)。 |
| 临时演员 / 龙套 | Tagged Value (标记值) | 所有 JS 值的统一物理载体。靠底层的 pointer-tagging / immediate-tag(指针标记机制)来区分小整数(Smi)与堆指针等不同表示形式。 |
| 唯一聚光灯 | Accumulator (Acc) | 舞台上的累加器。全场只有这一盏聚光灯,同一时间只能有一个值站在灯下,是所有字节码指令的核心操作锚点。 |
| 小板凳 / 休息椅 | Registers (虚拟寄存器) | 摆在聚光灯外围的椅子(r0, r1...)。用于存放局部变量或暂时退下阵来的中间计算结果。 |
| 豪华别墅 | Heap Context Slot | 为“逃逸(被闭包捕获)”的变量专门在富人区(堆内存)开辟的保留地。只要拿着钥匙的剧组还活着,别墅就不会被强拆。 |
| 情报小本本 | Feedback Vector | 解释器狂奔时动态更新的侦查记录。记录对象的形状与运行信息,为后期特效师(TurboFan)提供关键证据与优化线索。 |
| 定妆照 / 服装单 | Hidden Class / Map | 规定了演员的穿着打扮和口袋位置。注意:演员只要加减/修改一个属性(换件衣服),就必须当场换一张全新的定妆照(Map 迁移)。 |
| 特技替身 | Inline Cache (IC) | 分为:单态替身(只认一张定妆照,速度极快)、多态替身、超态替身(定妆照太乱,替身直接罢工,只能走完整查找流程)。 |
| 吹哨的警卫 | The Hole (哨兵值) | 主要看守 let/const 的未初始化状态(TDZ)。(注意:除了暴躁的吹哨子警卫,它还有另外一种用途,在这个列表后面,会详细说明)。 |
| 场记板 | Bytecode PC | 记录当前执行的字节码偏移(哪条分镜头正在执行)。解释器、错误回溯与去优化恢复时靠它精准定位回退点。 |
| 制作日程 / 微任务队 | Microtask / Job Queue | 存放 Promise.then 的回调。主调用栈清空后,微任务队会被逐条调度执行,对事件循环的可观察顺序有直接影响。 |
四、 场地与关键动作
| 片场比喻 | V8 底层实体 | 核心职责与表现 |
|---|---|---|
| 跨界防爆门 | Entry Frame (入口帧) | 砸在物理堆栈底部的厚重铁门。保存宿主调用约定与 C++ 物理现场,挡住异常穿透,保全宿主进程。 |
| 临时搭建的戏台 | JS Stack Frame | 函数 Call 时拔地而起的工作区。嵌套调用就是“戏台叠戏台”,杀青时严格按调用顺序从最上层挨个拆除。 |
| 界碑 与 戏台前沿线 | FP (帧指针) & SP (栈指针) | FP 往下看内务,往上看遗产。SP 是戏台前沿线,杀青时 SP=FP 瞬间收回前沿线,夷平整个戏台。 |
| 极速圈地 / 物理一刀 | SP = SP - Frame_Size |
解释器按图纸钢印数字,挥刀向下拉伸 SP,瞬间 O(1) 斩出戏台上所有虚拟寄存器(小板凳)的物理空间。 |
| 替身罢工 | IC Miss (缓存未命中) | 特技替身(IC)上场时,发现演员的定妆照和情报本里不一样,直接罢工。只能重新走查找流程,同时更新情报小本本。 |
| 现场无缝换角 | OSR (On-Stack Replacement) | 演极其漫长的循环戏时发生的现场换人。这是片场唯一能打破“杀青前不能换演员”规则的绝对特例。 |
| 安全绳 / 彩排录像 | Deopt Metadata | 特效师预留的回退通道。必要时借助它,将高度优化的机器码物理寄存器,精确还原回解释器的状态。 |
| 拍摄翻车与废片 | Deoptimization (去优化) | 激进特效遇到突变当场穿帮。拉拽安全绳,把控制权安全交还给解释器(Ignition),并直接把这段失效的机器码扔进垃圾桶废弃。 |
| 重拍预案 | Lazy Deopt (懒去优化) | 翻车后如果不致命,先标记当前特效失效,等这组长镜头(函数)平稳演完再回退,避免强行中断。 |
我们从开篇就一直强调, 一定要分清 规范 和 实现 的区别,在学习中, 也尽量以双视角甚至多视角来讲解。下面我们就以从解析篇到现在,已经出现很多次的 会吹哨子的警卫 这个知识点,来说明,双视角多视角的必要性。
对于数组 [1, , 3] 我们进行分析:
— 规范 / 编译期(AST 层,语法语义)
在语言/规范层面,[1, , 3] 中间的“空位”(elision)语义上就是“该索引在对象上不存在”,不是 NullLiteral 也不是显式写出的 null。解析器/Parser 在生成 AST 时会以一种占位(elision)的形式标记该位置;某些解析器实现把这个占位在 AST 的数组元素列表里表示为 null(仅作为实现细节的占位符),但这和源码中显式写的 null(NullLiteral)是不同的概念。简短检验(语义区别)如 1 in arr、forEach 的行为,会把两者明显区分开来。
二 运行期 / 引擎实现(Heap 层,物理表示)
在实际堆布局里(例如 V8 的 FixedArray backing store),不能留“物理空洞”,因此引擎用一个内部哨兵(sentinel)填充该槽——通常称为 the_hole / the_hole_value。
the_hole不是null、不是undefined、也不是数值 0;它是 C++ 层面的内部标记/对象,脚本层不应直接依赖或可见它。- 读取槽时若遇到
the_hole,引擎会把该槽视为“缺失属性”,按属性查找/回退逻辑继续处理(最终由语义层返回undefined)。 - 出现
the_hole会把数组的 elements-kind 从 packed 降到 holey(如HOLEY_SMI_ELEMENTS),这改变了底层快速路径并通常带来性能成本(对后续访问产生长期影响)。
三 运行结果 / JS 语义层(表面行为)
对脚本可观察到的是:访问空位 arr[1] 返回 undefined,但这只是规范定义的回退值(因为属性不存在),并不意味着槽里真实存的是 undefined。
示例
const holey = [1, , 3];
const undef = [1, undefined, 3];console.log(1 in holey); // false — 索引不存在
console.log(1 in undef); // true — 索引存在,值为 undefinedholey.forEach(x => console.log(x)); // prints 1, 3 (跳过空槽)
undef.forEach(x => console.log(x)); // prints 1, undefined, 3
最后,语义层、编译期 AST 表示与运行期物理表示是三套不同的“视角”:AST 用占位表示缺节点;运行期用 the_hole 填槽并影响优化;JS 层最终呈现的是规范定义的 undefined 回退。写代码和做性能优化时需要以规范语义判断行为,但以引擎实现(hole → holey → 性能降级)来评估性能的结果。
2. JS的运行场景
js的运行场景,需要两个刚性的核心需求。
强隔离:不同的JS代码运行环境必须互不干扰,比如浏览器里两个网站的代码不能互相篡改数据、一个页面崩溃不能带崩整个浏览器;
轻量隔离:在同一个大运行环境里,需要多个独立的小执行环境,但是又不能付出过高的性能和内存开销,比如同一个页面里的多个同站iframe,不需要重新启动一整套引擎实例。
为了能满足这两个要求,v8设计了两层隔离体系
Isolate负责底层物理级别的绝对隔离,Context负责同物理实例内的逻辑级执行环境隔离。
下面我们分别学习。
一、Isolate
- 详细定义
- 首先,V8本身是一套用C++编写的JS引擎库,它不是进程、也不是线程,而是一套可以被嵌入到程序中的代码执行能力。
- 一个Isolate,就是V8引擎的一个完整、独立、可运行的副本实例。当你在一个操作系统进程中创建一个Isolate时,相当于你在进程的内存空间里,划出了一块完全独立的「专属运行领地」,初始化了一整套完整的JS运行所需的核心组件。
- 通俗理解:操作系统是一座城市,进程是城市里的一个独立工业园区(有自己的水电、安保边界,和其他园区完全隔离),Isolate就是这个工业园区里,一个完全独立的「物理制片厂」。这个制片厂有自己的围墙、专属地皮、专属工作人员、专属仓库,和园区里其他制片厂完全物理隔绝,连大门都不互通。
这里必须纠正一个常见错误:Isolate不是进程,也不是线程。一个操作系统进程里,可以创建多个Isolate实例;一个Isolate实例,对应且仅对应一个主线程,同时可以有自己专属的辅助线程。
- 每一个Isolate都拥有一整套完全专属的运行资源,不会共享
(1)专属的堆内存(Heap)
- 堆内存到底是什么? 通俗说,JS里所有的引用类型数据(对象、数组、函数、闭包、字符串、类实例等),实际的内容都存在堆内存里;我们代码里的变量,只是在栈内存里存了这个数据在堆里的内存地址。堆内存,就是JS代码运行的「数据仓库」。
- 专属的核心含义:每个Isolate的堆内存,是操作系统分配的、完全独立的内存地址空间,和其他Isolate的堆内存完全割裂。
- 内存地址完全不互通:A Isolate堆里的一个对象的内存地址,在B Isolate里完全无效,B Isolate根本无法读取、访问、修改这个地址里的内容,就像A制片厂的仓库地址,在B制片厂的系统里根本不认,连门都进不去。
- 内存配额完全独立:每个Isolate都有自己独立的堆内存上限,A Isolate的堆内存用了多少、剩了多少,和B Isolate完全无关。
- 内存生命周期完全独立:这个堆里的内存分配、释放,全由当前Isolate自己管理,其他Isolate无权干预。
(2)专属的垃圾回收器(GC)实例
- GC是什么? GC全称Garbage Collection,垃圾回收。通俗说,就是引擎自动扫描堆内存,清理掉那些不再被使用的对象,释放内存空间的机制,避免内存泄漏和内存溢出。V8的GC有完整的分代回收策略(新生代、老生代),包含标记清除、标记压缩、增量标记等一整套流程。
- 专属的核心含义:每个Isolate都有自己独立的、完整的GC全流程实例,和其他Isolate的GC完全互不干扰。
- 回收范围完全独立:A Isolate的GC,只会扫描、清理自己的堆内存,绝对不会碰其他Isolate的堆,就像A制片厂的垃圾清运队,只会清理自己仓库的垃圾,绝不会跑到隔壁制片厂的仓库里干活。
- 执行时机与影响范围:GC执行时会触发的「全停顿」(Stop-The-World),在JS/引擎语义层只会暂停当前Isolate的主线程,其他Isolate的代码执行不受影响。但需注意:如果embedder(如Chrome)在高层做了进程/线程绑定、或存在native共享资源,极端的native bug/内存分配压力仍可能影响整个进程/其它组件。
(3)线程模型:Isolate的进入限制与后台任务
- Isolate的进入限制:一个Isolate在任意时刻只能被一个线程
Enter并执行(需用Locker/Unlocker在多线程中同步)。这是V8的核心线程规则,Isolate本身不是线程安全的,必须通过排他锁保证同一时间只有一个线程访问,否则会直接崩溃。 - 后台任务与线程调度:V8会使用后台worker/任务来做并发GC、并行标记或JIT编译等工作,这些后台线程/任务的调度与是否“为某个Isolate专属”由V8平台与embedder决定,不能简单的下结论说是为每个Isolate都创建一整套独占OS线程。
- Isolate之间的强隔离,是V8稳定性和安全性的底层基石
(1)完全不共享任何JS对象,跨Isolate无法直接传递对象引用
- 底层逻辑:V8里的每一个JS对象,都有一个绑定所属Isolate的「隐藏类(Map)」,同时对象的实际数据存在所属Isolate的堆内存里。这个对象和它的隐藏类,只在所属的Isolate里有效,一旦脱离这个Isolate,就完全失去了意义。
- 实际表现:你绝对无法把A Isolate里的一个对象,直接传给B Isolate使用。哪怕你通过C++代码把内存地址传过去,B Isolate也无法识别这个地址,更无法访问这个对象,强行操作会直接触发崩溃。
- 跨Isolate数据传递的唯一方式:序列化+反序列化。比如浏览器里的跨Tab通信、Node.js里的Worker线程和主线程通信,用的「结构化克隆算法(Structured Clone)」,本质就是把A Isolate里的对象,转换成二进制数据流,再把这个数据流传给B Isolate,B Isolate在自己的堆里,重新生成一个一模一样的全新对象。注意:这里传递的不是原对象的引用,而是生成了一个完全独立的副本,两个对象后续的修改完全互不影响。
(2)OOM、崩溃的隔离边界
- OOM(内存溢出)隔离:OOM通俗说就是,Isolate的堆内存使用量超过了系统给它分配的上限,装不下新的对象了,导致程序无法继续运行。在JS语义层与正常错误范围内,一个Isolate发生OOM,只会触发当前Isolate的内存超限,同进程里的其他Isolate的堆内存完全不受影响,依然可以正常运行。但需注意:在native内存越界、引擎bug或exploit的情况下,整体进程仍可能被破坏。
- 实际场景:Chrome浏览器里,一个网站页面因为内存泄漏触发OOM崩溃,只会当前页面白屏,其他打开的Tab页面完全正常,就是因为每个站点的页面都运行在独立的Isolate(甚至独立进程)里。
- 崩溃隔离:在JS语义层与正常错误范围内,一个Isolate里发生的运行时错误,只会触发当前Isolate的异常,不会污染同进程里其他Isolate的内存空间。但极端的native内存越界、未定义行为、内核/驱动异常或者V8自己的严重bug,仍可能影响整个进程。
二、Context
- 详细定义
- 首先,JS是词法作用域(静态作用域)语言,代码的作用域在编写时就确定了,而所有作用域链的最顶端,就是全局执行环境。我们写的所有JS代码,最终都必须在一个全局执行环境里运行,所有的全局变量、全局函数,都挂载在这个环境的全局对象上。
- 一个Context,就是V8里一个完整、独立的全局执行环境的实体,对应V8的C++类
v8::Context。它是JS代码真正的「运行容器」——哪怕你创建了Isolate,没有Context,也无法执行任何JS代码。 - 通俗理解:如果Isolate是独立的物理制片厂,Context就是这个制片厂里面,搭建的一个个独立的逻辑摄影棚。同一个制片厂(Isolate)里,可以搭建多个摄影棚(Context),每个摄影棚都有自己完整的布景、道具、演员阵容,拍摄的剧本完全独立;它们共享制片厂的地皮(堆内存)、垃圾清运队(GC)、核心拍摄团队(主线程),但每个棚的拍摄内容互不干扰,也不会窜棚。
这里我们需要理解这个设计的核心价值所在:Context是为了在同一个Isolate里,实现轻量级的全局环境隔离,避免重复创建Isolate带来的巨大性能和内存开销。创建一个新的Context,开销极小(只是创建一套新的全局环境);而创建一个新的Isolate,需要重新分配堆内存、初始化GC、初始化一整套引擎实例,开销是Context的成百上千倍。
- 每个Context都有一套完全独立的全局执行环境,是隔离的核心
(1)专属的、完全独立的全局对象
- 全局对象是什么? 它是JS全局执行环境的根对象,所有的全局变量、全局函数都会作为它的属性存在。在浏览器环境里,全局对象是
window/globalThis;在Node.js环境里,是global/globalThis;在自定义Context里,你可以完全自定义这个全局对象。 - 专属独立的核心含义:每个Context的全局对象,都是一个全新的、独立的对象,和同Isolate里其他Context的全局对象完全没有关联。
- 实际表现1:你在A Context里执行
window.a = 123,给全局对象加了一个属性a,在同Isolate的B Context里,执行console.log(window.a),只会输出undefined——因为两个Context的window根本不是同一个对象,就像两个摄影棚的背景板,哪怕都叫「客厅布景」,也是两个完全独立的板子,你在A棚的背景板上写字,B棚的背景板上完全看不到。 - 实际表现2:浏览器里,主页面和同站iframe的
window对象,就是两个不同Context的全局对象。主页面的全局变量,iframe里默认完全访问不到,反之亦然,这就是Context隔离的最直观体现。
- 实际表现1:你在A Context里执行
(2)内置原生对象
- 内置原生对象是什么? 就是JS语言自带的、不需要我们手动引入的原生构造器和API,比如
Array、Object、Function、String、Number、Math、JSON、Promise、RegExp等等,所有JS内置的语法相关的API,都属于这个范畴。 - 准确表述(区分实现与语义):
- ECMAScript语义层(JS开发者视角):每个Context都有自己的全局对象与内置构造器/原型(即一个Context的
Array与另一个Context的Array在JS语义上是不同的),这就是iframe-to-parentinstanceof出现false的根本原因。 - V8实现层(引擎开发者视角):V8/Isolate会维护builtin的实现(engine code),但在ECMAScript语义上,内置对象是按Context/realm隔离的。
- 前端高频踩坑案例:浏览器里,主页面(A Context)里拿到了同站iframe(B Context)里的一个数组
arr,在主页面里执行console.log(arr instanceof Array),结果会返回false——因为主页面里的Array构造器,是A Context的内置对象;而iframe里的数组arr,它的原型是B Context里的Array.prototype。这两个Array构造器,在JS语义上是两个完全独立的函数对象,它们的原型对象也完全独立,所以instanceof判断会失败。
- ECMAScript语义层(JS开发者视角):每个Context都有自己的全局对象与内置构造器/原型(即一个Context的
(3)Context的环境装配流程,以及自定义沙箱能力
一个Context的创建和环境装配,分为两个核心步骤,这也是它能实现自定义JS沙箱的核心原理:
第一定义全局对象模板:通过V8的ObjectTemplate(对象模板),预先定义全局对象可以拥有哪些属性、方法,哪些属性可读写、可配置、可枚举。你可以在这里决定,给这个Context注入哪些API,屏蔽哪些API。
第二初始化Context实例,完成环境装配:基于上面的模板,创建Context实例,V8会自动为这个Context初始化一整套完整的内置原生对象(语义层独立),同时把模板里定义的属性、方法挂载到全局对象上,最终生成一个完整的、可执行JS代码的全局执行环境。
- 沙箱应用场景:很多低代码平台、在线代码编辑器、JS沙箱库(比如Node.js的
vm模块、isolated-vm库),核心原理就是创建一个自定义Context,只给它注入允许的安全API,屏蔽掉fetch、eval、document、process等危险API,让用户的JS代码只能在这个受限的Context里运行,实现安全隔离。
- 同Isolate内多Context的运行规则、通信机制与实际场景
(1)浏览器Tab/iframe与Isolate/Context的映射
浏览器的Tab页与Isolate、Context的对应关系,受Chrome的站点隔离(Site Isolation) 机制影响,分为两种情况:
- 同站iframe:和主页面运行在同一个渲染进程、同一个Isolate里,主页面和iframe各自拥有独立的Context。
- 跨站iframe:Chrome会把它分配到独立的渲染进程中,拥有自己独立的Isolate。
重要说明:这是Chrome的site-isolation策略带来的常见映射;具体映射依赖浏览器的进程/线程模型与隔离策略,V8只提供Isolate/Context的能力,并不强制这种对应关系。
(2)同Isolate内多Context的核心运行规则
- 共享底层资源,隔离执行环境:
- 共享:同一个Isolate里的所有Context,共享Isolate的堆内存、GC实例、主线程、后台任务调度系统。
- 隔离:每个Context的全局执行环境、全局对象、内置原生对象(语义层)完全独立,代码在哪个Context里执行,就默认使用哪个Context的全局环境。
- Context的切换规则:同一时间,主线程只能进入一个Context执行代码
- V8里,要在某个Context里执行JS代码,必须先通过
Context::Enter()进入这个Context,执行完成后,通过Context::Exit()退出。 - 通俗类比:制片厂的拍摄团队,同一时间只能在一个摄影棚里拍戏,拍完这个棚的内容,要先退出这个棚,再进入另一个棚拍摄。
- 核心优势:Context的切换开销极低,只是切换了当前的全局执行环境指针,不需要切换线程、堆内存等底层资源,比切换Isolate的开销小几个数量级。
- V8里,要在某个Context里执行JS代码,必须先通过
- 词法作用域与Context的绑定规则
JS是词法作用域,函数的作用域链,是在函数创建时确定的,而不是执行时。这个规则和Context深度绑定:- 举个例子:你在主页面的A Context里,创建了一个函数
fn,函数里写了console.log(window.a)。然后你把这个fn函数,传给同Isolate的iframe的B Context里执行。 - 执行结果:
fn里访问的window,依然是A Context的window,而不是B Context的window。 - 底层原因:函数创建时,它的作用域链就已经绑定了创建它的A Context的全局环境,哪怕你把它拿到B Context里执行,它的作用域链也不会改变,依然会从创建时的Context里查找变量。
- 举个例子:你在主页面的A Context里,创建了一个函数
(3)跨Context通信
postMessageAPI:这是最常用、最安全的跨Context通信方式。底层原理是:V8允许在同Isolate的不同Context之间,传递结构化克隆的数据,或者可转移对象,同时浏览器会校验消息的来源、目标域名,防止恶意跨域访问。iframe.contentWindow引用:同站iframe之间,可以通过contentWindow拿到对方Context的全局对象的有限引用,进而访问对方允许的属性、调用对方的方法。底层是V8暴露了跨Context的对象访问能力,同时浏览器会做严格的同域校验,跨域场景下会屏蔽绝大多数属性的访问。- V8原生API的跨Context对象传递:在C++层面嵌入V8时,可以直接通过V8的API,把一个Context里的对象、函数,传递给另一个Context使用,因为它们在同一个堆里,对象引用是有效的。但V8依然会做上下文的安全校验,避免非法的跨Context访问。
三、容易理解错误的关键知识点
-
错误:Isolate就是进程/线程,Context就是线程
正确:Isolate不是进程也不是线程,它是V8引擎的一个运行实例,一个进程里可以创建多个Isolate,一个Isolate对应一个主线程;Context更不是线程,它只是一个执行环境,多个Context共享Isolate的主线程。 -
错误:内置对象要么完全是per-Isolate,要么完全是per-Context
正确:需区分实现层与语义层。实现层V8/Isolate保有builtin的实现代码;语义层每个Context拥有独立的全局对象与内置构造器/原型,这是instanceof在不同Context不等同的根源。 -
错误:iframe一定是一个独立的Context,且和主页面同Isolate
正确:Chrome站点隔离机制下,跨站iframe会运行在独立的渲染进程、独立的Isolate里;只有同站iframe才是同Isolate下的独立Context。且这种映射是embedder策略,不是V8强制的。 -
错误:把函数传到另一个Context里执行,就会用这个Context的全局对象
正确:JS是词法作用域,函数的作用域链在创建时就绑定了所属的Context,哪怕在另一个Context里执行,依然会使用创建时的Context的全局环境。 -
错误:Isolate的OOM/崩溃“绝对”不会波及其他Isolate
正确:在JS语义层与正常错误范围内不会波及,但native内存越界、引擎bug或exploit仍可能影响整个进程。 -
错误:每个Isolate都必然拥有一组专属的OS后台线程
正确:后台线程/任务的调度与是否“专属”由V8平台与embedder决定,不能轻易下结论,前面讲过的。 -
错误:64位系统下V8堆上限通常为4GB
正确:堆上限与V8版本、embedder配置及运行时参数(如--max-old-space-size)有关;默认值在不同平台/版本间有较大差别,指针压缩会对最大堆规模引入工程限制(常见讨论在4GB左右),但不能将4GB作为统一默认值。
四、一段js代码的完整执行流程
我们看一段JS代码从初始化到执行的完整流程:
- 进程与Isolate初始化:操作系统启动浏览器渲染进程,进程内创建一个V8 Isolate实例,为它分配专属的堆内存、初始化专属的GC实例、启动主线程。
- Context创建与环境装配:在这个Isolate里,为页面主环境创建一个Context实例,初始化全局对象
window,注入语义层独立的JS内置原生对象,同时挂载浏览器提供的document、location、fetch等Web API,完成全局执行环境的装配。 - 进入Context执行代码:主线程Enter这个主Context,把页面的JS代码加载进来,进行预解析、编译成字节码,然后在这个Context的全局执行环境里逐行执行;代码里的全局变量挂载到当前Context的
window上,调用的Array、Object等API,都来自当前Context。 - 多Context场景处理:页面加载了一个同站iframe,浏览器在同一个Isolate里,为这个iframe创建一个全新的Context实例,初始化它自己的
window对象、内置原生对象和独立的document对象;主线程Exit主Context,Enter这个iframe的Context,执行iframe里的JS代码,两个Context的全局环境完全隔离。 - 跨Context通信:主页面通过
postMessage给iframe发消息,浏览器通过V8的跨Context通信机制,把消息数据传递给iframe的Context,触发iframe里的消息回调,回调在iframe的Context里执行。 - 资源销毁:页面关闭时,先销毁iframe的Context,再销毁主页面的Context,最后销毁Isolate实例,释放对应的堆内存和所有资源。
3. 快速启动的机制
一个可执行的 Context(逻辑摄影棚),必须完成全局对象、ECMAScript 规范内置原生对象的完整装配,才能承接 JS 代码的执行。但是,规范定义了上百个内置构造器、上千个内置方法,从 Array、Object 到 Promise、JSON,每一个都需要底层 C++ 代码从零创建、初始化、挂载原型链、编译字节码。
这套标准化的繁琐装配流程,每次新建 Isolate 或 Context 时都要完整重复执行一遍,这是 V8 引擎冷启动最大的性能瓶颈。在早期无快照的版本中,桌面端创建一个 Context 需要耗时 40ms 以上,中低端移动端更是需要 270ms 以上(这些是查阅资料,找到的历史观测的估算数值,具体耗时取决于硬件平台和测量方法)。这严重降低了冷启动的体验。
而现在, V8 用来打破这个瓶颈、实现开机时间数量级缩减的,就是类似于 预制化基建 的 mksnapshot 快照机制。
我们使用 制片厂-摄影棚 的比喻:如果说 Context 的内置对象初始化,是给每个新摄影棚从零搭建标准化的背景板和道具架,哪怕所有摄影棚的基础配置完全一致,也要一钉一板的重新施工;那么 mksnapshot 就是制片厂的预制构件工厂,提前在工厂里把所有标准化基建一次性搭建完成,拍下完整的状态快照存档。新建摄影棚时,直接把预制好的整套基建搬运进场、一键还原,瞬间达到开机拍摄的标准,省去了 99% 的施工时间。
一、定义
mksnapshot 是 V8 引擎源码编译阶段的一个中间可执行程序,也是 V8 冷启动优化的核心基础设施。它的核心逻辑就是:
把 JS 运行环境的重复初始化工作,从无数次的运行期提前到一次性的编译期;把运行时需要 CPU 逐行执行代码才能生成的堆内存状态,固化成编译期预制好的二进制快照,运行时直接内存还原即可。
- 构建极简实例: 编译 V8 源码时,第一步会先编译出一个极简版的、最小可用的 V8 实例,这就是 mksnapshot 程序。(mksnapshot 的具体生成策略与 V8 版本和平台有关,早期与现在的实现细节会有差别,它会随引擎不断演进)。
- 生成基建状态: 运行 mksnapshot,它会在内部创建一个临时的 Isolate 和 Context,完整执行一遍 ECMAScript 规范要求的所有内置对象初始化流程,生成一个完全可用、装配完毕的 JS 运行环境堆内存状态。
- 拍下物理快照: mksnapshot 会把这个稳定的堆内存状态,序列化成一个二进制快照文件(snapshot_blob.bin)。mksnapshot 会把内置函数的元数据(如 SharedFunctionInfo)、Ignition 字节码以及部分底层的可序列化内建实现(code objects)一并打包进快照;而由运行时 JIT(如 TurboFan)针对业务代码动态生成的优化机器码通常是运行期产物,绝不会作为通用快照的一部分。
- 打包发版: 最后,这个快照文件会被转换成 C++ 常量数组,和 V8 的其他核心源码一起编译,最终打包进 Chrome、Node.js 等宿主程序的可执行文件中,随程序发布。
通俗来说,这就是餐饮行业的中央厨房预制菜模式(即星际著名的西贝模式.):中央厨房(编译期 mksnapshot)提前把菜做好、速冻锁鲜(序列化快照),配送到各个门店(用户的宿主程序)。门店不用再洗菜、切菜、点火,只需要微波炉加热(反序列化),瞬间就能出餐,彻底解决了每个门店都要重复备菜的效率问题。
二、底层全流程:快照的生成与反序列化的物理动作
我们将快照机制分解为编译期生成和运行期还原两个核心阶段,理解底层的内存操作。
(1)编译期:快照的生成与序列化全流程
这一步发生在 V8 引擎自身的编译构建阶段,对前端开发者和最终用户完全透明。
-
第一步:预执行,完成虚拟世界的完整基建
mksnapshot 启动后创建一个干净的临时 Isolate 和默认 Context,执行两大核心工作:
-
内置对象全量装配: 从零创建规范定义的所有内置构造器、原型对象、全局 API,完成原型链挂载和属性配置。
-
内置函数预编译: 对所有内置方法进行解析、编译,生成对应的 SharedFunctionInfo(公共图纸,下一章核心)和 Ignition 字节码,部分核心内置函数甚至直接编译成底层可序列化的 code objects 存入快照。
至此,临时 Isolate 的堆内存里,已经有了一个无任何动态副作用的纯净 JS 运行环境。
-
-
第二步:序列化与指针重定位(核心要点)
堆内存里的对象通过指针互相引用,而指针存储的是绝对物理地址。下次新建 Isolate 时,堆的基地址完全不同,直接死板拷贝会导致指针全部失效。
mksnapshot 的序列化是一套完整的「对象图谱持久化」流程:
- 遍历临时堆内存,梳理出所有对象(内置对象、字节码、隐藏类、常量等)的依赖关系图。
- 将所有对象的绝对内存指针,转换成基于快照基地址的相对偏移量。引用关系从「绝对地址指向」变成了「相对偏移指向」。
- 按照特定格式,将这些元数据、实际内容和偏移信息,压缩编码成连续的二进制数据块(快照 Blob)。
-
第三步:嵌入可执行文件
生成的快照 Blob 被转换为 C++ 巨型常量数组,随 V8 一起链接打包。当你安装 Chrome 或 Node.js 时,这个预制好的 JS 世界基建,就已经以物理数据的形式躺在二进制文件里了。
(2)运行期:快照的反序列化(开箱即用的基建还原)
这一步发生在浏览器或 Node.js 启动、新建 Isolate/Context 的瞬间。
-
第一步:内存拷贝与指针重定向
当宿主环境创建新 Isolate 时,V8 拒绝执行繁琐的初始化代码,而是干脆利落地执行两个物理动作:
- 拷贝: 在新 Isolate 的堆内存里开辟连续空间,把内置在程序里的快照二进制数据直接整块拷贝进去。
- 重定向: 执行一次极速遍历,把快照里所有的相对偏移量,加上当前 Isolate 堆的基地址,瞬间转换成当前堆里有效的绝对物理指针,缝合所有对象的引用关系。
复杂度: 这两步操作本质上是大块内存拷贝加上对快照中所有引用的一次修正(Pointer Relocation)。其耗时随快照规模线性增长(即 O(n) 复杂度)。但由于 V8 使用了大块 memcpy、只读映射和按需反序列化等极致优化,体验上能做到非常低的延迟。在桌面端耗时不到 2ms 等毫秒级别(估算值),实现了数量级的性能跃升。
-
第二步:环境挂载,完成最终装配
快照只包含标准的 ECMAScript 基础状态。浏览器还需要 window/document,Node.js 还需要 global/process。
此时,V8 只需基于快照还原出的干净环境,快速创建一个全局对象并挂载这些宿主专属 API,一个完整可用的 Context 瞬间拔地而起。
三、现代 V8 的进阶优化:从全量到精细化管控
早期的快照是全量反序列化的,哪怕 90% 的内置对象(如 WebAssembly 或高级正则库)用户根本用不到,也会完整塞进内存,造成极大浪费。现代 V8 通过三招将启动速度和内存占用做到了极致平衡。
(1)懒反序列化 (Lazy Deserialization):按需加载
这是 V8 彻底解决内存浪费的核心优化。
原理: 将完整的快照拆分成几十个独立的小块。启动时,仅反序列化最核心、最基础的极小一部分快照块。
按需触发: 当用户的 JS 代码第一次用到某个特定的内置对象时(比如第一次执行 new Promise()),V8 才会去反序列化对应的快照块并在堆里还原。完全用不到的对象,永远不会占据物理内存。
(2)只读堆快照 (Static Roots):多实例共享的公共基建
在多 Context 或多 Isolate 场景下(如 Chrome 的多个同站 Tab,或 Node.js 的 Worker 线程),每个实例都反序列化一份完全相同的内置对象,依然是内存冗余。
原理: 现代 V8 将快照中永远不会被修改的内容(如内置函数的字节码、隐藏类 Map、undefined/null 常量等),单独剥离成一个独立的只读快照 (Read-Only Snapshot)。
共享机制: 现代 V8 可以通过操作系统的内存映射 (mmap) 实现多个实例(通常指同一进程内的多个 Isolate)对这段物理内存的直接共享;至于能否跨进程共享,则依赖宿主(如 Chrome 的进程模型)如何使用共享内存或文件映射来达成。这使得多实例场景下的基础内存开销骤降 50% 以上。
(3)自定义快照 (Custom Snapshot):业务级冷启提速
mksnapshot 不仅能预制 V8 原生对象,还允许开发者把自己的业务代码和第三方库提前预制进去。
原理: 在应用的构建阶段,提前执行高频依赖库(如 React、Vue 或各种 Utils),生成自定义快照并打包。应用启动时直接反序列化,彻底免去了运行时的源码解析和编译时间。
战绩: VS Code、Figma 等重型 Electron 桌面应用,正是通过自定义快照,将冷启动时间砍掉了 30% 以上。(需要注意:某些系统用自定义快照确实能显著提速,但也要注意业务代码调试的复杂性与快照维护成本)。
四、使用和限制的问题:快照机制不能做什么?
快照是冷启动优化的核心关键,但是快照并不是万能的,它有严格的限制。
不能包含宿主相关的动态 API
快照只能包含与 ECMAScript 语义直接相关、且在编译期可确定为无副作用的内容。任何依赖宿主运行时信息的对象(如浏览器的 DOM 树、Node.js 的 process.pid、实时网络交互或打开的文件句柄等),都不应被写入快照,必须在运行时动态挂载。
不能包含有副作用或动态不确定的代码
预执行的代码必须是纯净无副作用的。不能包含 Math.random()、Date.now()、网络请求或文件读写。如果在编译期预制了当前时间,那用户运行时拿到的将永远是几个月前快照打包那一刻的过期时间。
跨版本不通用
快照与 V8 引擎版本是强绑定的。不同版本的 V8,其堆内存布局、对象隐藏类结构、序列化格式随时会变。跨版本使用快照会导致指针错乱,直接引发进程崩溃。
(说明:在前面的章节中提到不同 Isolate 之间完全不共享对象引用,这是准确的;但通过宿主环境提供的共享内存如 SharedArrayBuffer 或 native handle,依然可以实现跨环境的数据互通,这是独立于堆快照之外的特殊通道。)
五、快着急制的理解误区
-
错误 :快照就是简单的内存镜像直接映射。
正确: 快照是经过序列化处理的对象依赖图。反序列化时必须经历严格的指针重定向计算,把相对偏移转为当前堆的绝对物理地址,绝非一句简单的 memcpy 就能搞定。
-
错误 :快照能把所有 JS 代码都提前预制,启动时无需执行。
正确: 只有静态、无副作用的初始化代码有资格进入快照。动态的业务逻辑必须在运行时老老实实交给解释器执行。
-
错误 :自定义快照里塞的代码越多,启动越快。
正确: 塞入过多低频代码会导致快照体积暴涨,极大地拖慢反序列化时的 I/O 读取和指针重定向耗时,反而得不偿失。
-
错误 :快照反序列化出来的对象,和运行时从零创建的对象有差异。
正确: 两者在结构、行为、语义上完全一致,JS 代码完全感知不到任何区别 —— 唯一的差异就是创建速度快了几个数量级。反序列化出来的对象,同样可以正常修改、删除属性,正常调用方法,没有任何限制。
六、收工了:串联制片厂的生命周期
到这里,我们可以用完整的比喻将快照机制串联起来:
- 工厂预制(编译期): 制片厂建厂前,先在预制构件厂(mksnapshot)把标准化的背景板、灯光系统搭建好,拍下状态快照存档。
- 拎包入住(新建 Isolate): 接到新项目时,不再从零采购砖瓦,直接把预制好的基建一次性搬运到新厂区。
- 按需装修(新建 Context): 在预制基建上,快速加装本次拍摄专属的道具(挂载宿主 API),瞬间开机。
快照机制的本质,就是把重复劳动一次性前置,用编译期的一次重度计算,替换掉运行时无数次的重复初始化。
同时,快照里已经预制好了所有内置函数的 SharedFunctionInfo(公共图纸)和预编译字节码。这正是连接「编译期」与「运行期」的终极纽带。
4. 空间的KPI
上面的快照,核心kpi是时间,而现在,我们讲一下空间,即v8在内存的使用中是如何的扣扣嗖嗖。
JavaScript 是一门到处都是回调函数、极度依赖闭包的语言。如果每一次 function() {} 的执行,引擎都要在内存里原封不动地把庞大的指令代码复制一份,那再大的运行内存也会被撑爆。
为了将内存压榨到极致,V8 对 JS 里最核心的实体------ 函数,进行了一次分解。这就是 V8 运行期精妙的内存设计:双子星模型(SharedFunctionInfo 与 JSFunction)。
第一:SharedFunctionInfo (SFI):公共图纸
- 属性: 编译期的纯静态产物。
- 物理形态: 它是一个绝对的“死物”。一旦在编译期(或 mksnapshot 快照期)生成,就作为以静态元数据为主的长期对象存在,通常位于老生代或只读映射段。(注意:只要没有任何引用且 GC 判定可回收,SFI 也会被销毁;并且为了节省内存,它挂载的部分编译产物——如长期未使用的字节码或机器码——在运行时可被触发 Flush 刷新或丢弃)。
- 跨环境共享: 同一份代码源码,即便运行在不同的摄影棚(Context / Realm)里,底层也可以共享同一份 SFI 图纸,因为它只描述静态特征,与具体的执行环境彻底分离。
核心内容: SFI 里面装载的全是与“单次执行状态无关”的元数据。它是一张详尽的公共建筑图纸:
- BytecodeArray(分镜头剧本): 导演(字节码生成器)录制的完整字节码序列,是函数执行的绝对核心指令集。
- FormalParameterCount(演员名额): 明确规定了这个剧本需要的形参个数,用于运行时的参数适配与溢出校验。
- Expected Register Count(栈帧最高水位线): 这主要记录在 SFI 关联的 BytecodeArray 中,是生成器在编译期精打细算后,打上的那个决定性物理钢印!它明确记录了未来建组时,需要瞬间圈出多少个虚拟寄存器(r0, r1...),是运行时解释器 O(1) 极速圈出栈帧空间的唯一依据。
- FeedbackMetadata(侦察兵的空白表格): 提前计算好这个函数里有多少个需要收集类型信息的插槽,规定了未来“情报小本本”的格式和页数。
- Source Position Table(源码雷达): 指向字节码与源码行列号的映射表。运行时报错时,能精准定位到开发者写的具体是哪一行哪一列。
- Flags(特性标记): 标注函数的核心特性(如“箭头函数”、“严格模式”、“async”等),直接指导解释器的微观执行逻辑。
为什么叫“Shared” (共享)?
想象一下,如果你写了一个高频触发的逻辑:for(let i=0; i<1000; i++) { function foo(){} }。
在 JS 的语义层面,每一次循环都会创建一个全新的、相互独立的函数对象。难道 V8 要把 foo 的字节码编译 1000 次、在内存里存 1000 份重复的死代码吗?
肯定不会!V8 对内存的控制极其抠门。对于 foo 这个函数,内存里永远只有唯一的一张 SFI 图纸。那 1000 个循环创建出来的函数实体,都会通过内部指针共享这同一张图纸。这也是它名字里 Shared 的核心由来,它极大拯救了前端应用的堆内存。
第二: JSFunction:活着的剧组与闭包的肉身
- 属性: 运行期的动态产物,它是有生命的,会随着代码执行而诞生,也会随着引用清零被垃圾回收。
- 诞生的瞬间: 当 Ignition 解释器在运行期,真刀真枪地执行到
CreateClosure这条字节码指令时,V8 才会在堆内存(通常是新生代 New Space,但具体的分配与提升行为会受 GC 与逃逸分析等优化影响)里new出一个真实的JSFunction对象。
核心动作(物理缝合): 这个新建的 JSFunction 本质上是一个“执行容器”。V8 会在它诞生的瞬间,做一次极其神圣的“缝合手术”,为其注入三大核心灵魂指针:
shared_指针(拿图纸): 死死地指向那张静态的 SFI 图纸,获取函数执行的所有静态指令与元数据。context_指针(锁环境): 死死地抓住当前那一刻正在运行的上下文(Context 结构)。这是 V8 最核心的一步——物理锁定函数的词法作用域。feedback_cell_指针(发笔记本的领取凭证): 注意!为了极致的节约内存,V8 在初期通常只会给 JSFunction 发一个feedback_cell的间接引用。真正的“情报小本本”(Feedback Vector)是按需、延迟分配的。一旦分配,它将负责记录函数专属的类型情报。情报猜测的准确度,将直接决定后期特效师(TurboFan)的优化质量;而错误的猜测,则会导致去优化(Deopt)的翻车惨剧。
第三:指针的力量
有很多初学者,甚至工作多年前端开发者,到处吐槽js如何的不堪,如何如何的难用,如何如何的是个缝合怪 指针如何如何的难理解难使用。。。js都默默承受着。
前端八股文里总是背诵:“JavaScript 采用词法作用域,函数的作用域在定义时决定,而非调用时决定”。很多初学者觉得这是一种语言规范的“玄学”,看不见摸不着,甚至经常和 this 的动态指向搞混。
但站在这对核心双子星面前,玄学荡然无存,只剩下冷冰冰的 C++ 指针与绝对确定的物理规则:
解释器在运行时,真正 Call 的永远是带有上下文的 JSFunction,而绝不是光秃秃的 SFI 图纸!
无论这个 JSFunction 被作为回调函数传到了多深的调用栈里,也无论它被 return 到了哪个毫无相干的外部环境去执行。只要它一启动,Ignition 解释器只会做两件固定的事:
- 从
JSFunction里掏出shared_指针,拿到预编译好的字节码和预先算好的寄存器数量,在物理栈上瞬间砸出一个栈帧(Stack Frame),完成极速内存圈地。 - 从
JSFunction里掏出那个在它出生时就被缝合进去的context_钥匙,把它作为当前函数查找外部变量的唯一基准点。所有越界的变量查找,都会顺着这把钥匙指向的堆内存链条(Context Chain)向上摸索。
我们用一个最经典的闭包例子,直观还原这个底层物理过程:
JavaScript
function outer() {let a = 1;// 解释器走到这里,执行 CreateClosure 字节码return function inner() { console.log(a); };
}
const fn = outer();
fn(); // 输出 1
底层的物理动作完全对应我们的规则:
outer执行时,解释器走到inner的函数声明处,触发CreateClosure指令,在堆内存中创建出inner的JSFunction对象。- 缝合瞬间完成:
inner的shared_指针连上预编译好的 SFI 图纸;它的context_指针,被 V8 强行绑定到了outer刚刚在堆上生成的那个包含了a=1的 Context 别墅上。 fn被 return 到了全局环境。此时,虽然outer的 C++ 物理调用栈帧已经被彻底销毁出栈,但是!fn身上的context_指针依然像个铁锚一样,牢牢抓着outer留在堆内存里的那个 Context 别墅,导致它无法被垃圾回收。- 当
fn()被调用时,解释器毫不关心当前是在全局环境,它直接掏出fn肚子里的context_钥匙,顺着指针一开门,精准拿到了a=1,完成打印输出。
总结: 所谓“闭包”,所谓“出身决定命运的词法作用域”,在 V8 底层从来都不是什么虚无缥缈的玄学。它就是 JSFunction 对象内部,那个在 CreateClosure 执行瞬间被刻死、永远指向堆内存中某座特定 Context 别墅的 context_ 物理指针。
如果看过这系列文章的第一部分 解析篇 的朋友,可能会记得,我们在学习解析时,说过, 在预解析时,并不会生成AST,而是会生成一个占位符,并且和SFI相关联,那个时候的SFI,和这里的SFI,有神么区别吗?我们下面就详细的讲一下,把这个延续千年的恩怨给了结了。
先说结论:
它们在 C++ 的物理内存地址上,是 100% 绝对相同的同一个对象, 但是,它的内部状态和装载的数据,经历了一次从“空壳档案袋”到“满配图纸”的变化。
在 V8 的 C++ 源码中,这个过程被称为 Lazy Compilation(惰性编译)。
我们就来回顾一下SFI的前世今生
阶段一:预解析阶段 “只有封面的空档案袋”(Uncompiled SFI)
当 V8 第一次拿到一长串 JS 源码,准备开机建厂时,为了极速启动,预解析器(Pre-parser)只会对没有立即执行的函数进行极其粗略的扫描。
此时,V8 会在堆内存里 new 出一个 SharedFunctionInfo 对象。但这时候的它,是一个半成品。
这个“空档案袋”里装了什么?
- 函数名: 比如叫
foo。 - 源码位置(Source Positions): 记录了这个函数在源码字符串里的起止位置(比如第 10 行到第 20 行)。
- 演员名额(Formal Parameter Count): 扫一眼括号里有几个参数,比如
function foo(a, b)就是 2。 - 特性标记(Flags): 比如标记了这是否是一个严格模式的函数。
它缺少了什么最重要的东东?
- 没有 AST(分镜头原稿): 预解析不生成 AST 树。
- 没有 BytecodeArray(分镜头剧本): 导演还没开工,根本没有指令代码。
- 没有 Frame Size 和 Feedback Metadata: 没编译,当然不知道需要多少寄存器和情报小本本。
- 【非常关键】替身指针: 此时,SFI 内部本该指向机器码或字节码的那个执行指针,被临时指向了一个 V8 内置的 C++ 占位函数,叫做
CompileLazy(懒编译替身)。
阶段二: 触发 CompileLazy
时间来到了运行期。代码里终于有一句 foo() 被调用了!
男一号 Ignition 解释器(或者更准确地说是执行环境)顺着 JSFunction 的指针,找到了这个 SFI 图纸。结果低头一看:“哎呀?剧本(字节码)呢?怎么是个叫 CompileLazy 的替身?”
此时,CompileLazy 被触发,V8 瞬间按下了暂停键,大喊一声:“导演,快写剧本,演员要上场了!”
阶段三: “满配的图纸”(Compiled SFI)
V8 立刻把 foo 函数的源码(根据 SFI 里记录的起止位置提取出来)重新扔给真正的 Parser 和 BytecodeGenerator(导演)。
生成了完整的 AST,接着生成了 BytecodeArray(字节码序列),并算出了 Frame Size(最高水位线)和 FeedbackMetadata(情报表格)。
v8的点睛之笔:
V8 不会去销毁那个旧的 SFI 然后创建一个新的!如果那样做,外面无数个指向旧 SFI 的闭包(JSFunction)全都会变成野指针而崩溃。
V8 的做法是:原位热更新(In-place Update)
它直接把刚刚生成好的 BytecodeArray、Frame Size 和 FeedbackMetadata,“塞进”那个预解析阶段留下的旧档案袋里,并把那个指向 CompileLazy 的占位指针,替换成真正指向字节码执行入口的指针。
总结:SFI 的“前世今生”
我们用表格对比一下同一个 SFI 对象在两个阶段的状态:
| 属性 / 内容 | 预解析阶段 (Uncompiled SFI) | 真正调用后 (Compiled SFI) |
|---|---|---|
| 片场比喻 | 只有封面的空档案袋 | 装满指令的图纸 |
| 物理内存地址 | 0x1234abcd |
0x1234abcd (同一个地址,原位更新) |
| 源码起止位置 | 已有 (记录了从哪到哪) | 保持不变 |
| 形参个数 (参数名额) | 已有 (如 2) |
保持不变 |
| AST (分镜头原稿) | 无 | 编译瞬间生成 (生成字节码后通常被丢弃) |
| BytecodeArray (剧本) | 无 | 被填入完整的字节码序列 |
| Frame Size (钢印) | 无 | 被填入确切的虚拟寄存器数量 |
| FeedbackMetadata | 无 | 被填入情报小本本的格式规范 |
| 执行入口指针 | 指向内置的 CompileLazy 替身 |
指向真正的字节码入口代码 |
那么为什么不一开始就全部编译好呢?
因为前端网页有太多类似下面这种“写了但可能永远不执行”的代码(比如点击某个冷门按钮才会触发的回调):
JavaScript
document.getElementById('hidden-btn').addEventListener('click', function massiveFunction() {// 几千行极其复杂的逻辑
});
如果在网页加载时,V8 就把 massiveFunction 完整编译成字节码,不仅会严重拖慢网页的首屏显示速度,还会白白浪费大量的内存,尤其是手机内存。先建个“空档案袋(Uncompiled SFI)”占着坑位,等用户真的点下按钮时再“填补剧本”,这是 V8 在极速启动与极致内存之间的平衡知道。
5. Script Function 和 Entry Frame
包装一切的 Script Function
当我们在 app.js 里写下第一行看起来自由的顶层全局代码时,比如:
JavaScript
var a = 1;
console.log(a);
很多朋友可能会以为,这些代码就像吹散的蒲公英一样,直接散落在名为“全局”的空间里。
其实并不是那样,在 V8 的底层视角里,根本不允许存在“散落代码”。
为什么不允许?
因为 V8 的整个编译流水线(从 Parser 生成 AST,到 BytecodeGenerator 生成字节码),其唯一能识别的“根节点”和“工作单元”,必须是函数(Function)。AST 树必须有一个树根,字节码序列必须有一个归属容器。它们无法接受零散的游离语句。
因此,V8 编译器在解析 JS 文件时,必须玩一个偷天换日的障眼法:它悄悄地把这整个文件里的顶层代码,全部编译成一个“类似函数”的顶级代码对象(Top-level Code Object)。在引擎内部,你可以把它视为一个隐式的 Script Function(脚本函数)。它的核心元信息(代码物理起止位置、词法作用域、包含多少个内层闭包),会被极其严谨地打上钢印,记录在对应的 SharedFunctionInfo (SFI) 图纸及 Script 结构中。
我们以为自己写的是“全局代码”,但在引擎眼里,这不过是这个庞大匿名函数肚子里的“内部逻辑(函数体)”而已。
这个 Script Function 有 3 个特殊的底层性质:
- 无显式函数名: 它是引擎内部的特权实体,JS 代码无法通过名字直接调用它。当你在浏览器控制台看到报错堆栈最底部的
(anonymous)时,那往往就是它的物理真身。 this指向: 在浏览器的传统<script>标签中,顶层this指向全局对象(window)。在 Node.js 普通文件(CommonJS 模块)中,顶层this绝对不等于global! 为什么?因为 Node.js 在把代码交给 V8 之前,在外部又暴力套了一层真实的字符串外衣:(function (exports, require, module, __filename, __dirname) { 你的代码 \n });。所以模块顶层的this实际上等同于module.exports。在 ES 模块(type="module"或.mjs)中,由于模块规范要求默认处于严格语义(Strict Mode),顶层的this永远是undefined。- 作用域链起点为全局 Context: 它的作用域链起点,在当前 Isolate 里的全局 Context(逻辑摄影棚)上。这意味着,当你在顶层写下
var a = 1时,引擎实际上是在这个隐式函数执行时,顺着这根被锁死的指针,找到了全局摄影棚,并把a这个道具摆在了大厅的正中央。
就像在我们的片场: 写了一堆零散的表演动作,制片厂绝不会让演员在马路上瞎演,它会强行给套上一个名叫《第一集:试播集》(Script Function)的剧集外壳。所有的全局动作,都不过是这一集里的剧情。
现在,剧本包装好了(Script Function),图纸(SFI)和实体(JSFunction)也都完美缝合了。但是,V8 引擎本质上只是一个被嵌入的 C++ 库,它绝对不会主动去给自己找活干。
真正掌握生杀大权、决定什么时候开机的,是宿主环境(Host Environment)——比如 Chrome 浏览器主进程,或者 Node.js 底层的 C++ 核心代码。
宿主环境,才是真正出资组建这一切的“大老板 / 制片人”。关于片场宇宙的设定,可以往上翻翻,复习一下。
不同的宿主环境,触发这声开机指令的场景也完全不同:
- Chrome 浏览器: 当页面加载完 HTML 中的
<script>标签、执行eval动态代码、或是调用new Function创建函数时。 - Node.js: 当执行入口文件
node app.js或是执行 REPL 环境中的输入代码时。(前面在讲this指向时讲过,在使用require加载 CommonJS 模块时,Node.js 还会给代码额外套上一层function(exports, require, module...){}的外衣,但剥开这层特定外衣,扔给 V8 执行的最底层机制依然同理)。
当物理片厂(Isolate)建好了,逻辑摄影棚(Context)也搭好了,大老板拿着那个包装好的 Script Function 走向 V8 引擎,重重地按下了那个跨越两个世界的底层 API 按钮:
v8::internal::Execution::Call
“都出来干活了,把整个脚本跑起来!”
随着这句 C++ 代码的执行,宿主程序正式向 V8 引擎下达了开机指令。
但这同时引出了一个问题:
C++ 大老板这一个命令出来,就意味着操作系统的物理 CPU 要从执行 C++ 编译出来的机器码,瞬间切换去执行 V8 解释器里的指令了。
万一里面有个死循环,或者爆出了一个致命的未捕获错误,会不会把大老板(Node.js 或浏览器进程)直接带着一起崩溃坠崖?
为了防止这种情况,在真正拔起第一个 JS 栈帧之前,V8 必须在悬崖底下铺上一张极其厚实的“防爆缓冲垫”。
这就是跨界防爆门——Entry Frame(入口帧)。
大老板(C++ 宿主)扣动了 Execution::Call 的扳机,但操作系统的物理 CPU 并不会直接“瞬移”到 JavaScript 的代码里去执行。
因为这是两个不同的世界
C++ 代码编译出的机器码,严格遵循着操作系统底层的 应用程序二进制接口调用约定,它把极其重要的系统状态保存在 CPU 的物理寄存器里(比如 rbp 栈底指针、rsp 栈顶指针,以及各种非易失性寄存器)。
而 V8 的 Ignition 解释器,是完全不按 C++ 规则运行的野路子。一旦让它接管 CPU,它会在物理内存里疯狂圈地、读写累加器、频繁变动栈顶指针,它有自己的一套寄存器使用策略。如果直接让它冲进去,C++ 保存在物理寄存器里的核心数据瞬间就会被踩得稀巴烂。
等 JS 代码跑完,CPU 回头一看:我是谁?我在哪?C++ 的执行现场全没了。操作系统会直接报出 Segmentation fault(段错误),把整个进程当场干掉。
为了防止这种同归于尽的惨剧,在真正建立第一个 JS 栈帧之前,V8 必须在悬崖底下,铺上一张极其厚实的“防爆缓冲垫”。
C++ 与 JS 的物理界碑
在执行任何一句 JS 字节码之前,V8 会先执行一段小型汇编代码片段(Stub)——也就是 JSEntry Stub。这段极速的底层汇编跳板代码,会在操作系统的物理堆栈上,强行砸入一个极其特殊的栈帧——Entry Frame (入口帧)。
它是横亘在 C++ 静态世界与 JS 动态世界之间的一道“气闸舱”:一边连接着 C++ 的物理寄存器规则,一边连接着 JS 的虚拟栈帧逻辑。
不仅如此,它还充当了两个世界之间的“海关”。 C++ 大老板调用时传递过来的参数,通常是一个 C++ 的数组指针(argv),JS 引擎是无法直接使用的。JSEntry Stub 会在建立 Entry Frame 的同时,负责把 C++ 数组里的参数一个个取出来,严格按照 JS 的调用约定(Calling Convention)物理压入栈中,完成数据的“跨界偷渡”。
作用:物理现场的绝对冻结
Entry Frame 砸入物理栈后的第一件事,就是封存历史。
它会把 C++ 世界此刻所有关键的物理寄存器状态——包括 rbp/rsp 等栈指针,以及所有非易失性寄存器(用于保存 C++ 的局部变量和调用上下文)——原封不动地全部压入自己所在的这片栈内存中保存起来。
完成封存后,它才放心地给 Ignition 解释器放行:“去吧,尽情去折腾 CPU 寄存器吧,C++ 的老家我已经替你们锁好了。”
兜底保障:跨越生死的完美退场
Entry Frame 不仅负责把 C++ 安全地送进去,更负责把结果安全地接回来。这里有两种情况:
- 常规杀青(正常返回): 当顶层的 JS 脚本(Script Function)正常执行到了最后一行
Return。控制流跳回 Entry Frame,它从容地从栈上把之前保存的物理寄存器数据塞回 CPU。指针一转,C++ 宿主程序就像什么都没发生过一样,拿着 JS 返回的结果继续往下跑。 - 重大生产事故(未捕获异常): 这是它作为“防爆门”最伟大的时刻。假设你的 JS 代码里抛出了一个错误
throw new Error("Boom!"),并且没有被任何try-catch捕获。- V8 引擎的异常处理机制会开始疯狂地“栈展开(Stack Unwinding)”——它会沿着栈链向上回溯,残忍地一层一层撕毁所有的 JS 栈帧、释放对应的栈空间,试图寻找能处理错误的 Catch 块。
- 当它撕毁了所有 JS 栈帧,一路倒退,最终重重地撞在 Entry Frame 这扇防爆门上时,撕毁动作会被强制逼停!
- 此时,
JSEntry Stub会检查 Isolate 线程内部的pending_exception(待处理异常)标志位。 一旦发现有致命错误,Entry Frame 会把这个致命的 Error 包装成一个安全的 C++ 可处理对象,通过宿主设置的v8::TryCatch机制传递出去,然后恢复 C++ 的寄存器现场,平稳地把错误交还给宿主大老板。
结果就是: 这就是为什么你的 Node.js 代码报错时,终端里只会优雅地打印出一段红色的 Error 堆栈字符串然后正常退出,而不是直接让整个操作系统进程崩溃的原因。
正是 Entry Frame 的默默扛下所有,才保全了宿主进程的体面。
伴随着 Entry Frame 稳稳扎入物理内存,两个世界的安全通道彻底打通。
大老板的参数已经静静地躺在栈上,等待被认领。
控制权,正式移交给 Ignition 解释器。这中间通常通过一个名为 InterpreterEntryTrampoline 的内置代码片段作为跳板,它是通往字节码世界的第一级台阶。
第一个真正的 JavaScript 栈帧,即将拔地而起!
补充内容 ------从解析篇到现在,时间太久了,我不确定有没有写过这部分相关的内容,只记得3月份的那篇提到过栈帧大小的事,多写总比少写好。
栈帧图纸的数字烙印
在 Entry Frame 铺好缓冲垫、控制权刚刚交接给 Ignition 解释器的这一瞬间,时间仿佛静止了。
在解释器准备大干一场、往物理内存里圈地建栈帧之前,我们必须先回答一个直击灵魂的底层问题:
解释器怎么知道这个即将开机的“剧组(栈帧)”,到底需要多大的占地面积?它怎么知道要准备几把“椅子(参数和局部变量)”?
难道解释器要在每次函数被调用时,先临时去把函数体里的代码从头到尾扫描一遍,数一数里面有几个 let、几个 var、需要多少个临时变量,然后再决定向操作系统申请多大的内存吗?
这是不可能的。
如果把这笔账留在运行期去算,那么每次函数调用的开销就会变成 O(N)(N 为代码复杂度)。对于那些在 requestAnimationFrame 里每秒执行 60 次,甚至在 for 循环里执行千万次的高频函数来说,这种运行时的扫描损耗是灾难性的。
V8 的底层思路是:永远不要在运行期,去做任何可以在编译期完成的事。
实际上,栈帧的大小和参数的数量,早在之前的编译阶段,就已经被精确计算出来,并且作为“死数据”死死地烙印在图纸上了。
(1)演员名额的核定:Formal Parameter Count(形参数量)
当 Parser(解析器)在编译期第一次扫过你的代码 function foo(a, b, c) 时,它就已经确定了这个剧组需要 3 个正式演员。
这个数字 3(即 FormalParameterCount)会被直接硬编码写入到这把函数的公共图纸——SharedFunctionInfo (SFI) 的元数据中。(注:除了这 3 个明面上的演员,引擎还会暗中加上 1 个隐形大佬——this 接收者,作为雷打不动的零号位参数,这个知识点,我记得前面在哪个地方讲过,好像在ignition上篇?)。
为什么必须记下这个数字?
因为 JS 是一门极其自由灵活的语言。你规定了 3 个参数,但大老板(调用者)执行时完全可能乱塞 5 个参数,或者只给 1 个参数。
在接下来的建组阶段,解释器必须拿着图纸上的这个标准数字 3,去和调用者实际压入栈的参数进行“对账(参数适配)”,以保证栈帧结构的绝对规整。
(2)场务的精打细算:最高水位线 (High-Water Mark)
参数数量决定了栈帧的“上半部分(参数区)”,而函数内部的局部变量和临时计算,决定了栈帧“下半部分(工作区)”的大小。
在上一篇中,我们讲过场务(BytecodeRegisterAllocator - 字节码寄存器分配器)。他在陪着导演生成字节码时,干了一件极其了不起的事:极限复用。
- 场务看到显式声明
let x,就分配一个常驻寄存器r0。 - 看到一个复杂的加法运算
a + b,就分配一个临时寄存器r1暂存结果。一旦加法算完,r1立刻被场务无情收回,借给下一行代码的乘法继续使用。
在整个 AST(抽象语法树)被遍历完、最后一条字节码生成完毕的杀青时刻,场务会翻开他的账本,统计出一个决定性的数据——最高水位线(Maximum Register Count):即在这个函数逻辑最复杂、嵌套最深的那个瞬间,同时最多需要用到多少个虚拟寄存器(这里的“同时用到”已经包含了显式局部变量和临时变量的最大并发数)。
假设场务算出来,最高水位线是 5 个寄存器。这个数字,就是作为不可篡改的物理钢印,被死死烙印在 BytecodeArray 对象头部的 frame_size,
它记录的是“需要预留的寄存器槽位个数”,而不是直接的物理字节数。
我们通过两个例子来看下计算过程:
例一:基础运算的临时借用
JavaScript
function calc(a, b) {let x = 100;let y = (a + b) * x;return y;
}
那个极度抠门的场务(Register Allocator)在编译期推演:
-
遇到
let x = 100:分配常驻虚拟寄存器r0。 -
遇到
(a + b):借用临时寄存器r1存结果。 -
遇到
* x:将r1和r0相乘,结果放入新的常驻寄存器r2(代表变量y)。推演结论: 此函数并发最高时,同时征用了 3 个虚拟寄存器。
例二 控制流分支的极限使用
很多前端以为:我声明了几个变量,就会占用几个坑位。 这样的理解是不正确的,看下面这段代码:
JavaScript
function process(type, val) {let result = 0; // 分配 r0if (type === 'A') {let tempA = val * 2; // 分配 r1result = tempA + 10;} else {let tempB = val / 2; // 场务极其抠门,直接复用 r1 !!result = tempB - 5;}return result;
}
在这个例子中,代码里明明声明了 result、tempA、tempB 三个局部变量。
但场务在推演时发现:tempA 和 tempB 存在于两个绝对互斥的 if/else 分支中,它们在物理时间线上永远不可能同时存活。
因此,场务会极其冷酷地让 tempA 和 tempB 共享同一个物理寄存器 r1!
推演结论: 尽管声明了 3 个变量,但这个函数的最高水位线只有 2 个虚拟寄存器(r0 和 r1)。
场务会将这个极限压榨出来的最高水位线数字(如例一的 3,例二的 2),死死地写入 BytecodeArray 的头部元数据中。
极速物理圈地
注意,图纸上记载的只是“寄存器需求数量(Metadata)”,它并不是最终的物理字节数。
当男一号登场前一瞬,InterpreterEntryTrampoline 会极速读取图纸上的 Metadata(假设最高水位线是 3 个寄存器),然后在脑子里进行一次绝对精确的汇编级心算:
(注:以下推演为一个基于 64-bit x86 架构的理想化核心模型。在真实的 V8 引擎中,实际的物理内存布局会因不同的操作系统 ABI、CPU 架构(如 ARM)以及编译器的具体优化策略而有所差异,但这丝毫不影响我们理解其 O(1) 圈地的本质。)
- 计算工作区:
3 个寄存器 × 8 字节(64位系统指针大小) = 24 字节。 - 叠加固定帧头(Fixed Header): 任何 JS 栈帧必须包含基建数据,通常包括:
Return Address(返回地址)Previous Frame Pointer(指向上一个栈帧的 rbp,用于异常回溯)Context Pointer(当前函数所在的逻辑摄影棚指针)JSFunction Pointer(当前正在执行的双子星实体自身的指针) 这 4 个固定槽位,占了4 × 8 = 32 字节。
- 平台内存对齐: 操作系统通常要求栈内存在 16 字节边界对齐,以保证 CPU 缓存行读取效率。
最终心算结果: 24 (工作区) + 32 (固定头) = 56 字节。为了对齐 16 的倍数,最终向上补齐到 64 字节。
算完这个绝对精确的数字后,InterpreterEntryTrampoline 对物理内存挥出那极速的 O(1) 一刀,它直接将物理 CPU 的栈顶指针(比如 x64 下的 rsp)向低地址狠狠拉伸 64 个字节:
sub rsp, 64
只用了一条毫无波澜的机器指令,全场所需的所有槽位、固定帧头、运行期空间,瞬间在物理内存中拔地而起!
三万字了,又要分篇了。下篇再见。
五一快乐。
本文首发于: 掘金社区
同步发表于: csdn
博客园
码字虽不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,
欢迎转载,请保持全文完整。
谢绝片段摘录。
