mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5427 字
14 分钟
深入解析JavaScript——V8引擎
2026-01-26

当 JavaScript 遇上 V8 引擎#

如何从设计哲学、编译原理(JIT/V8)以及内存模型的角度切入,更好的理解和复习 JavaScript。我们从 mozilla 官方的 JavaScript 指南开始复习,首先我们从介绍和语法与数据类型开始讲起,虽然这两个章节在 MDN 当中是分开的,但是从底层逻辑上看来,它们共同了共同的基础,即:程序是怎么被解析的,数据是怎么存储的

JS 设计哲学、声明方式以及内存模型#

JavaScript 执行模型#

JavaScript 执行模型其实主要指的是 JavaScript 运行时环境的基本架构。该模型主要是理论性的、抽象的,没有具体的特定的实现细节。

引擎和宿主#

JavaScript 的执行需要两个软件的配合:JavaScript 引擎和宿主环境 JavaScript 引擎实现了 ECMAScript 语言,提供了核心功能。他接收源代码,对其进行解析并执行。但是,为了与 V8 引擎之外的外部世界进行交互的时候,比如产生任何有意义的输出、与外部资源接口,或实现与安全或性能相关的机制,我们需要由宿主环境提供额外的特定环境机制。例如,在 web 浏览器中执行 JavaScript 时,HTML DOM 就是宿主环境。Node.js 是另一种运行 JavaScript 在服务端运行的宿主环境。通常情况下,Node.js 或者 Deno 会模仿我们即将提到的机制,所以我们这里就能给出一个较为通用的解释

代理执行模型#

在 JavaScript 的规范中,JavaScript 每个自主执行器都被成为代理,它维护着自己的代码执行设施:

  • (Objects)堆:这里只是一个名称,用来表示内存中的一个大区域(大多是非结构化的)。当程序中创建对象时,它就会被填充。如果在共享内存的情况下,每个代理都有一个自己的堆,每个堆当中都有自己版本的SharedArrayBuffer对象,但缓冲区所代表的底层内存是共享的
  • (Homework)队列:正在 HTML 中通常被称为事件循环(event loop),它可以在 JavaScript 中实现异步编程,同时又是单线程的。之所以称其为队列,是因为它通常是先入先出:先执行的工作在后执行的工作之前。
  • (Execution Context)栈:这就是所谓的调用栈(call stack),允许通过进入和退出执行上下文(如函数)来传输控制流。之所以称为栈,是因为他是先进后出的。每个任务进入时都会向(空)栈中推入一个新帧,退出时则会清空栈。

这是三种不同的数据结构,用于跟踪不同的数据。每一个代理都类似于一个线程,每个代理可以拥有多个域(与全局对象一一对应),这些代理可以同步访问彼此,因此需要在单个执行线程中运行。一个代理也有一个单一的内存模型,表明他是否是小端序的、是否可以同步阻塞、原子操作是否无锁等。

在 web 上,代理可以是一下的内容:

  • 一个包含各种Window对象的相似源 window 代理,这些对象有可能直接或通过使用document.domain相互联系。如果窗口按源成键,则只有同源窗口才能互相联系。
  • 一个包含DedicatedWorkerGlobalScope的专用 Worker 代理
  • 一个包含SharedWorkerGlobalScope的共享 Worker 代理
  • 一个包含ServiceWorkerGlobalScope的 Service worker 代理
  • 一个包含WorkletGlobalScope的 Worklet 代理

换句话说,每个 Worker 创建自己的代理,而一个或多个窗口可能在同一个代理之中,通常是一个主文档及其类似的源 iframe。在 Node.js 中,也有一个类似的概念,被称为worker线程

示例图

#

每个代理都拥有一个或多个(realm)。每一段 JavaScript 代码在加载时都会与一个相关域相关联,即使从另一个领域调用也不会改变。域由以下的信息构成:

  • 固有对象列表,如Array, Array.prototype等。
  • 全局声明的变量、globalThis的值以及全局对象。
  • 模板字面数组的缓存,因为对同一标记的模板字面表达式的求值总是会导致标记接收到相同的数组对象

在 Web 上,域和全局对象是一一对应的。全局对象可以是WindowWorkerGlobalScopeWorkletGlobalScope。因此,每个iframe都在不同的域中执行,尽管他可能与父窗口在同一个代理当中。在讨论全局对象的身份时,通常会提到域。例如,当我们需要Array.isArray()Error.isError()这样的方法,因为在另一个域构建的数组的原型对象与当前域中的Array.prototype对象不同,因此instanceof Array将错误的返回false

栈与执行上下文#

我们首先考虑同步代码执行。每个作业通过调用相关的回调进入。回调中的代码可以创建变量、调用函数或退出。每个函数都需要追踪自己的变量环境和返回位置。为此,代理需要一个堆栈来跟踪执行上下文。执行上下文一般也称为栈帧,是执行的最小单位。他会追踪以下的信息:

  • 代码评估状态;
  • 包含此代码的模块或脚本、函数(如适用)以及当前执行的生成器;
  • 当前的域;
  • 绑定,包括:
    • varletconstfunctionclass等定义的变量;
    • 私有标识符,如#foo,仅在当前上下文中有效;
    • this引用;

举个例子

function foo(b) {
const a = 10;
return a + b + 11;
}
function bar(x) {
const y = 3;
return foo(x + y);
}
const baz = bar(7); // 将42赋值给baz
  1. 任务启动时,会创建第一个栈帧,其中定义了变量foobarbaz。它使用参数7调用bar
  2. bar调用创建第二个栈帧,其中包含参数x和局部变量y的绑定。它首先执行乘法运算x * y,然后使用结果调用foo
  3. foo调用创建第三个栈帧,其中包含参数b和局部变量a的绑定。它首先执行加法运算a + b + 11,然后返回结果。
  4. foo返回时,最上面的帧元素从堆栈中弹出,调用表达式foo(x * y)解析为返回值。然后继续执行,也就是返回这个结果。
  5. bar返回时,最上面的帧元素从堆栈中弹出,调用表达式bar(7)解析为返回值。这样就用返回值初始化了baz
  6. 我们到达了作业源代码的末尾,因此入口点的栈帧被从堆栈中弹出。堆栈为空,因此作业被视为已完成。

生成器与重入#

栈帧弹出后,并不一定会永远消失,因为有时候我们需要回到它。例如,我们以一个生成器函数作为例子:

function* gen() {
console.log(1);
yield;
console.log(2);
}
const g = gen();
g.next(); // 输出1
g.next(); // 输出2

在这种情况下,调用gen()首先会创建一个挂起的执行上下文——gen内部的代码不会被执行。生成器g会在内部保存这个执行上下文。当前正在运行的执行上下文仍然是入口点。调用g.next()时,gen的执行上下文会被推入堆栈,gen内部的代码会一直执行到yield表达式。然后,生成器的执行上下文被挂起并从堆栈中移除,控制权返回入口点。再次调用g.next()时,生成器的执行上下文会被推回堆栈,gen内部的代码会从上次中断的地方继续执行。

尾调用#

正确的尾调用是规范中定义的一种机制,即(proper tall call, PTC)。如果调用者在调用后除了返回值外什么也不做,那么该函数调用就是尾部调用:

function f() {
return g();
}

在这种情况下,对g的调用是尾调用。如果函数调用处于尾部位置,引擎需要丢弃当前的执行上下文,替换成尾部调用的上下文,而不是为g()调用推送一个新帧。这意味着尾递归不受堆栈大小的限制:

function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc);
}

实际上,如果我们丢弃当前帧就会导致调试问题,因为如果g()引发错误,f就不再在堆栈上,也不会出现在堆栈跟踪中。目前,只有 Safari(JavaScriptCore)实现了 PTC,而且它们还发明了一些特定基础设施来解决调试的问题。

闭包#

与变量范围和函数调用相关的另一个现象是闭包,据说很有趣。。。每当创建一个函数时,它也会在内部记忆当前运行的执行上下文的变量绑定。这样,这些变量绑定就可以超越执行上下文。

let f;
{
let x = 10;
f = () => x;
}
console.log(f()); // 输出10
WARNING

关于闭包的内容之后会专门写一篇文章再总结和测试

作业队列与事件循环#

单个代理是一个线程,这意味着解释器一次只能处理一条语句。当代码都是同步的时候,这没有问题,因为我们总是能取得进展,即正常的按照条目进行执行。但如果代码需要执行异步操作,那么除非该操作完成,否则我们就无法取得进展。但是,如果整个程序因此而停止,就会影响用户体验——JavaScript 作为 Web 脚本语言的特性要求他永不堵塞。因此,处理异步操作完成的代码被定义为回调。该回调会定义一个任务,一旦动作完成,该任务就会被放入一个任务队列,或者被称为一个事件循环(来自 HTML 的术语)

每次,代理都会从队列中拉出一个作业并执行它。作业执行完毕后,可能会创建更多的作业,这些job会被添加到队列末尾。job也可以通过异步平台机制(例如定时器、I/O和事件)的完成而添加。当堆栈为空时,任务被视为已完成;然后,下一个任务会从队列中去除。任务的取出优先级可能并不统一——例如,HTML事件循环将任务分为两类:任务和微任务。微任务的优先级更高,微任务队列会优先于任务队列清空

”Rum-to-completion”运行至完成#

每个任务都会在其他任务处理之前完整执行完毕。这为程序逻辑推理提供了一些优势,例如:函数一旦执行,就无法被抢占,并且会在任何其他代码之前完全执行完毕(并且可以修改函数操作的数据)。这与C语言不同,例如,在C语言中,如果一个函数在一个线程中运行,运行时系统可以随时将其停止,以便在另一个线程中执行其他的代码。

例如

const promise = Promise.resolve();
let i = 0;
promise.then(() => {
i += 1;
console.log(i);
});
promise.then(() => {
i += 1;
console.log(i);
})

在上面的例子中:我们创建了一个已经被解析的Promise,即Promise.resolve(),这意味着任何附加到他的回调函数都会立即被调度为任务。这两个回调函数看似会导致竞争,但实际上,输出是完全可以预测的:12会按顺序被记录。这是因为每个任务都会在下一个任务执行之前被完成,所以任务的整体顺序是可以被推测出来的,在我们这里:i += 1; console.log(i); i += 1; console.log(i),而永远不会是i += 1; i += 1; console.log(i); console.log(i);

但是这种模型具有这样一个缺点:如果某个任务耗时过长,Web应用程序将无法处理用户交互,例如点击或滚动。浏览器会通过显示“脚本运行时间过长”对话框来缓解这个问题。优化的方式则是缩短任务的处理时间,并尽可能将一个任务拆分为多个任务。

永不阻塞#

事件循环模型提供的另一个重要保证则是JavaScript执行永远不会阻塞。I/O处理通常通过事件和回调函数完成,因此当应用程序等待IndexedDB查询或fetch()请求返回结果时,它仍然可以处理其他任务,例如用户输入。异步操作完成后执行的代码始终以回调函数的形式提供(例如,Promise的then()处理程序、setTimeout()中的回调函数或事件处理程序),该回调函数定义了一个任务,该任务将在操作完成后添加到任务队列中。

当然,“永不阻塞”的保证,要求平台API本身是异步的,但一些遗留的例外情况是仍然不可忽视的,例如alert()或同步XHR。为了确保应用程序的响应速度,避免使用这些例外情况被认为是良好的。

代理集群和内存共享#

多个agent可以通过内存共享进行通信,形成agents集群。agent属于同一个集群当且仅当它们可以共享内存。系统没有内置机制允许两个代理集群交换任何信息,因此它们可以被视为完全隔离的执行程序。 创建代理(例如通过生成工作节点)时,需要满足一些条件来判断它是否与当前代理位于同一集群中,因此可以彼此共享内存。

  • 一个Window对象和它创建的专用工作进程
  • 它创建一个任何类型的worker和一个专职的worker
  • Window对象A和A创建的同源iframe元素的Window对象
  • 一个Window对象和一个同源的Window对象(说明该对象打开了他才会发生这种情况)
  • 一个Window对象和它创建的工作区

以上的这些都可以彼此共享内存,而以下的几对全局对象不再同一个代理集群中,是无法共享内存的:

  • 一个Window对象和它创建的共享工作进程
  • 一个工作进程(任何类型的进程)和它创建的共享工作进程
  • 一个Window对象和它创建的Service Worker
  • Window对象A和A创建的iframe元素的Window对象,但该iframe元素不能与A属于同一源域。
  • 任意两个没有打开或先祖关系的Window对象,即使这两个Window对象来自在同一源

以上内容仅供暂时参考,具体的还得去看官方文档中的HTML规范

跨代理通信和内存模型#

和上面我们所将的一致,代理通过内存共享进行通信。在Web端中,内存共享通过postMessage()方法实现。Web Worker里面对此有概述,简单的来说,通常,数据仅按值进行传递(通过结构化克隆)因此不会涉及任何的并发问题。要共享内存,必须发布一个SharedArrayBuffer对象,该对象可以被多个代理同时访问。一旦两个代理通过SharedArrayBuffer共享对同一内存的访问,它们就可以通过Atomics对象同步执行。

共享内存有两种不同的方式:普通内存访问(非原子操作)和原子内存访问。后者具有顺序一致性,而前者是无序的;JavaScript不提供任何保证顺序的操作。

并发性及确保程序顺序运行#

当多个代理协同工作时,永不阻塞的保证不一定是一定成立的。一个代理可能会因为等待另一个代理执行某个操作而陷入阻塞或停止状态。这与等待同一个代理中的Promise不同,因为阻塞会导致整个代理停止运行,并且在此期间不允许其他的代码执行——也就是说,他无法进行后续的操作。

