js 引擎原理✅
解释性语言和编译型语言的区别
答案
区别 | 编译型语言 | 解释型语言 |
---|---|---|
执行方式 | 运行前整体编译为机器码 | 运行时逐行解释执行 |
生成文件 | 生成独立可执行文件 | 不生成可执行文件,需解释器运行 |
执行速度 | 通常较快(已是机器码) | 通常较慢(边解释边执行) |
跨平台性 | 需针对不同平台分别编译 | 跨平台性好,只需相应解释器 |
错误发现时机 | 编译阶段发现大部分语法/类型错误 | 运行时才发现错误 |
代表语言 | C、C++、Go、Rust | JavaScript、Python、Ruby、PHP |
说一下 v8 是如何执行 javascript 语句的 ?
答案
整个流程如下图
- 加载(loading) 从网络或者本地缓存等读取代码的字符流,转换为 UTF-16 的字符编码。
- 解析(parser) 将读取的字符流转换为抽象语法树
(AST)
。同时生成作用域相关信息, 进一步又可细分为- scan 将字符流转换为 词法单元(token),解析包括标识符、关键字、运算符等信息
- preparser 对于非执行的代码会做预解析,用于检查语法错误等,这个阶段在 V8 内部又叫做,lazy parsing,因为它不会立即生成完整的 AST,而是延迟到真正需要执行时才进行完整解析。
- parse 对于会执行的代码会完全解析生成 AST 和对应的作用域关系。这个阶段又被叫做 eager parsing,因为它会生成完整的 AST 和作用域信息。
- 解释(interpreter) 会将 AST 转换为字节码,之所以需要字节码是为了解决直接编译为机器码导致的内存消耗过大和启动速度慢的问题。
- 运行(execution) 运行字节码,在运行中会生成一些额外的信息用来作为优化的输入
- 优化(optimization) 基于字节码和运行的信息,会对热点代码进行优化,V8 使用 TurboFan 来将字节码转换为机器码。
- 去优化(deoptimization) 代码执行过程中,如果发现某些优化不再适用,V8 会回退到未优化的字节码或解释器执行。
此外 V8 也支持 wasm 的解释和运行,wasm 代码会被转换为 V8 的内部表示形式,然后经过与 JavaScript 相似的管道进行处理。
延伸阅读
- Life of a Script 2020 讲解 v8 主流程
- V8 blog 包含了 V8 的最新动态和技术细节
Strict Mode?
答案
"use strict"
是 JavaScript 中的一个编译指示(directive),用于启用严格模式(strict mode)。严格模式是 JavaScript 的一种执行模式,它增强了代码的健壮性、可维护性和安全性,并减少了一些常见的错误。通过在代码或函数顶部声明 'use strict'
开启 Strict Mode 模式。通过 strcict 来解决一些不安全的语言特性带来的安全或者其他问题。开启后的限制,详见 The Strict Mode of ECMAScript。
- 严格模式禁止使用一些不安全或不推荐的语法和行为,例如使用未声明的变量、删除变量或函数、对只读属性赋值等。它会抛出更多的错误,帮助开发者发现并修复潜在的问题。
- 严格模式禁止使用一些不严谨的语法解析和错误容忍行为,例如不允许在全局作用域中定义变量时省略
var
关键字。 - 严格模式对函数的处理更加严格,要求函数内部的
this
值为undefined
,而非在非严格模式下默认指向全局对象。 - 严格模式禁止使用一些具有歧义性的特性,例如禁止使用八进制字面量、重复的函数参数名。
需要注意的是,严格模式不兼容一些旧版本的 JavaScript 代码,可能会导致一些旧有的代码产生错误。因此,在使用严格模式之前,需要确保代码中不会出现与严格模式不兼容的语法和行为。
延伸阅读
- JavaScript二十年 严格模式章节 讲解了,为什么会设计成这样的语法
V8 里面的 JIT 是指什么?
答案
JIT(Just-In-Time,即时编译)是 V8 引擎中提升 JavaScript 执行效率的核心技术。它的作用是:在代码运行期间,将热点 JavaScript 代码动态编译为本地机器码,从而大幅提升执行速度。
V8 的 JIT 机制主要包括以下流程:
- 解释执行:V8 首先用解释器(Ignition)将 JS 代码编译为字节码并执行,启动快,适合冷代码。
- 热点检测与即时编译:当某段代码被频繁执行(成为“热点”),JIT 编译器(TurboFan)会将其优化为本地机器码,后续直接运行机器码,速度远超解释执行。
- 优化与去优化:JIT 编译会基于运行时收集的信息进行激进优化。如果后续发现假设不成立(如类型变化),会触发“去优化”,回退到字节码或重新优化。
JavaScript 如何做内存管理?
答案
V8 的内存管理主要解决三个问题
- 标识存活和死掉的对象
- 清理死掉的对象,重用内存
- 压缩和去碎片化提高内存使用率(可选)
可以在 node 中采用 v8.getHeapSpaceStatistics()
获取堆信息,V8 的内存结构如图
内存概览图
(new_space)
(old_space)
(large_object_space)
(code_space)
(map_space)
(read_only_space)
(code_large_object_space)
(new_large_object_space)
(stack)
空间 | 说明 | 特点 | GC 相关性 |
---|---|---|---|
Stack (栈空间) | 存储基本类型值、局部变量、函数参数及对象引用。函数调用创建栈帧,返回时销毁。 | 后进先出 (LIFO),分配/回收快,空间小,操作系统自动管理。 | 不由 V8 GC 直接管理,但栈上的引用是 GC 判断堆对象可达性的起点。 |
New Space (新生代) 1MB | 存放新建、生命周期短的对象,V8 频繁快速回收(Scavenge)。 | 空间小,分为 From/To 两半区,Scavenge 算法复制存活对象。 | 经一两次回收仍存活的对象晋升到老生代。 |
Old Space (老生代) 动态增加 | 存放生命周期长或晋升的对象。 | 空间大,回收频率低但耗时长,采用 Mark-Sweep/Mark-Compact 算法。 | 存放常驻对象,满时触发主 GC。 |
Large Object Space (大对象空间) | 存放体积大(通常 >1MB)的对象。 | 每个大对象独立分配内存块。 | GC 不移动大对象,参与 Mark-Sweep 回收。 |
Code Space (代码空间) | 存放 JIT 编译后的机器码。 | 存储可执行代码。 | 可被 GC 清理未引用的编译代码。 |
Map Space (Map 空间) | 存放对象的隐藏类(Maps)及转换信息。 | 包含对象元数据。 | Map 对象由 GC 管理。 |
Read-Only Space (只读空间) | 存放运行时不会变的内置对象和数据。 | 启动后内容固定,不可修改。 | 不进行 GC,内容永久。 |
Code Large Object Space (大代码对象空间) | 存放非常大的已编译代码块。 | 针对大型代码对象优化。 | 管理方式类似大对象空间。 |
New Large Object Space (新生代大对象空间) | 新生代分配较大对象,生命周期短但体积大。 | 优化大短期对象,减少直接进入老生代大对象空间。 | 参与新生代回收,存活则晋升。 |
V8 gc 收集器叫做 orinoco, 整个核心流程如下
- Minor GC 新生代的垃圾回收,使用 Scavenge 算法。
- 对象首相分配到 Nursery Space
- marking 会有单独对新生代区执行标记过程
- evacuating
- 利用 semi-space 移动
- 如果经过首次 GC 后仍然存活的对象会被标记为 Intermediate Generation, 移动到 TO Space,通过这一步实现了内存的对齐,减少了碎片
- 如果经过两次 GC 后仍然存活的对象会被直接移动到 Old Generation
- pointer-updating 更新指针,指向新对象的地址,用于循环执行步骤 2,3
- 如果经过两次 GC 后仍然存活,则会晋升到 Old Generation
- Major GC 负责老生代的垃圾回收,
- Marking 从全局根对象开始,标记所有可达对象。对于不可达对象会记录在 free-list
- Sweeping 会在进程空闲的时候执行清除操作
- Compacting 压缩内存,移动存活对象,减少碎片化。
此外在进程模型上上述操作可以并行执行,具体的逻辑可以参考 state of gc
参考 Understanding and Tuning Memory,V8 的内存管理基于 分代假说(generational hypothesis),即大多数对象生命周期很短。因此,V8 将堆划分为不同的代,以优化垃圾回收效率
延伸资料
- Visualizing memory management in V8 了解 V8 内存结构
- A tour of V8: Garbage Collection 讲解 V8 的垃圾回收机制
- Trash talk 了解 V8 新老生代的内存管理
- young generation garbage collection 了解新生代垃圾回收
- Memory management MDN 描述 V8 内存管理
- v8 gc 了解 V8 的垃圾回收机制
内联缓存
答案
内联缓存(Inline Cache,简称 IC)是 V8 等 JavaScript 引擎中用于优化属性访问和方法调用性能的一种技术。
内联缓存是一种记录对象属性访问模式的机制。当 JavaScript 代码多次访问对象的同一属性时,V8 会在第一次访问时记录下对象的“形状”(即隐藏类/Map)和属性的偏移位置。后续访问时,如果对象的形状没有变化,V8 就可以直接通过缓存快速定位属性,而无需再次查找。
-
加速属性访问
JavaScript 是动态类型语言,对象的结构可以随时变化。每次访问属性都去查找会很慢。内联缓存通过记录之前的访问模式,让后续相同模式的访问变得非常快。 -
减少查找开销
如果对象的结构(隐藏类)没有变化,V8 可以直接用缓存的偏移量读取属性,避免了原本的原型链查找和动态解析。 -
优化方法调用
方法调用本质上也是属性访问。内联缓存同样可以加速方法查找和调用。
- 第一次访问:V8 记录下对象的隐藏类和属性位置,生成一条慢路径(miss)。
- 后续访问:如果对象的隐藏类没变,直接命中缓存,走快路径(hit)。
- 对象结构变化:如果对象的隐藏类变了,缓存失效,重新走慢路径并更新缓存。
function foo (obj) {
return obj.x
}
const a = { x: 1 }
const b = { x: 2 }
foo(a) // 第一次访问,建立缓存
foo(b) // 对象结构相同,命中缓存,加速访问
如果 b
的结构和 a
不同(比如 b
多了个属性),就会触发缓存失效。
内联缓存通过记录和复用对象属性的访问模式,大幅提升了 JavaScript 代码的执行效率,是现代 JS 引擎性能优化的核心技术之一。
引擎调试与性能分析
答案
JavaScript 引擎调试和性能分析是开发者优化代码性能和排查问题的重要工具。以下是一些常用的调试和性能分析方法:
- Chrome DevTools:Chrome 浏览器内置的开发者工具,提供了强大的调试和性能分析功能。可以设置断点、查看调用栈、监控内存使用情况、分析网络请求等。
- Node.js 调试器:Node.js 提供了内置的调
- 试器,可以通过
node --inspect
启动应用,然后在 Chrome DevTools 中连接调试。支持断点、变量查看、性能分析等功能。 - Performance:使用 Chrome DevTools 的 Performance 面板,可以录制应用的运行情况,分析 CPU 使用率、内存分配、函数调用等。可以帮助识别性能瓶颈和内存泄漏。
- Memory:在 Chrome DevTools 中,可以拍摄堆快照,查看内存分配情况,分析对象的引用关系,帮助识别内存泄漏和不必要的内存占用。
- Console API:使用
console.log
、console.time
、console.timeEnd
等 API,可以在代码中插入日志,帮助调试和性能分析。可以输出变量值、执行时间等信息。
JS 中的数组和函数在内存中是如何存储的?
答案
在 JavaScript 中,数组和函数的内存存储方式如下:
- 本质:数组是对象的一种特殊形式,底层实现通常为稠密数组(连续存储)或稀疏数组(哈希表存储),由引擎根据实际使用自动选择。
- 稠密数组:当数组索引连续且类型一致时,V8 等引擎会将其存储为类似 C 语言的连续内存块,访问和遍历速度快。
- 稀疏数组:如果数组索引不连续或类型混杂,存储会退化为哈希表结构,性能下降。
- 动态扩容:数组长度可变,添加/删除元素时,可能触发内存重新分配或结构切换。
- 引用存储:数组本身在栈上存储引用,实际元素内容存储在堆上。
const arr = [1, 2, 3] // 连续稠密存储
arr[100] = 42 // 变为稀疏数组,底层切换为哈希表
函数的存储
- 函数是对象:函数在 JS 中是一等对象,存储在堆内存中。
- 结构:函数对象包含可执行代码(字节码/机器码)、作用域链(闭包)、参数信息等元数据。
- 引用存储:变量保存的是指向函数对象的引用(指针),而不是函数本身。
- 闭包:函数对象会持有其创建时的作用域链,保证闭包变量不会被回收。
function foo (a) { return a + 1 }
// foo 变量在栈上,指向堆中的函数对象
- 基础类型(number、boolean、undefined、null、symbol):值直接存储在栈上。
- 引用类型(对象、数组、函数):变量在栈上存储引用,实际内容在堆上分配。
- 对象/数组底层:通常用哈希表实现键值对存储,数组在条件允许时优化为稠密存储。
- 内存管理:JS 引擎通过垃圾回收自动管理堆内存,未被引用的对象会被回收。
延伸阅读
隐藏类是什么概念?
答案
隐藏类(Hidden Class,也称为 Shape 或 Map)是 JavaScript 引擎(如 V8)为优化对象属性访问速度而引入的一种内部数据结构。它类似于静态语言中的“类”,但会在运行时动态生成,用于描述对象的属性布局(属性名、顺序、偏移等)。这样,访问对象属性时可以通过偏移量直接定位,而不是每次都查找属性名。
-
动态生成:当你用相同顺序为对象添加属性时,引擎会为这些对象复用同一个隐藏类。每次添加新属性,都会基于当前隐藏类生成一个新的隐藏类,并建立“转换链”。
-
属性顺序敏感:属性添加顺序不同会导致不同的隐藏类。例如:
// 产生相同隐藏类
function Foo () {
this.a = 1
this.b = 2
}
const o1 = new Foo()
const o2 = new Foo()
// 产生不同隐藏类
const o3 = {}
o3.a = 1
o3.b = 2
const o4 = {}
o4.b = 2
o4.a = 1o1 和 o2 共享隐藏类,o3 和 o4 因属性顺序不同,隐藏类也不同。
-
属性变化触发隐藏类切换:删除属性、为对象动态添加新属性、属性类型变化等操作都会导致隐藏类切换(deopt),影响性能。
-
优化对象访问:隐藏类让引擎能像访问 C++ 结构体那样,通过偏移量直接访问属性,避免哈希查找。
基于隐藏类业务侧的优化建议:
- 统一对象结构:尽量让同一类对象的属性初始化顺序和数量一致,避免动态增删属性。例如,构造函数中一次性初始化所有属性。
- 避免动态添加/删除属性:不要在对象创建后随意增删属性,尤其是在性能敏感代码中。
- 预分配属性:即使某些属性暂时用不到,也可以先赋值为 null 或 undefined,保证对象结构一致。
- 数组稀疏性:数组稀疏(索引不连续)也会导致隐藏类退化,影响性能。
延伸阅读
尾调优化
答案
尾调优化(Tail Call Optimization, TCO)是指:当函数的最后一步是调用另一个函数(或自身),并且直接返回其结果时,JS 引擎可以复用当前的调用帧,不再额外增加栈空间,从而避免栈溢出、提升性能。 例如:
function f (x) {
return g(x) // g(x) 是尾调用
}
只有在 return 语句中直接返回函数调用结果,才属于尾调用。若调用后还有其他操作(如赋值、加法等),则不是尾调用。
尾调优化优点
- 节省内存:尾调优化只保留当前调用帧,避免递归导致的调用栈过深。
- 防止栈溢出:递归场景下,尾调优化可让递归像循环一样高效,避免“Maximum call stack size exceeded”错误。
- 提升性能:减少函数调用栈的维护开销。
业务侧编码时
- 确保尾调用位置:函数的最后一步是 return 另一个函数的调用结果。
- 递归时参数携带中间状态:将所有中间变量通过参数传递,避免依赖外层作用域。
- 严格模式下生效:ES6 规定尾调优化只在
'use strict'
模式下启用。
阶乘尾递归示例:
'use strict'
function factorial (n, total = 1) {
if (n === 1) return total
return factorial(n - 1, n * total) // 尾调用
}
factorial(5) // 120
斐波那契尾递归示例:
'use strict'
function fib (n, a = 1, b = 1) {
if (n <= 1) return b
return fib(n - 1, b, a + b)
}
fib(10) // 89
注意事项
- 严格模式:尾调优化仅在严格模式下生效,普通模式下不会优化。
- 不能引用外层变量:尾调用函数不能引用当前函数的局部变量,否则无法优化。
- 并非所有 JS 引擎都实现了 TCO:目前主流浏览器(如 Chrome、Firefox)并未实现 ES6 的尾调优化规范,Node.js 也未支持。理论上 ES6 规范要求支持,但实际需查阅引擎实现情况。
- 递归参数设计:尾递归通常需要将中间状态作为参数传递,初次使用时可通过包装函数或默认参数简化调用。
蹦床函数(trampoline)手动优化
若运行环境不支持 TCO,可用“蹦床函数”将递归转为循环,避免栈溢出:
function trampoline (fn) {
while (typeof fn === 'function') {
fn = fn()
}
return fn
}
function sum (x, y) {
if (y > 0) {
return () => sum(x + 1, y - 1)
}
return x
}
trampoline(() => sum(1, 100000)) // 100001
- 尾调优化让递归像循环一样高效,避免栈溢出。
- 使用时需确保 return 语句直接返回函数调用结果,且在严格模式下。
- 递归参数需传递所有中间状态,避免依赖外层变量。
- 若引擎不支持 TCO,可用蹦床函数手动优化。
参考资料: