概览
Chrome V8 或简称 V8 是 Google 开发的,用 C++ 编写的开源 Javascript 和 WebAssembly 引擎。 V8引擎最初是为基于 Chromium 的浏览器和 Chrome 浏览器构建的,旨在提高JavaScript执行的性能,但也可以在浏览器之外执行 JavaScript 代码,从而实现服务器端脚本编写。 如今,V8 引擎是 Node.js、MongoDB 和 Electron 等桌面应用程序等各种技术的基础。
为什么要有 V8 引擎?
Google的主要收入来源是基于网络广告,因此,任何能够改进网络设施的事可能对谷歌都是有利的。
从一开始,Google 就明白性能强大的 JavaScript 引擎是实现更好的 Web 未来中一件很重要的事情。 这也是 Google 正在构建的一些在 Web 浏览器中运行的复杂产品(例如 Gmail、Google Drive 和 Google Maps)的需要。
V8 引擎产生的那个时候,Javascript的执行速度很慢,无法处理复杂的计算任务,那么也就是意味着实现复杂的 web 应用是非常困难的。
当时许多传统语言如 java 和 C# 在编译代码并创建字节码后,最终由计算机执行。但生成字节码会产生额外开销,会降低编译器的效率。而V8 引擎可以将 javascript 代码直接编译为机器语言,即可以由系统直接执行的语言,而不需要使用解释器。
这让V8 当时在理念上也是遥遥领先的。(后面因为直接编译为机器码占用内存太大,改为了先编译为字节码后编译为机器码)。
除此之外,V8 引擎还使用其他优化技术,在当时遥遥领先:
- "Inline expansion",举个例子:
javascript
function multiply(a, b) {
return a * b;
}
function calculate() {
let x = 5;
let y = 10;
let result = multiply(x, y); // 函数调用
console.log(result);
}
calculate();
在上面的代码中,multiply函数是一个简单的乘法函数,用于计算两个数的乘积。在calculate函数中,我们调用了multiply函数,并将其结果存储在result变量中。这是一个常见的函数调用。
当V8引擎执行这段代码时,它可能会检测到multiply函数的调用处可以进行内联扩展的优化。这意味着引擎会将multiply函数的代码直接插入到调用点。这样做可以减少函数调用的性能开销。
所以,当内联扩展被应用在这个例子中时,calculate函数的代码可能会被优化为:
javascript
function calculate() {
let x = 5;
let y = 10;
let result = x * y; // 内联扩展
console.log(result);
}
calculate();
- "Copying elision"(复制省略), 用于减少数据的显式复制,从而提高内存操作的性能。以下是示例 :
javascript
class MyClass {
constructor() {
console.log('Constructor called.');
}
}
function createObject() {
const obj = new MyClass();
return obj;
}
const newObj = createObject();
在上述代码中,createObject 函数返回一个 MyClass 对象。在没有Copy elision的情况下,会创建一个临时对象并将其拷贝到返回值中。然而,由于JavaScript引擎的优化,实际执行时并没有进行拷贝操作,而是直接构造了返回值对象。
这使得 V8 成为高性能的 JavaScript 引擎。但 V8 是现在最好的 Javascript 引擎吗?
这还是取决于看你使用 js 引擎的场景。
例如,如果你正在创建一个大型的web应用,所以默认你有足够的内存,那么 V8 是不错的。 但假设你要做可穿戴 IoT(物联网)方面的设备,意味着你可以使用的内存将非常少。 在这种情况下,您可以使用 Duktape 或 Jerryscript,它们虽然速度较慢,但更适合内存小的环境。
V8 引擎是如何运行的?
JavaScript 引擎是一个程序,它将 JavaScript 代码作为输入并生成机器可执行代码或字节码。 任何人都可以编写 JS 引擎,只要遵循 ECMAScript 标准制定的标准即可。
V8 引擎是用 C++ 编写的,内部运行着以下的一些线程:
- 有一个主线程负责加载、编译、运行JS代码
- 有一个线程用于优化和编译
- 有一个线程仅用于反馈,告诉 runtime 哪些方法需要进一步优化
- 少量线程用于处理垃圾回收
上图中,可能有些同学会疑惑,为什么有de-optimization(去优化),是因为有时候优化的代码可能有问题,那么就不优化了,直接执行未优化的字节码。
简单看下 V8 和浏览器的关系
浏览器渲染进程在初始化的时候会做的以下两件事:
- 初始化宿主环境
- 初始化 V8 引擎实例
浏览器有很多渲染进程。通常, 每个浏览器 tab 标签页有自己的渲染进程和对应的 V8 引擎实例。具体的V8 引擎和浏览器的关系如下图
在上图中的上下文宿主环境是浏览器。
上图是从国外一篇知名的解释V8引擎和浏览器关系的文章中获取的,但是这张图是有问题的,你能发现吗?
其中Call Stack(堆栈)和Heap(堆)的管理是在V8 引擎中,如下图:
然后 V8 引擎首先通过网络、缓存等方式下载 js 代码。 下载代码后,会对代码进行解析,解析由两部分组成:扫描器和解析器。
这个阶段可以理解为下图的 Parser
Scanner
V8 引擎拿到 JavaScript后,会让 Parser 使用 Scanner 提供的 Tokens(Tokens 里有 JavaScript 内的语法关键字,像是 function、async、if 等),将 JavaScript 解析为 AST(抽象语法树)。
解析代码也是需要时间的,而且有些代码并不需要完全解析,比如跟交互,毕竟用户还没有产生例如点击事件,所以这些代码的解析可以延后,同时节省了cpu和内存开销,术语叫做 Lazy Parsing。这就是有时候你们看文章说的 "预解析"。官方链接:Lazy Parsing
但为什么数据存储在两个不同的地方呢(Call stack存储函数调用的产生的普通变量数据,例如string,number等数据。Heap存储引用的数据,例如 Object,Array)?
-
以空间换取速度:调用堆栈(Callback stack)需要内存中连续的空间以使进程更快,但内存中连续的空间很少。 为了解决这个问题,浏览器开发人员限制了最大的内存大小,这就是堆栈溢出错误的原因。 浏览器通常在调用堆栈中存储有限的数据,例如整数和其他主要数据类型(基本类型会在stack中存储,例如number,string,boolean等等)。
-
以速度换取空间:堆(Heap)不需要连续的空间来存储对象等体积较大数据。 代价是堆处理数据的速度相对较慢。(例如 V8 引擎中垃圾回收机制是一般是间隔一定时间触发的(在特定情况下会强制触发),所以并不是你声明了 const a = {}; 然后马上执行 a = null,a的引用数据就真的立马从内存消失,而是等到垃圾回收完了才消失,而function demo(){ a = 1 }; demo(); 其中的a变量,在函数执行完会立马从内存中消失)
生成 AST
以下是一个 AST 预览网站: acron,然后生成 AST 看看长什么样子。
假如解析如下代码:
javascript
let name; name = 'Clark';
得到的 AST 转化为图如下:
AST查看步骤是从上到下,从左到右,可以看到这个语句的声明(kind)是let,声明的名字是name,然后值是 'Clark'
解释器
V8 使用 Ignition 解释器来遍历 AST 并生成字节代码。 解释器从上到下执行每一行字节码。 一旦生成字节码,抽象语法树就会被删除以释放内存空间。
现在让我们举一个例子,并手动为其生成字节码:
如下图,r0,它值函数中的临时变量,然后,a0-a2可以看做是a,b,c 3个函数的参数。
rust
LdaSmi #100 ->加载常数100到累加器(Smi是小整数)
Sub a2 ->从a2形参(即c)中减去加载的常数,并存储在累加器中
* r0 ->将累加器中的值存储到r0中
Ldar a1 ->读取参数a1 (b)的值并存储到累加器中
将r0相乘->将r0乘以累加器并将结果也存储在累加器中
Add a0 ->将第一个参数a0 (a)添加到累加器中,并将结果存储在累加器中
Return -> Return
V8 解释器 ignition 有一个可以存储和读取值的地方,称为累加器( 英文是 accumulator ,并且这是虚拟的累加器,不是物理上)。 累加器消除了压入和弹出堆栈顶部的需要。 它也是许多字节码的隐式参数,通常保存操作的结果。
为什么累加器能减少堆栈操作?
它提供了一个在寄存器中暂存结果的地方。堆栈操作通常涉及将数据存储到堆栈中或从堆栈中加载数据。这些操作需要将数据从内存移动到堆栈中,或从堆栈中移动数据到寄存器中进行计算。相比之下,使用accumulator(累加器)可以直接将结果存储在寄存器中,避免了频繁的堆栈操作。
执行机器码
在此步骤中,ignition 使用由字节码生成的控制器表来解释指令。 对于每个字节码,ignition 可以找到相应的处理函数并使用提供的参数执行它们。
当运行字节码时,V8 会寻找优化代码的机会。
当检测到频繁使用的字节码时,V8 将它们标记为"hot"。 然后,hot 代码被转换为高效的机器代码并由CPU使用。
如果优化失败了怎么办? 编译器删除这些代码,并让解释器执行原始字节码。
然而,随着引擎的发展,V8团队引入了字节码。为什么?因为使用机器代码会带来额外的问题。
- 机器代码需要大量的内存
V8引擎将编译后的机器码存储在内存中,并在页面加载时重用它。
编译机器码可以将10000行 javascript脚本转换成2000万行 机器码。这是大约2000倍的内存空间。
尽管字节码比原始 JavaScript 大,但比相应的机器码小得多。
由于体积减小,浏览器可以缓存所有已编译的字节码,跳过前面的所有步骤并直接执行。
- 机器代码并不总是比字节码快
字节码的编译时间较短,但代价是执行步骤较慢。
所以你并不能说机器码一定快,毕竟还需要更慢的转换时间。
- 机器代码增加了开发的复杂性
不同的CPU可以有不同的架构,并且有自己特定的机器语言。市场上有很多处理器架构,如ARM64、X64、S397等。而有了字节码,相当于有一个中间层,就像虚拟dom一样,可以更方便的由它转化为不同机器架构的机器码。
编译器
编译器的主要工作编译为低级机器可读语言。
V8 引擎使用称为 Turbofan 的编译器,它从 Ignition 获取字节码,并做一定的优化,最后生成机器代码。
优化的案例,其实上面已经讲了,我们再回顾一下:
javascript
function multiply(a, b) {
return a * b;
}
function calculate() {
let x = 5;
let y = 10;
let result = multiply(x, y); // 函数调用
console.log(result);
}
calculate();
在上面的代码中,multiply函数是一个简单的乘法函数,用于计算两个数的乘积。在calculate函数中,我们调用了multiply函数,并将其结果存储在result变量中。这是一个常见的函数调用。
当V8引擎执行这段代码时,它可能会检测到multiply函数的调用处可以进行内联扩展的优化。这意味着引擎会将multiply函数的代码直接插入到调用点。这样做可以减少函数调用的性能开销。
所以,当内联扩展被应用在这个例子中时,calculate函数的代码可能会被优化为:
javascript
function calculate() {
let x = 5;
let y = 10;
let result = x * y; // 内联扩展
console.log(result);
}
calculate();
什么是沙盒(Sandboxing)?
沙盒是指隔离的环境。
沙箱是一个运行代码的环境,它与其他环境(甚至同一台计算机上的环境)隔离。 换句话说,沙箱提供了一个隔离且安全的环境来安装和运行程序。
在Chrome V8中,每个进程都是沙盒环境。
V8 的 Sandbox 模式是 V8 为了保护进程内其他内存( 避免黑客基于 V8 的漏洞实现对进程内任意内存的读写 )而设计的一套机制。
原理上,主要是通过提前开辟一块足够大的虚拟内存( 毕竟 64 位设备虚拟内存不要钱 ),即沙箱,并将 V8 可访问到的内存都分配到这块内存上来。
实际上,随着指针压缩这一机制在 64 位设备上的默认启用,多数的 JS 对象原本就已经被限制分配在了预分配出来的 4G 地址空间上。此外,V8 可访问到的内存大体上可以分为三种:即 ArrayBuffer 对象指向的内存( BackingStore ),WebAssembly 的 Memory 对象指向的内存,以及 V8 的宿主应用基于 V8 的 API 提供给 V8 访问的内存( 外部指针指向的内存,通常被分配在我们称之为 Native 堆的位置 )。
对于 ArrayBuffer 和 WebAssembly 的 Memory 这两类情况,限制分配在沙箱内部。
沙箱的内存权限被限制为只可读写不可执行,而 JIT 所需要使用的可执行内存则会另行分配。
serverless 和 V8 引擎
许多开发人员使用 V8 引擎为其网站构建和部署无服务(serverless)应用。
V8 引擎附带 WebAssembly 支持,可以支持多种编程语言。
冷启动是 serverless 计算中的一个大问题,但在 V8 上运行函数可以让这些函数在几毫秒内运行完成。
以下是部署在 serverless 服务器中的一个案例:
vbnet
addEventListener("get", event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
try {
let section = request.headers.get("t-section");
console.log(section);
let response = await fetch(request);
return response;
} catch (error) {
return new Response(error.stack || error, {
status: 500
});
}
}
代码中的addEventListener函数用于注册一个事件监听器,监听"get"事件。当收到一个"get"请求时,会调用handleRequest函数处理该请求。
handleRequest函数是一个异步函数,它接收一个请求对象作为参数。函数首先尝试从请求头中获取"t-section"字段的值,并将其打印到控制台。然后,函数使用fetch函数发送原始请求,等待响应返回。最后,函数返回该响应。
其它的 JavaScript 引擎
- Chakra:Microsoft 开发的 JavaScript 引擎,主要运用在 IE 浏览器。 该引擎的一个显着特点是在多核 CPU 上与 Web 浏览器并行地对js代码进行 JIT 编译。
- SpiderMonkey:Netscape 的 Brendan Eich 开发的 JavaScript 和 WebAssembly 引擎,目前主要用于 Firefox、Servo 和各种其他项目。 它是用 C++、Rust 和 JavaScript 编写的。
- Webkit:Apple 开发的浏览器引擎,主要应用于Safari、App Store以及macOS、iOS和Linux上的许多其他应用程序。 WebKit 还被 BlackBerry 浏览器、PS3 及以上版本的 PlayStation 游戏机以及 Amazon Kindle 电子阅读器中的浏览器使用。
- Nashorn:它是一个用Java编程语言编写的JavaScript引擎,首先由Oracle开发,然后由OpenJDK社区开发。 Nashorn 包含在 Java 8 到 JDK 14 中。
参考: