揭秘Google V8引擎:带你了解JavaScript的运行之谜

346 阅读26分钟

什么是V8?

V8 是由 Google 开发的开源 JavaScript 引擎,也称为虚拟机。在浏览器端,它支撑着 Chrome 以及众多 Chromium 内核的浏览器运行。在服务端,它是 Node.js 及 Deno 框架的执行环境。在桌面端和 IoT 领域,也同样有 V8 的一席之地。

简单来说, JavaScript 虚拟机可以理解成是一个翻译程序,将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言。如下图所示:

为什么要学习V8?

对于大部分人来说,V8 虚拟机还只是一个黑盒,我们将一段代码丢给这个黑盒,它便会返回结果,并没有深入了解过它的工作原理。但其实我们了解V8后,当我们遇到项目的占用内存过高,或者页面响应速度过慢等问题的时候,我们可能会有一些解决思路。因为这些问题都与 V8 的基本运行机制有关。下面是本文所要讲解的主要内容,为了让大家更加了解V8,本文d8进行调试。如果你有兴趣的话,就接着往下看吧~

image.png

V8的基础环境

V8启动时会初始化执行环境

  • 栈空间和堆空间
  • 全局执行上下文
  • 全局作用域
  • 事件循环系统
  • 其他......

我们先来简单复习一下JS的执行过程,这段代码也可以在vscode里面debug去查看堆栈环境。

function foo() {
  var myName = "lijiayan";
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function () {
      console.log(test1);
      return myName;
    },
    setName: function (newName) {
      myName = newName;
    },
  };
  return innerBar;
}
var bar = foo();
bar.setName("jiaynn");
bar.getName();
console.log(bar.getName());

下面这张图可视化了这段代码的执行环境

image.png

V8的执行流程

接下来,我们站在 JavaScript 引擎 V8 的视角,来分析 JavaScript 代码是如何被执行的

一些概念

编译器和解释器(Compiler & Interpreter

之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。按语言的执行流程,可以把语言划分为编译型语言和解释型语言。

  • 编译型语言:在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。
  • 解释型语言:在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

image.png

抽象语法树

可以通过这个网站查看生成的AST树,个人觉得这个网站查看AST很清晰明了:astexplorer.net/

function add(x, y) {
  var z = x + y;
  return z;
}
console.log(add(1, 2));

Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

ESLint 也使用 AST。其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

字节码

字节码是介于 AST 和机器代码的中间代码。字节码与平台无关,不管是哪个平台,字节码都是一致的

解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。

执行过程

V8采用了混合 编译执行和解释执行 这两种手段,我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。V8 为了提升 JavaScript 的执行速度,实现了 JIT 机制。

这是一种权衡策略,因为这两种方法都各自有各自的优缺点,解释执行的启动速度快,但是执行时的速度慢,而编译执行的启动速度慢,但是执行时的速度快。

上图是一段js代码的执行过程的整体流程,接下来我们来详细的看一下每一个步骤发生了什么。

(1)生成抽象语法树

为什么需要生成AST?

因为对 V8 来说JavaScript代码只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。

结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。

将源代码转换为抽象语法树(AST) ,并生成执行上下文,执行上下文就是代码在执行过程中的环境信息。

将 JS 代码解析成 AST主要分为两个阶段:

  1. 词法分析:这个阶段会将源代码拆成最小的、不可再分的词法单元,称为 token。比如代码 var a = 1;通常会被分解成 var 、a、=、1、; 这五个词法单元。代码中的空格在 JavaScript 中是直接忽略的,简单来说就是将 JavaScript 代码解析成一个个令牌(Token)
  2. 语法分析:这个过程是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。如果源码存在语法错误,这一步就会终止,并抛出一个语法错误,简单来说就是将令牌组装成一棵抽象的语法树(AST)

通过词法分析会对代码逐个字符进行解析,生成类似下面结构的令牌(Token),这些令牌类型各不相同,有关键字、标识符、符号、数字等。

(2)生成字节码

有了 抽象语法树 AST 和执行上下文后,就轮到解释器就登场了,它会根据 AST 生成字节码,并解释执行字节码。

在 V8 的早期版本中,是通过 AST 直接转换成机器码的。将 AST 直接转换为机器码会存在一些问题:

  • 时间问题:编译时间过久,影响代码启动速度;
  • 空间问题:缓存编译后的二进制代码占用更多的内存。

字节码的优势有如下三点:

  • 解决启动问题:生成字节码的时间很短;
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。(字节码是平台无关的,机器码针对不同的平台都是不一样的)

理解了什么是字节码,我们再来对比下高级代码、字节码和机器码,你可以参考下图:

我们也可以通过 d8 查看生成的字节码和机器码。通过下面两行代码我们将js代码的机器码和字节码输入到 code 和 bytecode 文件中。

image.png

//通过d8调试一下
function add(x, y) {
  var z = x + y;
  return z;
}
console.log(add(1, 2));

image.png

可以看到字节码占比远小于生成的机器码,接下来,我们来详细的查看一下上面这段代码的字节码:

通常有两种类型的解释器,基于栈 (Stack-based)和基于寄存器 (Register-based),基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。V8 虚拟机采用了基于寄存器的设计。

image.png

  • Ldar a1,(LoaD Accumulator from Register)这是将 a1 寄存器中的参数值加载到累加器中,这时候第二个参数就保存到累加器中了。
  • 接下来执行加法操作,Add a0, [0],因为 a0 是第一个寄存器,存放了第一个参数,Add a0 就是将第一个寄存器中的值和累加器中的值相加,也就是将累加器中的 2 和通用寄存器中 a0 中的 1 进行相加,同时将相加后的结果 3 保存到累加器中。

[0],这个符号是feedback vector slot,中文称为反馈向量槽,它是一个数组,解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了,目的是为了给 TurboFan 优化编译器提供优化信息,很多字节码都会为反馈向量槽提供运行时信息。

  • 现在累加器中就保存了相加后的结果,然后执行第四段字节码,Star r0,(Store Accumulator to Register)这是将累加器中的值,也就是 1+2 的结果 3 保存到寄存器 r0 中,那么现在寄存器 r0 中的值就是 3 了。
  • return

(3)解释执行字节码,并输出执行结果

在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码

当某段代码被标记为热点代码后,V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。

但是,JavaScript 是动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

image.png

接下来,我们可以看一下V8是如何优化一段JavaScript代码的

//调试 观察优化信息
let a = { x: 1 };
function bar(obj) {
  return obj.x;
}
function foo() {
  let ret = 0;
  for (let i = 1; i < 10000; i++) {
    ret += bar(a);
  }
  return ret;
}
foo();

V8 采取了 TurboFan 的 OSR 优化,OSR 全称是 On-Stack Replacement,它是一种在运行时替换正在运行的函数的栈帧的技术,如果在 foo 函数中,每次调用 bar 函数时,都要创建 bar 函数的栈帧,等 bar 函数执行结束之后,又要销毁 bar 函数的栈帧。

通常情况下,这没有问题,但是在 foo 函数中,采用了大量的循环来重复调用 bar 函数,这就意味着 V8 需要不断为 bar 函数创建栈帧,销毁栈帧,那么这样势必会影响到 foo 函数的执行效率。

于是,V8 采用了 OSR 技术,将 bar 函数和 foo 函数合并成一个新的函数,具体你可以参考下图:

如果我在 foo 函数里面执行了 10 万次循环,在循环体内调用了 10 万次 bar 函数,那么 V8 会实现两次优化,第一次是将 foo 函数编译成优化的二进制代码,第二次是将 foo 函数和 bar 函数合成为一个新的函数。

(4)执行过程优化

如果JavaScript代码在执行前都要完全经过解析才能执行,那可能会面临以下问题:

  • 代码执行时间变长:一次性解析所有代码会增加代码的运行时间。
  • 消耗更多内存:解析完的 AST 以及根据 AST 编译后的字节码都会存放在内存中,会占用更多内存空间。
  • 占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。

所以,V8 引擎使用了延迟解析:在解析过程中,对于不是立即执行的函数,只进行预解析;只有当函数调用时,才对函数进行全量解析。

进行预解析时,只验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST,而实现预解析的,就是 Pre-Parser 解析器。

以下面代码为例:

//调试惰性解析
function foo() {
  let a = 1;
  let b = 2;
  return a + b;
}
foo();

V8 解析器是从上往下解析代码的,当解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST。

之后解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,执行函数调用 foo(),这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给解释器编译执行。

我们可以通过d8来查看下这段代码的字节码,观察函数内部的代码是否被生成了字节码。

image.png

image.png

在上面两张图中,我们可以很明显的看到在不执行该函数的时候,V8不会解析函数内部代码,只会解析函数声明。而当执行了该函数的时候,V8就会解析函数内部代码。

V8中的对象表示

在 V8 中,对象主要由三个指针构成,分别是隐藏类(Hidden Class),Property 还有 Element

其中,隐藏类用于描述对象的结构。PropertyElement 用于存放对象的属性,它们的区别主要体现在键名能否被索引。

Property 和 Element

看一个例子:

let a = { 1: "a", 2: "b", "first": 1, 3: "c", "second": 2 }
let b = { "second": 2, 1: "a", 3: "c", 2: "b", "first": 1 }
console.log(a);
console.log(b);

  • 设置的索引属性属性被最先打印出来了,并且是按照数字大小的顺序打印的;
  • 设置的命名属性依然是按照之前的设置顺序打印的;

之所以出现这样的结果,是因为在 ECMAScript 规范中定义了索引属性应该按照索引值大小升序排列,命名属性根据创建时的顺序升序排列。

在这里我们把对象中的索引属性称为排序属性,在 V8 中被称为 elements,命名属性就被称为常规属性,在 V8 中被称为 properties。我们通过下面示例来查看这两种属性的存取:

function Foo() {}
var bar = new Foo();

bar[1] = 'first';
bar[2] = 'second';
bar.first = 'first';
bar.second = 'second';

对这段代码,在浏览器控制台生成快照。

image.png

image.png

我们注意到在上述的示例的内存快照中并没有发现properties属性,相反,命名属性是直接保存在对象内的,这实际上是因为将不同的属性分别保存到elements属性和properties属性会导致我们在查找元素时多了一步查找elements或properties对象的操作,V8因此对命名属性采取了一种更为权衡的策略以加快属性的查找效率,也对应了V8中命名属性的三种不同存储方式:对象内属性、快属性和慢属性。

对象内属性、快属性、慢属性

V8最终对命名属性所采取的策略主要是将部分命名属性直接存储到对象本身,我们将其称为对象内属性(in-object properties),对象内属性由于直接保存在对象本身,相比于快属性和慢属性其少了一次寻址的时间,因此对象内属性是三种存储方式中访问速度最快的。

不过对象内属性的数量是由对象的初始大小决定的,如果添加的属性超出那么他们会被分配在properties属性对象的属性存储空间中,虽然多了一层间接层,但是可以自由的扩容,同时我们根据属性存储空间是否为线性存储空间将属性区分为快属性(fast properties)和慢属性(slow properties),我们以如下示例进行说明:

function Foo2() {}

var a = new Foo2()
var b = new Foo2()
var c = new Foo2()

for (var i = 0; i < 10; i ++) {
  a[new Array(i+2).join('a')] = 'aaa'
}

for (var i = 0; i < 12; i ++) {
  b[new Array(i+2).join('b')] = 'bbb'
}

for (var i = 0; i < 30; i ++) {
  c[new Array(i+2).join('c')] = 'ccc'
}

可以看到a均采用了对象内属性进行存储。

image.png

而b对象,超出限制后,其余命名属性采用了快属性进行存储。

image.png

而c对象中,为了加快访问速度,采用了慢属性进行存储,也就是所谓的哈希存储

image.png

image.png

为什么要分三种存储方式?

早期的 JS 引擎都是用慢属性存储,前两者都是出于优化这个存储方式而出现的。

我们知道所有的数据在底层都会表示为二进制,如果程序逻辑只涉及二进制的位运算(包括与、或、非)速度是最快的,对象内属性和快属性做的事情很简单,线性查找每一个位置是不是指定的位置,这部分的耗时可以简单理解为至多N次简单位运算(N为属性的总数)的耗时,而慢属性则需要先经过哈希算法计算,哈希运算的本身耗时若干倍于简单位运算,另外哈希表是个二维空间,所以我们在通过哈希算法计算出其中一维的坐标后另一维仍需要线性查找

因此当属性少时快属性的存取效率远高于慢属性但是当属性太多时快属性的提取效率反而低于慢属性,假设我们需要访问第120个属性,那么快属性就需要至多120次简单位运算,而使用慢属性,假设哈希运算的耗时与60次简单位运算相同,但是第二维的线性比较远小于60次的,此时慢属性的提取效率是优于快属性的,因此无论是对象内属性、快属性还是慢属性,V8不同的选择最终都是为了更高的存取效率。

隐藏类

为什么要引入隐藏类?

为了提升对象的属性访问速度而引入了隐藏类。

js对象可以在运行时改变类型,添加或删除属性。相比之下,像 C++ 这样的静态语言,类型一旦创建便不可更改,属性可以通过固定的偏移量进行访问。

//共用隐藏类
function Article(){
  this.title='Inauguration Ceremony Features Kazoo Band'  ;
}
let a1=new Article();
let a2=new Article();

image.png

//添加属性后,不共用隐藏类
a2.author='J. K. Rowling';

image.png

//解决
function Article(opt_author) {
   this.title = "harry potter";
  this.author = opt_author;
}
let a1 = new Article();
let a2 = new Article("J. K. Rowling");

image.png

//另一个例子
function Article() {
  this.title = "harry potter";
 this.author = "J. K. Rowling";
}
let a1 = new Article();
let a2 = new Article();
delete a1.author;

image.png

//解决
function Article() {
  this.title = "harry potter";
  this.author = "J. K. Rowling";
}

let a1 = new Article();
let a2 = new Article();

a1.author = null;

image.png

为什么要尽可能共用隐藏类?

  • 减少隐藏类的创建次数,也间接加速了代码的执行速度;
  • 减少了隐藏类的存储空间。

JavaScript 依然是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类,这对于 V8 的执行效率来说,是一笔大的开销。

  • 使用字面量初始化对象时,要保证属性的顺序是一致的
  • 尽量使用字面量一次性初始化完整对象属性
  • 尽量避免使用 delete 方法

内联缓存 (Inline Cache)

现在我们知道了 V8 为每个对象配置了一个隐藏类,隐藏类描述了该对象的形状,V8 可以通过隐藏类快速获取对象的属性值。不过这里还有另外一类问题需要考虑。

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

当 V8 调用 loadX 的时候,会先查找参数 o 的隐藏类,然后利用隐藏类中的 x 属性的偏移量查找到 x 的属性值,虽然利用隐藏类能够快速提升对象属性的查找速度,但是依然有一个查找隐藏类和查找隐藏类中的偏移量两个操作,如果 loadX 在代码中会被重复执行,依然影响到了属性的查找效率。

V8为了加速运算而引入了内联缓存

V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。

IC 会为每个函数维护一个反馈向量 (FeedBack Vector) ,反馈向量记录了函数在执行过程中的一些关键的中间数据。关于函数和反馈向量的关系你可以参看下图:

反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行 loadX 函数的中间数据写入到反馈向量的插槽中。

我们可以看一个例子,来测试一下内联缓存:

function Person(name) {
  this.name = name;
}

Person.prototype.getName = function () {
  return this.name;
};
let start = performance.now();
for (let i = 0; i < 1000000; i++) {
  new Person("zhangshan").getName();
}
let end = performance.now();
console.log("Without inline caching:", end - start);

start = performance.now();
for (let i = 0; i < 1000000; i++) {
  new Person("zhangshan").getName();
}
end = performance.now();
console.log("With inline caching:", end - start);

可以发现使用了内联缓存后,执行速度提升了很多

image.png

再看一个例子,了解反馈向量:

function foo(){}
function loadX(o) { 
    o.y = 4
    foo()
    return o.x
}
loadX({x:1,y:4})

获取这段函数的字节码

image.png

当 V8 再次调用 loadX 函数时,比如执行到 loadX 函数中的 return o.x 语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能直接去内存中获取 o.x 的属性值了。

但如果对象的形状不是固定的,那 V8 会怎么处理呢?

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3, y:6,z:4}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

V8 会选择将新的隐藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的第一个槽里就包含了两个隐藏类和偏移量。

当 V8 再次执行 loadX 函数中的 o.x 语句时,同样会查找反馈向量表,发现第一个槽中记录了两个隐藏类。这时,V8 需要额外做一件事,那就是拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较,如果新的隐藏类和第一个插槽中某个隐藏类相同,那么就使用该命中的隐藏类的偏移量。如果没有相同的呢?同样将新的信息添加到反馈向量的第一个插槽中。

现在我们知道了,一个反馈向量的一个插槽中可以包含多个隐藏类的信息,那么:

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic);
  • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic);
  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)。

垃圾回收

JavaScript 内存管理机制

计算机程序语言都运行在对应的代码引擎上,使用内存过程可以分为以下三个步骤:

  1. 分配所需要的系统内存空间;
  2. 使用分配到的内存进行读或写等操作;
  3. 不需要使用内存时,将其空间释放或者归还。

对于JavaScript:

  • 栈的基本类型,可以通过操作系统直接处理;
  • 堆的引用类型,正是由于可以经常变化,大小不固定,因此需要 JavaScript 的引擎通过垃圾回收机制来处理。

所谓的垃圾回收是指:JavaScript 代码运行时,需要分配内存空间来储存变量和值。当变量不在参与运行时,就需要系统收回被占用的内存空间。 Javascript 具有自动垃圾回收机制,会定期对那些不再使用的变量、对象所占用的内存进行释放,原理就是找到不再使用的变量,然后释放掉其占用的内存。

V8 垃圾回收过程

任何垃圾回收器都有一些必须定期完成的基本任务。

  1. 确定存活/死亡对象
  2. 回收/再利用死亡对象所占用的内存
  3. 压缩/整理内存(可选)

V8 的垃圾回收主要有三个阶段:标记、清除和整理。

⽬前 V8 使用了两个垃圾回收器:主垃圾回收器副垃圾回收器。下面就来看看 V8 是如何实现垃圾回收的。

在 V8 中,会把堆分为新生代和老生代两个区域新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象:

对于这两块区域,V8分别使⽤两个不同的垃圾回收器,以便更⾼效地实施垃圾回收:

  • 副垃圾回收器:负责新⽣代的垃圾回收。
  • 主垃圾回收器:负责⽼⽣代的垃圾回收。

(1)副垃圾回收器(新生代)

新加⼊的对象都会存放到对象区域,当对象区域快被写满时,就需要执⾏⼀次垃圾清理操作:首先要对对象区域中的垃圾做标记,标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来。这个复制过程就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域,这种算法称之为 Scavenge 算法,这样就完成了垃圾对象的回收操作。同时,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

暂时无法在飞书文档外展示此内容

不过,副垃圾回收器每次执⾏清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新⽣区空间设置得太⼤了,那么每次清理的时间就会过久,所以为了执⾏效率,⼀般新⽣区的空间会被设置得⽐较⼩。 也正是因为新⽣区的空间不⼤,所以很容易被存活的对象装满整个区域,副垃圾回收器⼀旦监控对象装满了,便执⾏垃圾回收。同时,副垃圾回收器还会采⽤对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到⽼⽣代中。