为了防止死锁,对何时以及哪些代理可以被阻塞有一些严格的限制:

  • 每个拥有专用执行线程且未被阻塞的代理最终会取得进展
  • 在一组共享同一执行线程的代理中,最终总会有一个代理取得进展
  • 除非通过提供阻塞功能的显示API,否则代理不会导致其他代理被阻塞
  • 只有特定类型的代理可以被阻止。在Web,专用工作进程和共享工作进程是不能被阻止的,但是并不包括同源窗口或Service Worker。

内存管理#

一些底层语言中(例如C语言)拥有手动的内存管理原语:例如:malloc()free()。相反,JavaScript是在创建对象时自动分配内存,并在不再使用时自动释放内存(即GC,垃圾回收)。不过这个自动通常是导致代码混乱的潜在根源:他会让开发者产生一种错觉,只要我们有了GC,就可以不再担心内存管理。

内存生命周期#

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(无论是读还是写)
  3. 不需要当前内的时候就释放他

在所有的语言中,第二点都是显式的。在底层语言中,第一点和第三点都是显式的,而在高级语言中,大多数都是隐式的

JavaScript的内存分配#

值的初始化#

为了不让程序员费心分配内存,JavaScript在值初次声明时自动分配内存

const n = 123; // 为数值分配内存
const s = "azerty" // 为字符串分配内存
const o = {
a: 1,
b: null,
}; // 为对象及其包含的值分配内存
// 为数组及其包含的值分配内存
const a = [1, null, "abra"];
function f(a) {
return a + 2;
} // 为函数以及可调用对象分配内存
// 函数表达式也可以分配内存
someElement.addEventListener(
"click",
function() {
someElement.style.backgroundColor = "blue";
},
false,
);
通过函数调用分配内存#

有些函数调用的结果是为对象分配内存

const d = new Date(); // 为Date对象分配内存
const e = document.createElement("div"); // 为DOM元素分配内存

编译原理视角:JavaScript 到底是一个什么样的语言?#

  • 面试考点: 解释型语言 vs 编译型语言
  • 从底层视角:在现在的 JS 当中,他并不是一个纯粹的解释执行语言。在谷歌开发的 V8 引擎当中:他采用了JIT(Just-In-Time)的编译策略
    • 解析(Parsing): 源码转换为抽象语法树
    • 解释(Ignition): 解释器将 AST 转换为字节码并执行
    • 优化(TurboFan): 编译器将热点代码(即多次执行的代码)直接编译为机器码以提升性能。
  • 设计思想:JS 是一门具有函数优先特性的、动态类型的、基于原型的语言。

变量声明的本质: var, let, const#

  • 面试考点: 块级作用域、暂时性死区(TDZ)、变量提升
  • 底层深度:
    • 执行上下文(Execution Context):在代码执行前,JS 引擎会先创建执行上下文。var 会被放入**变量环境 ( Variable Environment )**中,初始化为 undefined(这就是提升的本质)
    • 词法环境(Lexical Environment): letconst被放入词法环境中。虽然它们也会被“创建”,但在代码执行到声明处之前,它们处于未被初始化的状态,任何访问都会触发 TDZ 错误
    • 内存分配: const保证的是栈内存中存储的值(或地址)不可变。如果是引用类型,堆中的内容依然是可变的

数据类型:JS 的 8 种内置类型与内存分布#

  • 面试考点: 基本类型 vs 引用类型,typeof null为什么是object
  • 底层深度:
    • 栈(Stack):存储基本类型(Number, String, Boolean, null, undefined, symbol, bigint)。
    • 堆(Heap): 存储引用类型(Objects)。
    • 类型标签(Type Tag): 为什么typeof null === 'object'?这是 JS 早期的一个 Bug。在原始底层实现中,值通过“类型标签+值”存储。对象的标签是 000,而 null 表示的是空指针,在很多机器上就是全 0,导致引擎将他误判成对象。
    • BigInt 的意义:解决 JS 中Number(基于 IEEE 754 双精度浮点数)无法精确表示大于 2^53 - 1 整数的问题

变量提升基本流程#

  • 重点总结:
    1. 函数提升优先于变量提升
    2. 函数声明(function a() {})会被完整提升,而函数表达式(var a = function() {})只会提升变量名。
    3. **为什么要设计提升?**最初是为了解决函数间循环调用的问题,让函数在定义前就能互相引用
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

深入解析JavaScript——V8引擎
https://fatfathao.top/posts/in-depth-analysis-javascript-01/
作者
FATFATHAO
发布于
2026-01-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录