v8 引擎简介
浏览器架构
Chrome 多进程架构
- 浏览器进程: 地址栏,书签,回退与前进按钮,以及处理 web 浏览器中网络请求、文件访问等不可见的部分
- 渲染进程: 控制标签页内网站渲染
- 插件进程: 控制站点使用的任意插件,如 Flash
- GPU 进程: 处理独立于其它进程的 GPU 任务
多渲染进程带来的优势
- 稳定性: 一般情况下,浏览器会给每个标签页分配一个渲染进程,从而保证每个标签页能独立运行,互不影响。但由于受设备的内存及 CPU 能力影响,
当 Chrome 运行时达到限制时,会开始在同一站点的不同标签页上运行同一进程
- 沙箱化: 限制与保护进程的特定权限与能力
浏览器内核
内核即
Rendering Engine
,渲染引擎,负责对网页语法的解析,比如 HTML、JavaScript,并渲染到网页上
排版渲染引擎
KHTML
: HTML 网页排版引擎之一,由 KDE 所开发。后来经苹果扩展开源,衍生出WebCore
及WebKit
引擎WebCore
: 如 Safari 浏览器
内核引擎
Trident
: IEGecko
: FirefoxWebKit
: 诞生于 1998 年,并于 2005 年由 Apple 公司开源,Safari、Google Chrome、傲游 3、猎豹浏览器、百度浏览器Presto
: 早期 Opera 的内核,现在主要是在手机平台 Opera miniChromium
: 基于 webkit,早期 chrome 的内核,那时候还叫 Chromium 浏览器Blink
: 基于 Webkit2 分支,新版 Opera (15 及往后版本) 和移动端上使用较多
WebKit Embedding API
: 负责浏览器 UI 与 WebKit 进行交互的部分WebKit Ports
: 让 Webkit 更加方便的移植到各个操作系统、平台上,提供的一些调用 Native Library 接口
JavaScript 引擎
将 js 代码编译成 CPU 认识的指令集,同时负责执行以及管理内存
- 解释形语言(如 js): 由引擎直接读取源码,一边编译一边执行,效率相对较低
- 编译型语言(如 c++): 将源码直接编译成可直接执行的代码,执行效率更高
V8
- 由 Google 开发,广泛应用于 Chrome 浏览器和 Node.js 环境
- 采用
即时编译
(JIT just in time compilation) 技术,混合编译执行和解释执行这两种手段, 启动过程中采用解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。同时,V8 具备强大的内存管理和垃圾回收机制,可有效减少内存泄漏问题 - 适用于对性能要求较高的 Web 应用开发、服务器端开发 (如 Node.js 项目) 等
JavaScriptCore
- 由 Apple 开发,是 Safari 浏览器的默认引擎
- 高度优化,具有出色的性能和较低的内存占用。JavaScriptCore 支持 ECMAScript 标准的最新特性,并且与苹果的操作系统和设备紧密集成
- 主要用于苹果的 Safari 浏览器、iOS 系统中的 WebView 以及其他基于 WebKit 内核的浏览器和应用程序
SpiderMonkey
- 由 Mozilla 开发,最早的 JavaScript 引擎之一,Firefox 浏览器的默认引擎
- 对 ECMAScript 标准的支持非常全面,并且不断引入新的特性和优化。具备良好的调试和开发工具支持,方便开发者进行代码调试和性能优化
- 主要应用于 Firefox 浏览器及其相关的开发项目
Chakra
- 由微软开发,曾用于 Internet Explorer 和 Microsoft Edge 浏览器 (旧版)
- 在 Windows 平台上具有良好的性能和兼容性,支持许多微软特有的 JavaScript 扩展
- 主要用于微软的浏览器和相关的 Windows 应用程序。不过,新版的 Microsoft Edge 已经切换到了基于 Chromium 的架构,使用 V8 引擎
Hermes
- 由 Facebook 开发,专为 React Native 应用设计
- 体积小、启动速度快,能够显著减少应用的启动时间和内存占用。采用了静态编译技术,将 JavaScript 代码编译成字节码,在运行时直接执行字节码,提高了执行效率
- 主要用于 React Native 开发的移动应用
V8
编译及运行
编译过程
Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code
Machine Code 可能会被还原成 Bytecode,此过程称为优化回滚
(Deoptimization)。比如 Ignition 收集的信息是错误的热点标记
:如果代码被调用多次 (反馈向量
标记),则可能会被识别为热点代码,且 Ignition 收集的类型信息证明可以进行优化编译的话,这时 TurboFan 则会将 Bytecode 编译为 Optimized Machine Code (已优化的机器码),以提高代码的执行性能
scanner
- 词法分析器,将 js 源码转换成有意义的词 (token) 形成的数组
parser
- 语法分析器,将 token 数组按照特定的格式转换成对象,供 Ignition (解释器) 引擎生成字节码
Ignition
- 解释器,负责将 AST 转换为 Bytecode,解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型;解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆
- 基于栈 (Stack-based): 保存函数参数、中间运算结果、变量等。比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机
- 基于寄存器 (Register-based): 保存参数、中间计算结果。现在的 V8 虚拟机
- 解释器,负责将 AST 转换为 Bytecode,解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型;解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆
TurboFan
- 编译器,利用 Ignition 所收集的类型信息,将 Bytecode 转换为优化的汇编代码
Orinoco
- 垃圾回收模块,将程序不再需要的内存空间回收
运行过程
- Execution: 辅助类,包含一些重要函数,辅助进入和执行 js 代码
- JSFunction: 需要执行的 js 函数表示类
- Runtime: 运行本地代码的辅助类,主要提供运行时所需的辅助函数,如属性访问、类型转换、编译、算术、位操作、比较、正则表达式等
- Heap: 运行本地代码需要使用的内存堆类
- MarkCompactCollector: 垃圾回收机制的主要实现类,用来标记、清除和整理等基本的垃圾回收过程
- SweeperThread:负责垃圾回收的线程
三大核心优化技术
- 惰性编译: 快速生成可执行的中间代码 (字节码)
- 内联缓存: 在字节码执行期间,记录对象属性的隐藏类和偏移量
- 隐藏类: 为内联缓存提供数据结构支持,实现属性快速访问
- 热点代码最终被 优化编译器 转换为高效机器码
性能优化建议
保持对象结构稳定: 避免在构造函数外动态增删属性
1
2
3
4
5
6
7
8
9
10
11// 推荐
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
// 不推荐
const p = {};
p.x = 1;
p.y = 2;维持类型一致性: 避免同一属性存储不同类型数据
1
2
3// 不推荐(破坏 IC)
obj.field = 1;
obj.field = "text";利用函数内联: 小函数更易被内联优化
1
2
3function add(a, b) {
return a + b;
} // 可能被内联
惰性编译
Lazy Compilation
- 延迟编译:不一次性编译所有代码,而是在函数首次被执行时才编译
- 分层编译:先快速生成低优化字节码 (Ignition 解释器),热点代码再优化 (TurboFan 编译器)
- 启动速度快:避免编译未使用的代码 (如大型库中未调用的函数);节省内存
内联缓存
Inline Cache (IC): 通过缓存对象操作的类型信息,将动态语言的特征访问转化为接近静态语言的效率,加速查找效率及运算
- 缓存对象属性的访问信息:针对同一属性的多次访问,直接复用之前的查找结果。减少了属性查找时间,但如果
属性类型变化会破坏优化
,比如属性从数字类型变成字符串类型,则需要重新查找隐藏类和偏移量 - 多态优化:跟踪不同类型的对象访问路径 (单态/多态/超态)
单态内联缓存
: 传参结构固定的情况,所有 obj 为同一隐藏类,速度最快多态内联缓存
: 传参结构不固定的情况,2-4 种隐藏类,速度较快超态内联缓存
: 传参结构不固定的情况,>4 种隐藏类,退化为哈希查找,速度慢1
2
3
4
5
6// 超态示例
function polymorphic(obj) {
return obj.x;
}
// 用5种不同隐藏类的对象调用
[A, B, C, D, E].forEach((cls) => polymorphic(new cls()));
多级处理状态
状态 | 处理方式 | 性能 | 触发条件 |
---|---|---|---|
未初始化 | 完整查找属性 | 慢 (100ms) | 首次执行 |
预单态 | 记录 1 种隐藏类 | 较快 | 第 2 次相同类型访问 |
单态 | 直接使用缓存偏移量 | 极快 (1ms) | 多次相同类型访问 |
多态 | 检查 2-4 种缓存类型 | 快 | 少量不同类型交替 |
超态 | 退化为哈希查找 | 慢 | >4 种类型 |
泛型 | 完全通用处理 | 最慢 | 无法预测的复杂情况 |
反馈向量
IC 的核心数据结构,系统性地收集和优化类型反馈信息,实现高性能属性访问的关键
- 动态类型记录器: 在字节码执行期间持续记录操作数的类型信息
- 优化决策依据: 为 TurboFan 编译器提供热代码的类型特征
- 内联缓存载体: 存储 IC 的状态和跳转目标地址
- 热代码编译触发: 当反馈向量显示某操作达到阈值,触发 TurboFan 热点优化
反馈向量结构
1 | // V8源码中的关键结构(简化) |
槽位类型 | 记录内容 | 对应操作 |
---|---|---|
LOAD_IC | 属性加载的隐藏类和偏移量 | obj.property |
STORE_IC | 属性存储的值信息 | obj.property = val |
CALL_IC | 函数调用目标 | func() |
BINARY_OP_IC | 运算操作数类型 | a + b |
属性访问优化过程
1 | function getProp(obj) { |
反馈向量内存布局
1 | FeedbackVector [0x1a50] |
隐藏类
Hide Class: 旨在将 JavaScript 中的
对象静态化
,提升对象的属性访问速度。假设对象创建好了之后就不会添加和删除属性,从而给对象创建隐藏类。在查找属性时会先去隐藏类中查找该属性相对于它的对象的偏移量,加上属性类型,直接从内存中取出属性值
动态创建对象的内存布局描述 (即反馈向量中的
map
属性):相同结构的对象 (相同的属性名称
、相等的属性个数
)共享隐藏类 (map 指向同一个隐藏类),记录属性偏移量,通过偏移量直接定位内存地址。转换机制:对象属性变化时,隐藏类会按规则转换或者直接重构
隐藏类转换
: 隐藏类通过链式转换
来记录对象结构的变化。每个隐藏类记录当前对象的结构,并包含指向可能转换路径的指针。相同修改路径
的对象会共享隐藏类链重构隐藏类
: 当对象结构发生非线性变化
时,转换链会断裂,触发完全重建。比如删除属性、动态属性添加顺序不一致 (属性添加顺序不同会生成不同的隐藏类链)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 记录转换路径
const obj = {};
obj.x = 1; // 隐藏类 C0 → C1(添加属性 x)
obj.y = 2; // 隐藏类 C1 → C2(添加属性 y),复用上一步的转换路径
// 删除属性
const obj = { x: 1, y: 2 };
delete obj.x; // 隐藏类退化为慢路径(哈希表模式)
// 动态属性添加顺序不一致
function createObj(a, b) {
const obj = {};
obj[a] = 1; // 隐藏类依赖 a/b 的值
obj[b] = 2;
return obj;
}
createObj("x", "y"); // 隐藏类 C0 → C1 → C2
createObj("y", "x"); // 触发另一条隐藏类链最佳实践
- 使用字面量初始化对象时,保证属性的顺序一致
1
2
3
4
5
6class Point {
constructor(x, y) {
this.x = x; // 所有实例共享相同隐藏类链
this.y = y;
}
}- 避免删除属性
1
2
3
4// 不推荐
delete obj.property;
// 推荐:设为 null 或 undefined
obj.property = null;- 提前初始化所有属性
1
2
3
4// 优于动态添加
const obj = { x: null, y: null };
obj.x = 1;
obj.y = 2;
V8 与 JS
对象内属性
- 排序属性: 对象中的数字属性,V8 中被称为 elements,按照顺序存放
- 常规属性:字符串属性,称为 properties,按照创建时的顺序保存
- 使用两个线性数据结构来分别保存排序属性和常规属性,同时 v8 将部分常规属性直接存储到对象本身,称为
对象内属性
(in-object properties),不过此类属性一般有限 (10-20 个)
快属性 / 慢属性
快属性: 保存在线性数据结构中的属性
- 访问速度快,但是添加或删除属性效率较低
慢属性: 如果一个对象的属性过多,对象内部会使用独立的非线性数据结构 (字典) 作为属性存储容器
- 读取速度慢,为提升查找效率,V8 在对象中添加了两个隐藏属性,排序属性和常规属性
如果对象中的属性过多,或者存在反复添加或者删除属性的操作,V8 会将线性的存储模式 (快属性)降级为非线性的字典存储模式 (慢属性),虽然降低了查找速度,但是提升了修改对象的属性的速度
动态、静态作用域
- 静态作用域:
静态语言
,符号之间的引用关系能够根据程序代码在编译时就确定清楚,运行时不变,由程序代码决定,大多数语言都是静态作用域的。直接通过偏移量
查询来查询对象的属性值,执行效率高 - 动态作用域:
动态语言
,变量引用跟变量声明不是在编译时就确定了的。运行时,在运行环境中动态地找一个相同名称的变量。在 macOS 或 Linux 中用的 bash 脚本语言,是动态作用域的
惰性解析
比如在
预解析
阶段- 加速代码的启动速度
- 延迟解析函数体: 解析器在解析的过程中,如果遇到函数声明,会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。避免了过多的代码增加编译时间和防止中间代码一直占用内存
最佳实践
- 模块化组织代码: 保持函数职责单一
- 避免 IIFE 滥用: 立即执行函数会强制全解析
- 合理使用动态导入
预解析 / 全解析
预解析: Pre-Parser
- 跳过函数体: 快速扫描函数签名和语法结构,如果有语法错误则向外抛出
- 收集变量声明: 检查函数内部是否引用了外部变量,如果有预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,解决闭包所带来的问题
- 不生成 AST 或字节码
- 位置标记: 记录函数体在源码中的位置范围
全解析: Full Parser
- 当函数首次被调用时、作为构造函数时、动态使用时触发
- 生成完整 AST 和字节码
- 进行作用域分析
内存结构
栈
后进先出,同时栈空间连续,在需要分配空间和销毁空间操作时,只需移动下指针,非常适合管理函数调用
保存基本数据类型及对象指针,管理 js 函数调用
堆
一种树形的存储结构,用来存储对象类型的离散的数据
new space
: 新生代内存区- 分为两个 semispace, from space 和 to space, 每个大小默认为 16M, 所以 new space 通常大小为 32M, 新创建的对象、临时变量、闭包变量、短生命周期的数据等会放入其中一个处于工作状态的 space
1
const obj = { a: 1 }; // 初始分配在新生代
old space
: 老生代内存区- 通常会持久化的保存对象, 分为两个区域, old pointer space 和 old data space, 分别用来存放 GC 后还存活的指针信息和数据信息。一般有长期存活的对象、大对象、全局变量、闭包长期引用的变量、DOM 相关对象等
1
2window.cache = { data: "长期存储" }; // 长期引用的对象,最终晋升到老生代
const bigArray = new Array(10 * 1024 * 1024); // 大数组直接进入老生代large object space
: 大对象区- 存放体积超越其他区大小的对象, 主要为了避免大对象的拷贝, 使用该空间专门存储大对象
code Space
: 代码区- 存放代码对象, 最大限制为 512M, 也是唯一拥有执行权限的内存
Cell space, property cell space, map space
: 单元区, 属性单元区, Map 区- Map 空间存放对象的 Map 信息 (即隐藏类 Hiden Class) 最大限制为 8M; 每个 Map 对象固定大小, 为了快速定位, 单独出空间
new space 和 old space
new space 置换过程
- 假设新创建对象分配到 from space
- 程序继续执行不断向 from space 中添加新的对象信息, 将要达到存储上限时, V8 垃圾回收机制开始清理 from 中不再被使用的对象 (此时称
Minor GC
,Eden
区将满时触发,Survivor
区将满时不会触发,速度快,但会暂停主线程 (Stop-the-World
) ) - 清理后将所有仍然存活的对象复制到 to space, 然后删除所有 from space 中的对象
- 此时程序继续执行向 to space 中添加新的对象信息, 重复上述过程
old space
- 程序运行一段时间后新生代内存区中仍存活的对象,终于满足了晋升的条件,转移到了老生代内存区
- 再经过一段时间, 对象不再被引用, 同时老生代内存区域空间被占用了很多的空间, V8 会在老生代里面进行遍历,给这些对象打上标记
- 为避免一次回收占用太多时间, V8 会分批回收有标记的待清理的对象 (此时称
Major GC / Full GC
,会遍历整个堆,可能造成明显卡顿)
垃圾回收
代际假说
(The Generational Hypothesis) 是垃圾回收领域中的一个重要术语, V8 的垃圾回收的策略也是建立在该假说的基础之上
- 大部分对象在内存中存在的时间很短,很多对象一经分配内存,很快就变得不可访问
- 不死的对象,会活的更久,比如全局的 window、DOM、Web API 等对象
垃圾数据
从 GC Roots 对象出发,遍历 GC Root 中的所有对象,如果通过 GC Roots 没有遍历到的对象,则这些对象便是垃圾数据
通常的 GC Roots 对象
- 全局的
window
对象,包括每个 iframe 中的- 文档 DOM 树
- 存放在栈上的变量
垃圾回收算法
- 通过
可访问性
(reachability) 算法,标记 GC Root 空间中的活动对象 (可访问的 reachable)和非活动对象 (不可访问的 unreachable) - 回收非活动对象所占据的内存
- 针对主垃圾回收器做内存碎片整理
垃圾回收器
副垃圾回收器
Scavenge
, 负责新生代区域的垃圾回收
针对新生代的置换过程,随着程序的运行,某些对象一直在被使用会持续的积压在新生代区域,为了解决这个问题,V8 采用了 晋升机制
,将满足条件的对象放到老生代内存区中存储,释放新生代内存区域的空间。有以下条件
- 经历过一次 Scavenge 算法,且并未被标记清除的,也就是经过一次翻转置换操作的对象
- 在进行翻转置换时,被复制的对象大于 to space 空间的 25%
主垃圾回收器
Mark-Sweep
&Mark-Compact
, 负责老生代区域的垃圾回收, 通常会有在新生代晋升后的对象以及初始占用空间就很大的对象会存储在老生代内存区
Mark-Sweep
: 标记-清除。只需遍历存活对象,不移动内存,适合大多数情况,但是回收后会产生不连续的内存块,可能无法分配大对象;全堆扫描时,JS 主线程会暂停 (Stop-the-World
)- 标记阶段:从根对象 (全局变量、活动函数栈等) 出发,递归遍历所有可访问的对象,并标记为“存活”
- 清除阶段:遍历整个堆内存,回收所有未被标记的对象,释放其内存
Mark-Compact
: 标记-整理。整理后内存连续,可分配更大对象,避免了碎片化可能产生的OOM
问题。但是移动对象和更新引用比 Mark-Sweep 更耗时;需要计算新地址并更新所有引用- 标记阶段:与
Mark-Sweep
相同,先标记所有存活对象 - 整理阶段:将所有存活对象向一端移动,并更新引用地址,使剩余空间连续
- 标记阶段:与
垃圾回收优化策略
为了优化垃圾回收产生的
STW
时间,V8 启动了代号为Orinoco
的垃圾回收子系统来进行优化
Orinoco 的核心优化目标
- 减少主线程停顿时间:避免长时间阻塞 JavaScript 执行
- 利用多核 CPU:通过并行和并发提高 GC 效率
- 分代回收策略:针对新生代和老生代采用不同算法
Orinoco 的三大垃圾回收技术:
Parallel
,Incremental
,Concurrent
Orinoco 的分代回收策略
分代 | 算法 | Orinoco 优化 | STW 时间 |
---|---|---|---|
新生代 | Scavenge (复制算法) | 并行回收 (多线程复制存活对象) | 极短 |
老生代 | Mark-Sweep-Compact | 增量标记 + 并发标记/整理 | 显著减少 |
Parallel
并行回收。在主线程暂停 (STW) 期间,使用多个辅助线程同时执行垃圾回收任务
比如同时进行新生代 Scavenge 算法和老生代的标记阶段 (Marking)
Incremental
增量回收。针对一个大对象的回收,将完整的 GC 任务拆分为多个小任务,交替执行 GC 和 JavaScript 代码
老生代的标记阶段 (Marking)
可以避免长时间卡顿,但是需要处理两个问题
如何保存上一次的扫描结果:三色标记位 + 标记工作表
- 三种颜色:白色(00)、灰色(10)、黑色(11)
- 初始状态,所有对象均为
白色
:未被根节点引用到的对象 - 当 GC 发现一个对象被引用,将会标记为
灰色
,并将其推入到标记工作表中 - 标记工作表访问所有存在的
灰色
对象及其所有子对象,结束后会将该对象标记为黑色
- 持续向表中添加
灰色
对象 - 处理完表中的
灰色
对象直至没有灰色对象,即所有对象均为白色或黑色,之后清理掉所有白色
的对象
如何处理标记好的数据被主线程修改:写屏障
Write Barrier
- 避免漏标 (Missed Mark) 和错标 (False Retention)
- 增量写屏障:强制让
黑色
对象不能直接指向白色
对象,将新写入的对象从初始的白色直接变为灰色,标记工作表 继续工作 - 并发写屏障:使用记忆集 (Remembered Set)记录跨代引用 (如老生代对象引用新生代对象)
- 卡表 (Card Table):将堆内存分块 (如 512B 一块),通过卡表标记脏块 (含引用变化的块)。减少写屏障开销,只需标记卡表,GC 时仅扫描脏块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 漏标
let a = { name: "A" }; // 黑色(已标记)
let b = { name: "B" }; // 白色(未标记)
a.ref = b; // 如果 GC 不感知此修改,b 可能被误回收
// 错标
let c = { name: "A" }; // 色(已标记)
c = null; // 如果 GC 不感知此修改,c 无法回收
// 简化的增量写屏障逻辑
function writeBarrier(obj, field, newValue) {
// 1. 实际执行写操作
obj[field] = newValue;
// 2. 如果 GC 正在标记阶段,处理引用变化
if (gcPhase === "MARKING") {
if (isMarked(obj) && !isMarked(newValue)) {
// 将新引用的对象标记为灰色(待扫描)
markAsGrey(newValue);
}
}
}
Concurrent
并发回收。完全在后台线程执行 GC,主线程继续运行 JavaScript
老生代的标记阶段 (Marking) 和内存整理 (Compaction)
主线程几乎无感知,但是需要解决并发冲突 (如对象被 JavaScript 修改时,GC 线程需同步)
协程 / 线程 / 进程
- Process: 操作系统资源分配的基本单位,每个进程有独立的内存空间 (代码、数据、堆栈), 进程间通信(IPC)需要通过 管道、消息队列、共享内存、Socket 等方式
- Thread: CPU 调度的基本单位,属于同一进程的线程共享内存空间, 线程间可直接读写同一进程的变量,但需要同步机制 (如锁、信号量)
- Coroutine: 用户态轻量级线程,由程序控制调度 (而非操作系统), 协程在单线程内实现任务切换,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协,通过
yield/resume
主动让出执行权。是 generator、async / await 实现异步编程的核心逻辑,await 暂停协程,Promise.resolve 后恢复协程
特性 | 进程 | 线程 | 协程 |
---|---|---|---|
调度单位 | 操作系统 | 操作系统 | 用户程序 |
内存隔离 | 完全隔离 | 共享同一进程内存 | 共享同一线程内存 |
上下文切换成本 | 高 (需切换页表等) | 中 (需切换寄存器) | 极低 (仅切换局部变量) |
通信方式 | IPC (管道、Socket 等) | 共享内存 (需同步) | 直接共享变量 |
多核并行能力 | 是 | 是 | 否 (需结合多线程) |
典型应用 | Chrome 多标签页 | Java 多线程服务器 | Go 的 goroutine |
机器码 / 字节码
现阶段的 V8 采用基于寄存器的编译器,带有一个累加器 (accumulator),通过累加器来暂存中间变量
早期的 V8 为了提升代码的执行速度,直接将 JavaScript 源代码编译成了没有优化的二进制机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。但存在致命问题,于是引入中间字节码
- 时间问题:编译时间过久,影响代码启动速度
- 空间问题:缓存编译后的二进制代码占用更多的内存
字节码优势
- 解决启动问题:生成字节码的时间很短
- 解决空间问题:相较于机器代码,字节码体积减小很多,缓存字节码很大程度上降低了内存的使用
- 代码架构清晰:简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易
常用字节码指令
- Ldar: Load accumulator Register, 表示将寄存器中的值加载到累加器中
- Star: Store accumulator Register, 表示把累加器中的值保存到某个寄存器中
- Add: Add a0 [0], 从 a0 寄存器加载值并将其与累加器中的值相加,将结果再次放入累加器。[0] 是反馈向量槽 (feedback vector slot),一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是给 TurboFan 优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息
- LdaSmi: 将小整数 (Smi)加载到累加器寄存器中
- Return: 结束当前函数的执行,并将控制权传回给调用方,返回累加器中的值
提升 JS 性能的技巧
V8 动态跟踪数组的元素类型,共 21 种内部表示
种类 | 示例 | 优化等级 |
---|---|---|
PACKED_SMI_ELEMENTS | [1, 2, 3] | 最高效 |
PACKED_DOUBLE_ELEMENTS | [1.1, 2.2] | 次高效 |
PACKED_ELEMENTS | [‘a’, {}] | 基础 |
HOLEY_ 变体* | [1, , ‘x’] | 较低效 |
- 在构造函数里初始化所有对象的成员
- 总是以相同的次序初始化对象成员
- 尽量使用可以用 31 位有符号整数表示的数
为数组使用从 0 开始的连续的主键,保持数组密集型
1
2
3
4
5// 推荐:预先填充undefined而非留空位
const arr = new Array(3).fill(undefined);
// 不推荐
const badArr = [1, , 3]; // 创建HOLEY数组不要预分配大数组 (比如元素占用内存大于 64K 字节)到其最大尺寸,可能从线性存储模式降级为字典存储模式,慢慢增大数组即可
- 不要删除数组里的元素,尤其是数字数组
- 不要加载未初始化或已删除的元素
对于固定大小的数组,使用
array literals
初始化 (初始化小额定长数组时,用字面量进行初始化)1
2
3
4
5
6// 字面量(推荐):引擎可静态分析优化
const literal = [1, 2, 3];
// 构造函数(动态分配,优化受限)
const constructed = new Array(1, 2, 3);
const fromed = Array.from({ length: 3 });小数组(小于 64k)在使用之前先预分配正确的尺寸
- 请勿在数字数组中存放非数字的值(对象)
尽量使用单一类型 (monomorphic) 而不是多类型 (polymorphic) (如果通过非字面量进行初始化小数组时,切勿触发类型的重新转换)
1
2
3
4
5
6// 保持单一元素类型(最高效)
const nums = [1, 2, 3]; // PACKED_SMI_ELEMENTS
nums.push(4.5); // → PACKED_DOUBLE(性能损耗)
// 混合类型会降级
const mixed = [1, "a"]; // → PACKED_ELEMENTS不要使用 try{} catch{} (如果存在 try/catch 代码快,则将性能敏感的代码放到一个嵌套的函数中)
- 在优化后避免在方法中修改隐藏类
稀疏数组
包含空位 (holes) 的数组,会引发显著的性能下降
1 | // 显式空位 |
性能问题
- 密集数组 (Packed): 连续内存块,访问复杂度为 O(1)
- 稀疏数组 (Holey): 哈希表/特殊标记,访问复杂度为 O(n)~O(1),无法应用 SIMD 指令优化,一旦变为 HOLEY 类型,即使填充空位也无法恢复 PACKED 状态,隐藏类更加复杂,大程度增加 GC 扫描时间