(2)主垃圾回收器(老生代)

主垃圾回收器主要负责⽼⽣代中的垃圾回收。除了新⽣代中晋升的对象,⼀些⼤的对象会直接被分配到⽼⽣代⾥。因此,⽼⽣代中的对象有两个特点:

  • 对象占⽤空间⼤
  • 对象存活时间⻓

由于⽼⽣代的对象⽐较⼤,若要在⽼⽣代中使⽤ Scavenge 算法进⾏垃圾回收,复制这些⼤的对象将会花费较多时间,从⽽导致回收执⾏效率不⾼,同时还会浪费空间。所以,主垃圾回收器采⽤标记清除的算法进⾏垃圾回收。

这种方式分为标记清除两个阶段:

  1. 标记阶段: 从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
  2. 清除阶段: 主垃圾回收器会直接将标记为垃圾的数据清理掉。
  3. ⽽碎⽚过多会导致⼤对象⽆法分配到⾜够的连续内存,于是⼜引⼊了另外⼀种算法——标记整理。

暂时无法在飞书文档外展示此内容

名称算法大小
新生代Parallel Scavenge 算法32MB(64位)/ 16MB(32位)
老生代标记清除、标记整理算法1400MB(64位)/ 700MB(32 位)
//调试 垃圾回收
function strToArray(str) {
  const len = str.length;
  let arr = new Uint16Array(str.length);
  for (let i = 0; i < len; ++i) {
    arr[i] = str.charCodeAt(i);
  }
  return arr;
}
function foo() {
  let i = 0;
  let str = "test V8 GC";
  while (i++ < 1e5) {
    strToArray(str);
  }
}
foo();

image.png 可以看到这段代码频繁的触发了垃圾回收,如何解决这个问题?

//解决方法
function strToArray(str, bufferView) {
  const len = str.length;
  for (let i = 0; i < len; ++i) {
    bufferView[i] = str.charCodeAt(i);
  }
  return bufferView;
}
function foo() {
  let i = 0;
  let str = "test V8 GC";
  let buffer = new ArrayBuffer(str.length * 2);
  let bufferView = new Uint16Array(buffer);
  while (i++ < 1e5) {
    strToArray(str, bufferView);
  }
}
foo();

image.png

事件循环

后面再补。。。

V8知识图谱

参考文档

极客时间—图解V8

安装v8

V8中的对象表示

v8.dev/blog

JavaScript 引擎基础:Shapes 和 Inline Caches

mathiasbynens.be/notes/shape…