关于 V8 引擎你必须知道的基础知识

概览

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 引擎还使用其他优化技术,在当时遥遥领先:

  1. "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();
  1. "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)?

  1. 以空间换取速度:调用堆栈(Callback stack)需要内存中连续的空间以使进程更快,但内存中连续的空间很少。 为了解决这个问题,浏览器开发人员限制了最大的内存大小,这就是堆栈溢出错误的原因。 浏览器通常在调用堆栈中存储有限的数据,例如整数和其他主要数据类型(基本类型会在stack中存储,例如number,string,boolean等等)。

  2. 以速度换取空间:堆(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团队引入了字节码。为什么?因为使用机器代码会带来额外的问题。

  1. 机器代码需要大量的内存

V8引擎将编译后的机器码存储在内存中,并在页面加载时重用它。

编译机器码可以将10000行 javascript脚本转换成2000万行 机器码。这是大约2000倍的内存空间。

尽管字节码比原始 JavaScript 大,但比相应的机器码小得多。

由于体积减小,浏览器可以缓存所有已编译的字节码,跳过前面的所有步骤并直接执行。

  1. 机器代码并不总是比字节码快

字节码的编译时间较短,但代价是执行步骤较慢。

所以你并不能说机器码一定快,毕竟还需要更慢的转换时间。

  1. 机器代码增加了开发的复杂性

不同的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 引擎

  1. Chakra:Microsoft 开发的 JavaScript 引擎,主要运用在 IE 浏览器。 该引擎的一个显着特点是在多核 CPU 上与 Web 浏览器并行地对js代码进行 JIT 编译。
  2. SpiderMonkey:Netscape 的 Brendan Eich 开发的 JavaScript 和 WebAssembly 引擎,目前主要用于 Firefox、Servo 和各种其他项目。 它是用 C++、Rust 和 JavaScript 编写的。
  3. Webkit:Apple 开发的浏览器引擎,主要应用于Safari、App Store以及macOS、iOS和Linux上的许多其他应用程序。 WebKit 还被 BlackBerry 浏览器、PS3 及以上版本的 PlayStation 游戏机以及 Amazon Kindle 电子阅读器中的浏览器使用。
  4. Nashorn:它是一个用Java编程语言编写的JavaScript引擎,首先由Oracle开发,然后由OpenJDK社区开发。 Nashorn 包含在 Java 8 到 JDK 14 中。

参考:

相关推荐
前端百草阁20 分钟前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜20 分钟前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund40421 分钟前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish21 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple21 分钟前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five23 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序23 分钟前
vue3 封装request请求
java·前端·typescript·vue
临枫54123 分钟前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
酷酷的威朗普24 分钟前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5
前端每日三省24 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript