# 什么是 V8

JavaScript 借鉴了很多语言的特性,比如 C 语言的基本语法、Java 的类型系统和内存管理、Scheme 的函数作为一等公民,还有 Self 基于原型(prototype)的继承机制。

V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,是使用最广泛的 JavaScript 引擎,核心功能就是执行 JavaScript 代码。

V8 率先引入了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,同时还引入了惰性编译、内联缓存、隐藏类等机制,极大的提升了 JavaScript 的编译执行速度。

# V8 是怎么执行 JavaScript 代码的

# 高级语言的两种执行方式

1. 解释执行

需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。

解释执行的启动速度快,但是执行时的速度慢。

2. 编译执行

需要先将源代码转换为中间代码,然后编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。

编译执行的启动速度慢,但是执行时的速度快。

# V8 执行 JavaScript 代码的完整流程

V8 采用即时编译技术来执行 JavaScript 代码,这是一种混合编译执行和解释执行的技术。它的完整执行流程如下:

1. 初始化基础环境,包括:堆和栈空间、全局执行上下文(内置函数、全局变量)、全局作用域、事件循环系统等等。

2. 解析器解析源代码,生成抽象语法树(AST)以及相关的作用域。

3. 解释器依据 AST 和作用域生成字节码。

4. 解释器解释执行字节码。

5. 监听热点代码。

6. 优化热点代码为二进制的机器代码。

7. 反优化生成的二进制机器代码。

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

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

不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。基于这点,在实际开发过程中,尽量一次性声明完对象,避免在执行过程中动态修改对象属性。

补充

  • 字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。

  • 解释器可以直接执行字节码,不需要将其转换成二进制代码再执行。

  • 其他使用了 JIT 技术的虚拟机有:JVM、LuaJIT(采用 C 语言写的 Lua 代码的解释器)、GraalVM(由Oracle创建的Java虚拟机(JVM)和Java开发工具包(JDK))。

# V8 中的 JIT

V8 引擎中的 JIT(Just-In-Time)编译是⼀种动态编译技术,⽤于提⾼ JavaScript 代码的执⾏效率。V8 引擎通过 JIT 编译将 JavaScript 代码转换成更接近机器码的格式,从⽽加速代码的执⾏。

在这个过程中,V8 使⽤了两个主要的编译器:Ignition(解释器)和 TurboFan(优化编译器)。

# Ignition(解释器)

1. 基本概念

Ignition 是 V8 的解释器,它⾸先将 JavaScript 代码转换成字节码。字节码是⼀种⽐原始源代码更低级,但⽐机器码更⾼级的指令集。

2. ⼯作原理

  • 当 JavaScript 代码⾸次执⾏时,Ignition 会解释执⾏这些字节码。

  • 这⼀过程⽐直接执⾏原始 JavaScript 代码快,但⽐直接执⾏机器码慢。

3. 优势

  • 相⽐直接编译为机器码,字节码的⽣成速度更快,内存占⽤也更少。

  • Ignition 为后续的优化编译提供了关键的性能数据。

# TurboFan(优化编译器)

1. 基本概念

TurboFan 是 V8 的优化编译器。它根据代码的运⾏情况对热点代码(即频繁执⾏的代码⽚段)进⾏优化。

2. ⼯作原理

  • TurboFan 分析 Ignition 收集的性能数据,识别出热点代码。

  • 然后,它对这些热点代码进⾏优化编译,将其转换为⾼效的机器码。

3. 优势

  • TurboFan 能够进⾏⾼级优化,例如内联函数、消除冗余代码、优化循环等。

  • 它能适应代码在运⾏时的实际表现,动态调整优化策略。

# JIT 编译流程

1. 解释执⾏

JavaScript 代码⾸先被 Ignition 解释执⾏,转换为字节码。

2. 性能监控

Ignition 在执⾏字节码的过程中收集性能数据,识别出哪些是热点代码。

3. 优化编译

TurboFan 根据性能数据对热点代码进⾏优化编译,⽣成⾼效的机器码。

4. 替换热点代码

优化后的机器码替换原字节码中的热点代码部分。

5. 回退机制

如果优化假设不再有效,TurboFan 可以将代码回退到字节码,重新优化。

V8 引擎中的 JIT 编译通过 Ignition 和 TurboFan 的组合来优化 JavaScript 代码的执⾏。Ignition 以较低的成本快速地将代码转换为字节码并执⾏,⽽ TurboFan 则负责进⼀步优化频繁执⾏的热点代码。这种组合使得 V8 能够在保持快速响应的同时,针对性地优化 代码执⾏效率。

# JIT 和运行时的区别

JIT 就是编译代码,运行时就是代码执行。

# V8 安装和 d8 shell 使用

# 安装 V8

在 mac 上可以使用以下命令来安装 V8:

brew instal v8
1

也可以使用 jsvu (opens new window) 来安装 V8,jsvu 是 JavaScript(引擎)版本更新器,使用它可以轻松安装各种 JavaScript 引擎的最新版本,而无需从源代码编译它们。

# 使用 d8

要查看 V8 (opens new window) 中间生成的一些结构,可以使用 V8 提供的调试工具 d8 来查看,d8 是一个非常有用的调试工具,可以把它看成是 debug for V8 的缩写。我们可以使用 d8 来查看 V8 在执行 JavaScript 过程中的各种中间数据,比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还可以使用 d8 提供的私有 API 查看一些内部信息。

d8 是通过 V8 源码编译出来的,编译方法可以参考以下文章。

不过编译过程比较繁琐,如果不想自己编译,也可以直接下载使用编译好的 d8。

d8 的一些常用命令如下:

d8 --help // 查看全部可用的命令
d8 --help |grep print // 只查看 print 相关的命令
d8 --print-ast test.js // 查看生成的 ast
d8 --print-scopes test.js // 查看生成的作用域
d8 --print-bytecode test.js // 查看生成的字节码
d8 --trace-opt test.js // 查看哪些代码被优化了(热点代码)
pt --trace-deopt test.js // 查看哪些代码被反优化了
d8 --allow-natives-syntax test.js // 使用 V8 提供的一些内部方法
d8 --trace-opt-verbose test.js // 跟踪代码执行细节
1
2
3
4
5
6
7
8
9

如果是在 windows 系统上,可能会缺少 grep 程序,可以去这里 (opens new window)下载。安装完成之后,需要手动将 grep 程序所在的目录添加到环境变量 PATH 中,这样才能在控制台使用 grep 命令。

V8 提供的内部方法可以查看这里 (opens new window),下面是一个使用示例。

// test.js
function Foo(property_num,element_num) {
  //添加可索引属性
  for (let i = 0; i < element_num; i++) {
      this[i] = `element${i}`
  }
  //添加常规属性
  for (let i = 0; i < property_num; i++) {
      let ppt = `property${i}`
      this[ppt] = ppt
  }
}
var bar = new Foo(10, 10)
console.log(%HasFastProperties(bar)); // HasFastProperties 来检查一个对象是否拥有快属性
delete bar.property2
console.log(%HasFastProperties(bar));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 快属性和慢属性

V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略。V8 采用了以下策略来提升对象属性的访问速度。

# 常规属性 (properties) 和排序属性 (element)

对象中的数字属性称为排序属性,在 V8 中被称为 elements;字符串属性称为常规属性,在 V8 中被称为 properties。

在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。可以参考下面这段代码的打印结果:

function Foo() {
  this[100] = 'test-100'
  this[1] = 'test-1'
  this["B"] = 'bar-B'
  this[50] = 'test-50'
  this[9] =  'test-9'
  this[8] = 'test-8'
  this[3] = 'test-3'
  this[5] = 'test-5'
  this["A"] = 'bar-A'
  this["C"] = 'bar-C'
}
var bar = new Foo()

for(key in bar){
  console.log(`index:${key}  value:${bar[key]}`)
}

/*
  index:1  value:test-1
  index:3  value:test-3
  index:5  value:test-5
  index:8  value:test-8
  index:9  value:test-9
  index:50  value:test-50
  index:100  value:test-100
  index:B  value:bar-B
  index:A  value:bar-A
  index:C  value:bar-C
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。

分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。

# 对象内属性

将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 bar.B 这个语句来查找 B 的属性值,V8 会先查找出 properties 属性所指向的对象 properties,然后再在 properties 对象中查找 B 属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率

因此,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,称为对象内属性 (in-object properties)

采用对象内属性之后,常规属性就被保存到 bar 对象本身了,这样当再次使用 bar.B 来查找 B 的属性值时,V8 就可以直接从 bar 对象本身去获取该值就可以了,这种方式减少查找属性值的步骤,增加了查找效率。

对象内属性的数量是固定的,默认是 10 个,如果添加的属性超出了对象分配的空间,则它们将被保存在常规属性存储中。

# 快属性和慢属性

保存在线性数据结构中的属性称为 “快属性”,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要是因为会产生大量时间和内存开销。

如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是 “慢属性” 策略,慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中

# 为什么不建议使用 delete 来删除对象属性

  • 如果删除的不是最后一个快属性的话,快属性就会回退到慢属性;如果删除的是最后一个快属性的话,不会回退。因此,使用 delete 可能会导致查找属性的速度变慢。

  • delete 方法会破坏对象的形状,这会导致 V8 要为该对象重新生成新的隐藏类,造成额外的开销,从而影响执行效率。

可以参考这篇文章:V8 是怎么跑起来的 —— V8 中的对象表示 (opens new window)

# 如何在 Chrome 中查看对象布局

打开 Chrome 开发者工具,然后在控制台执行以下代码:

function Foo(property_num, element_num) {
  //添加常规属性
  for (let i = 0; i < property_num; i++) {
    let ppt = `property${i}`
    this[ppt] = ppt
  }
  //添加排序属性
  for (let i = 0; i < element_num; i++) {
    this[i] = `element${i}`
  }
}
var bar = new Foo(10, 10)
1
2
3
4
5
6
7
8
9
10
11
12

接着打开 Memory 标签,然后点击左上角的小圆点,就可以捕获当前的内存快照。并在搜索框中输入函数名 Foo,就会列出所有经过构造函数 Foo 创建的对象。

从图中可以看到,此时 bar 对象的内存布局为:

  • 10 个常规属性作为对象内属性,存放在 bar 函数内部;

  • 10 个排序属性存放在 elements 中。

由于这里常规属性的数量只有 10 个,所以 V8 直接将这些常规属性作为 bar 对象的对象内属性了。

如果把常规属性的数量调整成 20 个:

var bar2 = new Foo(20, 10)
1

从图中可以看到,此时 bar2 对象的内存布局为:

  • 10 个属性直接存放在 bar2 的对象内;

  • 10 个常规属性以线性数据结构的方式存放在 properties 属性里面;

  • 10 个数字属性存放在 elements 属性里面。

如果常规属性有很多个,比如 100 个:

var bar3 = new Foo(100, 10)
1

从图中可以看到,此时 bar3 对象的内存布局为:

  • 10 个属性直接存放在 bar3 的对象内;

  • 90 个常规属性以非线性字典的数据结构方式存放在 properties 属性里面;

  • 10 个数字属性存放在 elements 属性里面。

# V8 中的函数

# 函数即对象:一等公民

JavaScript 是一门基于对象 (Object-Based) 的语言,但是它却不是一门面向对象的语言 (Object—Oriented Programming Language),因为面向对象语言天生支持封装、继承、多态,但是 JavaScript 并没有直接提供多态的支持

JavaScript 中的对象就是由一组组属性和值构成的集合。对象的属性值有三种类型:

  • 原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变,比如 JavaScript 中的字符串就是原始类型,如果你修改了 JavaScript 中字符串的值,那么 V8 会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为 “原始值”。(null、undefined、boolean、number、string、bigint、symbol

  • 对象类型 (Object),对象的属性值也可以是另外一个对象。

  • 函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法。

函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是和普通对象不同的是,函数可以被调用

# V8 如何处理函数

在 V8 内部,会为函数对象添加了两个隐藏属性:name 和 code。

  • name 属性的值就是函数名称,如果某个函数没有设置函数名,该函数对象的默认的 name 属性值就是 anonymous,表示该函数对象没有被设置名称。

  • code 属性值函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。

补充

  • 在函数对象上覆盖 name 属性也不会影响它在环境中的名称。

    function a(){console.log(1)}
    
    a.name="b"
    console.log(Object.getOwnPropertyDescriptor(a, "name")); // {value: 'a', writable: false, enumerable: false, configurable: true}
    
    Object.defineProperty(a, "name", {
      enumerable: false,
      configurable: false,
      writable: true,
      value: "b"
    });
    console.log(Object.getOwnPropertyDescriptor(a, "name")); // {value: 'b', writable: true, enumerable: false, configurable: false}
    
    a() // 1
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
  • name 属性可以通过打印查看,但是 code 属性无法通过打印查看,因为它是 v8 提供的内部属性,没有提供 js 接口。

# V8 中的函数声明和函数表达式

# 函数声明和函数表达式的区别

// 函数声明,能够正常执行
foo()
function foo() {
  console.log('foo')
}

// 函数表达式,执行会报错:Uncaught TypeError: foo is not a function
foo()
var foo = function () {
  console.log('foo')
}
1
2
3
4
5
6
7
8
9
10
11

两者的区别有以下几点:

  1. 写法不同;

  2. 在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions)

  3. 一个函数表达式可以被用作一个立即调用函数表达式——IIFE(Immediately Invoked Function Expression)

  4. 函数声明的本质是语句,而函数表达式的本质则是表达式。

  • 表达式就是表示值的式子(会返回值),而语句是操作值的式子(不会返回值)。

  • 语句是在编译阶段执行的,表达式是在执行阶段执行的。

// 表达式,都会返回值
x = 5
6 === 5

// 语句,定义变量或者声明了一个函数,都不会返回值
var x
function foo(){
  return 1
}
1
2
3
4
5
6
7
8
9

# V8 如何处理函数声明

V8 在解析 JavaScript 源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为 undefined,如果遇到的是函数声明,那么 V8 会在内存中为声明生成函数对象,并将该对象提升到作用域中

在编译阶段,将所有的变量提升到作用域的过程称为变量提升

# V8 如何处理函数表达式

在编译阶段,V8 并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。

# 立即调用函数表达式

(function() {})()
// 或者
(function() {}())
1
2
3

因为函数立即表达式也是一个表达式,所以 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。

在 ES6 之前,JavaScript 中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。

此外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。比如:

var a = (function () {
  return 1
})()
1
2
3

# 经典面试题

var n = 1;
(function foo(){
  n = 100;
  console.log(n);
}())
console.log(n);
// 100 100

var n = 1;
function foo(){
  n = 100;
  console.log(n);
}
console.log(n);
foo();
// 1 100

var n = 1;
(function foo(){
  var n = 100;
  console.log(n);
}())
console.log(n);
// 100 1

var foo = 1;
(function foo(){
  foo = 100;
  console.log(foo);
}())
console.log(foo);
/*
  ƒ foo(){
    foo = 100;
    console.log(foo);
  }
  1
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# V8 如何实现对象继承

继承就是一个对象可以访问另外一个对象中的属性和方法。

不同的语言实现继承的方式是不同的,其中最典型的两种方式是基于类的设计(C++、Java、C#)和基于原型继承的设计(JavaScript)。

关于原型和原型链的知识可以看这里 —— 原型链总结

# 利用 __proto__ 实现继承(不推荐)

var animal = {
  type: "Default",
  color: "Default",
  getInfo: function () {
    return `Type is: ${this.type},color is ${this.color}.`
  }
}
var dog = {
  type: "Dog",
  color: "Black",
}

dog.__proto__ = animal
dog.getInfo() // Type is: Dog,color is Black.
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在实际项目中,不应该直接通过 _proto_ 来访问或者修改该属性,主要原因有两个:

  • 首先,这是隐藏属性,并不是标准定义的;

  • 其次,直接修改 __proto__ 属性会破坏隐藏类已经优化好的对象结构,相当于要重建整个隐藏类,所以使用该属性会造成严重的性能问题。

# 利用构造函数实现继承

function DogFactory(type, color) {
  this.type = type
  this.color = color
}

var dog = new DogFactory('Dog', 'Black')
1
2
3
4
5
6

当 V8 在执行这句 new 代码时,实际上做了以下几件事:

  1. 创建一个空白对象 dog;

  2. 将 DogFactory 的 prototype 属性设置为 dog 的原型对象;

  3. 使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog,然后在 DogFactory 函数中,利用 this 对对象 dog 执行属性填充操作,最终就创建了对象 dog。

var dog = {}
dog.__proto__ = DogFactory.prototype
DogFactory.call(dog, 'Dog', 'Black')
1
2
3

实现继承

function DogFactory(type,color){
  this.type = type
  this.color = color
  //Mammalia
  //恒温
  this.constant_temperature = 1
}
var dog1 = new DogFactory('Dog', 'Black')
var dog2 = new DogFactory('Dog', 'Black')
var dog3 = new DogFactory('Dog', 'Black')
1
2
3
4
5
6
7
8
9
10

这种写法的缺点就是,对象 dog1 到 dog3 中的 constant_temperature 属性都占用了一块空间(如下图),但是这是一个通用的属性,表示所有的 dog 对象都是恒温动物,所以没有必要在每个对象中都为该属性分配一块空间,应该将该属性设置公用的。

因此,正确的继承方法应该是将这个通用属性添加到 prototype 属性上。

function DogFactory(type,color){
  this.type = type
  this.color = color
  //Mammalia
}
DogFactory.prototype.constant_temperature = 1
var dog1 = new DogFactory('Dog', 'Black')
var dog2 = new DogFactory('Dog', 'Black')
var dog3 = new DogFactory('Dog', 'Black')
1
2
3
4
5
6
7
8
9

# V8 如何查找变量

作用域就是存放变量和函数的地方,而作用域链就是用来沿着函数的作用域一级一级查找变量的。

全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出。 而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了。

JavaScript 是基于词法作用域的,词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域

和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用

因此,查找作用域的顺序是按照函数定义时的位置来决定的

比如在下面这段代码中,bar 和 foo 函数的外部代码都是全局代码,所以无论是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域 –> 全局作用域这个路径来的。

var name = 'name'
var type = 'global'

function foo(){
  var name = 'foo'
  console.log(name) // foo
  console.log(type) // global
}

function bar(){
  var name = 'bar'
  var type = 'function'
  foo()
}
bar()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

下面这段代码中,let 定义的 i 会运行在 for 的块级作用域中,每次执行一次循环,都会创建一个块级作用域。
在这个块级作用域中,又定义了一个函数,而这个函数又引用了函数外部的 i 变量,那么这就产生了闭包,也就是说,所有块级作用域中的 i 都不会被销毁,这里执行了 10 次循环,那么也就创建了 10 个块级作用域,这十个块级作用域中的变量 i 都会被保存在内存中。
那么当再次调用该 a[n]() 时,v8 就会拿出闭包中的变量 i,并将其打印出来,因为每个闭包中的 i 值都不同,所以 a[n]() 时,打印出来的值就是 n。

如果将 for 循环中的 i 变量声明改成 var,那么并不会产生块级作用域,那么函数引用的 i 就是全局作用域中的了,由于全局作用域中只有一个,那么在执行 for 循环的时候,i 的值会一直被改变,最后是 10,所以最终执行 a[n]() 时,无论 n 是多少,打印出来的都是10。

var a = [];
for(let i = 0;i<10;i++){
  a[i]=function(){
    console.log(i)
  }
};
a[2](); // 2
1
2
3
4
5
6
7

# V8 是如何执行加法操作的

V8 在执行加法操作的时候,如果其中一个值的类型是字符串时,则另一个值也需要强制转换为字符串,然后做字符串的连接运算。在其他情况时,所有的值都会转换为数字类型值,然后做数字的相加。

1 + "2"
// 实际上相当于执行了下面的操作
Number(1).toString() + "2"
1
2
3

V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:

  • 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;

  • 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值;

  • 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。

# V8 中的 OSR 优化

当一段代码的循环次数过多时,V8 会采取 TurboFan 的 OSR 优化,OSR 全称是 On-Stack Replacement,它是一种在运行时替换正在运行的函数的栈帧的技术

关于 OSR 的介绍可以看这篇文章:on-stack replacement in v8 (opens new window)

let a = {
  x: 1
}
function bar(obj) {
  return obj.x
}
function foo() {
  let ret = 0
  for (let i = 1; i < 100000; i++) {
    ret += bar(a)
  }
  return ret
}
foo()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

通常情况下,在 foo 函数中,每次调用 bar 函数时,都要创建 bar 函数的栈帧,等 bar 函数执行结束之后,又要销毁 bar 函数的栈帧。但由于上面这段代码中采用了大量的循环来重复调用 bar 函数,这就意味着 V8 需要不断为 bar 函数创建栈帧,销毁栈帧,那么这样势必会影响到 foo 函数的执行效率。

因此,V8 采用了 OSR 技术,将 bar 函数和 foo 函数合并成一个新的函数。如下图:

V8 会对上面那段代码实现两次优化,第一次是将 foo 函数编译成优化的二进制代码,第二次是将 foo 函数和 bar 函数合成为一个新的函数。

如果执行 d8 --trace-opt-verbose test.js 命令,就会看到以下的优化提示信息:

<JSFunction foo (sfi = 0x2c730824fe21)> for optimized recompilation, reason: small function]
1

该信息说明 V8 已经使用 TurboFan 优化编译器将函数 foo 优化成了二进制代码,执行 foo 时,实际上是执行优化过的二进制代码。

# V8 的宿主环境和运行时环境

# 宿主环境

V8 有两种宿主环境:浏览器Node.js

浏览器为 V8 提供基础的消息循环系统、全局变量、Web API,而 V8 的核心是实现了 ECMAScript 标准,提供了 ECMAScript 定义的一些对象和一些核心的函数,包括 Object、Function、String。此外,V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行

如果 V8 使用不当,比如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过久,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的卡死。

Node.js 提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的,比如 Node.js 也会提供一套消息循环系统,也会提供一个运行时的主线程。

# 运行时环境

在执行 JavaScript 代码之前,V8 会准备好代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统

准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。

# 堆空间和栈空间

# 堆和栈的作用

由于 V8 是寄生在浏览器或者 Node.js 这些宿主中的,因此,V8 也是被这些宿主启动的。比如,在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。

栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是 “先进后出” 的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

栈空间的最大的特点是空间连续,所以在栈中每个元素的地址都是固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误。

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,JavaScript 中除了原生类型的数据,其他的都是对象类型,诸如函数、数组,在浏览器中还有 window 对象、document 对象等,这些都是存在堆空间的。

# 函数调用如何影响堆和栈

// 栈溢出
function foo() {
 foo()
}
foo()
1
2
3
4
5
// 正确执行
function foo() {
  setTimeout(foo, 0)
}
1
2
3
4
// 不会栈溢出,但是页面会卡死
function foo() {
  return Promise.resolve().then(foo)
}
foo()
1
2
3
4
5

这三段代码的底层执行逻辑是完全不同的:

  • 第一段代码是在同一个任务中重复调用嵌套的 foo 函数,导致栈无限增。

  • 第二段代码是使用 setTimeout 让 foo 函数在不同的任务中执行,即 setTimeout 会使得 foo 函数在消息队列后面的任务中执行,所以不会影响到当前的栈结构。

  • 第三段代码相当于会在当前这一轮宏任务里不停地创建微任务,执行,创建,执行,创建.....虽然不会爆栈,但也无法去执行下一个宏任务,主线程被卡在这里了,所以页面会卡死。

📌 为什么使用栈结构来管理函数调用?

大部分高级语言都不约而同地采用栈这种结构来管理函数调用,这与函数的特性有关。通常函数有两个主要的特性:

  • 函数可以被调用,我们可以在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;

  • 函数具有作用域机制,所谓作用域机制,是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离,在函数内部定义的变量我们也称为临时变量,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。

函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)。

被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。

由于函数的生命周期和函数的资源分配情况都符合后进先出 (LIFO) 的策略,因此选择栈来管理函数调用关系。

栈的优势:

  • 栈的结构和非常适合函数调用过程。

  • 在栈上分配资源和销毁资源的速度非常快,这主要归结于栈空间是连续的,分配空间和销毁空间只需要移动下指针就可以了。

栈的缺点:

  • 栈是连续的,但是要想在内存中分配一块连续的大空间是非常难的,因此栈空间是有限的。

📌 有了栈为什么还要使用堆?

和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,我们可以在任何时候分配和释放它。

# 全局执行上下文和全局作用域

当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。

执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。而词法环境中,则包含了使用 let、const 等变量的内容。

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。此外,当我们执行了一段全局代码时,如果全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中。

// V8 在执行这段代码的过程中,会在全局执行上下文中添加变量 x 和函数 show_x
var x = 1
function show_x() {
  console.log(x)
}
1
2
3
4
5

全局作用域和全局执行上下文的关系:在 ES6 中,同一个全局执行上下文中,能存在多个作用域。

// 这段代码中有两个作用域,全局作用域和括号内的作用域
// 两个作用域都会保存到全局执行上下文中
var x = 5
{
  let y = 2
  const z = 3
}
1
2
3
4
5
6
7

当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。

var x = 1
function show_x() {
  console.log(x)
}
function bar() {
  show_x()
}
bar()
1
2
3
4
5
6
7
8

当执行到 show_x 的时候,其栈状态如下图所示:

❗️ 作用域和执行上下文的关系

作用域是在编译的时候,函数定义时就确定了;而执行上下文是在运行时,调用函数的时候创建的。

# 事件循环系统

V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。

但是,仅有一个主线程是不行的,因为如果我们开启一个线程,在该线程执行一段代码,那么当该线程执行完这段代码之后,就会自动退出了,执行过程中的一些栈上的数据也随之被销毁,下次再执行另外一个段代码时,我们还需要重新启动一个线程,重新初始化栈数据,这会严重影响到程序执行时的性能。

如果主线程正在执行一个任务,这时候又来了一个新任务,比如 V8 正在操作 DOM,这时候浏览器的网络线程完成了一个页面下载的任务,而且 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务。

事件循环系统是事件驱动的,如果没有任务,那么该线程将被挂起,一旦有新的任务到达了消息队列,那么系统会将这个挂起的线程激活,激活之后线程继续向下执行。

因为所有的任务都是运行在主线程的,在浏览器的页面中,V8 会和页面共用主线程,共用消息队列,所以如果 V8 执行一个函数过久,会影响到浏览器页面的交互性能。

总的来说,事件循环系统主要用来处理任务的排队和任务的调度。

关于事件循环系统之前总结过,可以看这里

# CPU 如何执行二进制机器码

在执行代码时,V8 需要先将 JavaScript 编译成字节码,然后再解释执行字节码,或者将需要优化的字节码编译成二进制,并直接执行二进制代码。即 V8 首先需要将 JavaScript 编译成字节码或者二进制代码,然后再执行。

# 汇编代码

编译成二进制代码的过程中,还会涉及到汇编代码,汇编代码采用助记符(memonic)来编写程序,例如原本是二进制表示的指令,在汇编代码中可以使用单词来表示,比如 mov、add 就分别表示数据的存储和相加。汇编语言和机器语言是一一对应的。

通常将汇编语言编写的程序转换为机器语言的过程称为 “汇编”;反之,机器语言转化为汇编语言的过程称为 “反汇编”

比如下面这段 C 语言代码以及最后编译出来的机器码。

int main()
{  
  int x = 1;
  int y = 2;
  int z = x + y;
  return z;
}
1
2
3
4
5
6
7

图中的一大堆指令按照顺序集合在一起就组成了程序,因此程序的执行,本质上就是 CPU 按照顺序执行这一大堆指令的过程。

# 计算机系统硬件组织结构

下图是一张比较通用的系统硬件组织模型图。

# CPU 的具体执行流程

  1. 二进制代码装载进内存,系统会将第一条指令的地址写入到 PC 寄存器中。

  2. 读取指令:根据 PC 寄存器中的地址,读取到第一条指令,并将 PC 寄存器中内容更新成下一条指令地址。

  3. 分析指令:识别出不同的类型的指令,以及各种获取操作数的方法。

  4. 执行指令:由于 CPU 访问内存花费时间较长,因此 CPU 内部提供了通用寄存器,用来保存关键变量,临时数据等。指令包括加载指令,存储指令,更新指令,跳转指令。如果涉及加减运算,会额外让 ALU 进行运算。

  5. 指令完成后,通过 PC 寄存器取出下一条指令地址,并更新 PC 寄存器中内容,再重复以上步骤。

补充

  • 内存是一个临时存储数据的设备,之所以是临时的存储器,是因为断电之后,内存中的数据都会消失。

  • 我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期

  • 可以这么来理解通用寄存器、内存和硬盘之间的关系:

    通用寄存器是身上的口袋,内存是背包,而硬盘则是行李箱,要从背包里面拿物品会比较不方便,所以常用的物品放在口袋里。但是身上口袋的个数通常不会太多,容量也不会太大,而背包就不同了,它的容量会非常大。

  • 通用寄存器容量小,读写速度快,内存容量大,读写速度慢。

  • 通用寄存器通常用来存放数据或者内存中某块数据的地址,我们把这个地址又称为指针,通常情况下寄存器对存放的数据是没有特别的限制的,比如某个通用寄存器既可以存储数据,也可以存储指针。不过由于历史原因,我们还会将某些专用的数据或者指针存储在专用的通用寄存器中 ,比如 rbp 寄存器通常是用来存放栈帧指针的,rsp 寄存器用来存放栈顶指针的,PC 寄存器用来存放下一条要执行的指令等。

# 惰性解析:V8 如何实现闭包

在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,主要是因为以下两点:

  • 首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。

  • 其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存。

因此,所有主流的 JavaScript 虚拟机都实现了惰性解析

# 什么是惰性解析

所谓惰性解析,是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

function foo(a,b) {
  var d = 100
  var f = 10
  return d + f + a + b;
}
var a = 1
var c = 4
foo(1, 5)
1
2
3
4
5
6
7
8

比如,V8 在解析上面这段代码时,只是会将 foo 函数的函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为 foo 函数的内部代码生成抽象语法树。

代码解析完之后,V8 便会按照顺序自上而下执行代码,当执行到调用 foo 函数时,会从 foo 函数对象中取出函数代码,然后和编译顶层代码一样,V8 会先编译 foo 函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。

这就是惰性解析的大致过程。不过在 V8 实现惰性解析的过程中,需要支持 JavaScript 中的闭包特性,这就使得该解析过程变得异常复杂。

# 闭包的问题

由于子函数使用到了父函数的变量,导致父函数在执行完成以后,它内部被子函数引用的变量无法及时在内存中被释放。

产生该问题的根本原因是 JavaScript 的三个基本特性:

  • 可以在 JavaScript 函数内部定义新的函数;

  • 内部函数中访问父函数中定义的变量;

  • 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。

# 闭包给惰性解析带来的问题

下面是一段典型的闭包代码。

function foo() {
  var d = 20
  return function inner(a, b) {
    const c = a + b + d
    return c
  }
}
const f = foo()
1
2
3
4
5
6
7
8

这段代码的执行流程如下:

  • 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f;

  • 然后 foo 函数执行结束,执行上下文被 V8 销毁;

  • 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。

按照通用的做法,d 已经被 v8 销毁了,但是由于存活的函数 inner 依然引用了 foo 函数中的变量 d,这样就会带来两个问题:

  • 当 foo 执行结束时,变量 d 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?

  • 如果采用了惰性解析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 inner 函数,那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d。

因此,在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 inner 函数,但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫做预解析器。

# 预解析器解决闭包带来的问题

V8 引入了预解析器,当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析。预解析不会生成 AST 和作用域,它只会做以下两件事:

  • 判断当前函数是不是存在一些语法上的错误;

  • 检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

# 字节码

所谓字节码,是指编译过程中的中间代码,一般是在虚拟机上执行的代码,不是在最终的物理机器上执行的二进制代码。在 V8 中,字节码有两个作用:

  • 解释器可以直接解释执行字节码;

  • 优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码。

不过,在早期的 V8 设计中,并没有使用字节码,而是直接将 JavaScript 代码编译成机器代码。这是因为早期 V8 团队认为这种 “先生成字节码再执行字节码” 的方式,多了个中间环节,多出来的中间环节会牺牲代码的执行速度。而机器代码的执行性能非常高效。早期的 V8 执行流水线如下图所示:

那为什么现有的架构里又引入了字节码?

# 机器代码缓存

V8 使用两种代码缓存策略来缓存生成的代码:

  • V8 第一次执行一段代码时,会编译源 JavaScript 代码,并将编译后的二进制代码缓存在内存中,我们把这种方式称为内存缓存(in-memory cache)。然后通过 JavaScript 源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时,V8 就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码,那么就直接执行编译好的二进制代码。

  • V8 还会将代码缓存到硬盘上,这样即便关闭了浏览器,下次重新打开浏览器再次执行相同代码时,也可以直接重复使用编译好的二进制代码。

采用缓存是一种典型的以空间换时间的策略,以牺牲存储空间来换取执行速度。Chrome 的多进程架构本身就已经非常吃内存了,而 Chrome 中每个页面进程都运行了一份 V8 实例,V8 在执行 JavaScript 代码的过程中,会将 JavaScript 代码转换为未经优化的二进制代码,二进制代码占用的内存空间要比源代码多很多。

如果把这些二进制代码全部缓存起来,将会过度占用内存,从而导致应用性能大大降低。

惰性编译除了能提升 JavaScript 启动速度,还可以解决部分内存占用的问题。这是因为根据惰性编译的原则,早期的 Chrome 并没有缓存函数内部的二进制代码,只是缓存了顶层次的二进制代码。

但是这种方式却存在很大的不确定性,比如我们多人开发的项目,通常喜欢将自己的代码封装成模块,在 ES6 之前,由于没有块级作用域,我们习惯使用立即调用函数表达式 (IIFEs),比如下面这样的代码:

var test_module = (function () {
  var count_
  function init_(){count_ = 0}
  function add_(){count_ = count_+1}
  function show_(){console.log(count_)}
  return {
    init: init_,  
    add: add_, 
    show:show_
  }
})()
1
2
3
4
5
6
7
8
9
10
11
test_module.init()
test_module.add()
test_module.show()
test_module.add()
test_module.show()
1
2
3
4
5

如果浏览器只缓存顶层代码,那么闭包模块中的代码将无法被缓存,而对于高度工程化的模块来说,这种模块式的处理方式到处都是,这就导致了一些关键代码没有办法被缓存。

# 字节码降低了内存占用

从图中可以看出,字节码虽然占用的空间比原始的 JavaScript 多,但是相较于机器代码,字节码还是小了很多。

有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节来进行操作。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码。

虽然采用字节码在执行速度上稍慢于机器代码,但是整体上权衡利弊,采用字节码也许是最优解。因为采用字节码除了降低内存之外,还提升了代码的启动速度,并降低了代码的复杂度,而牺牲的仅仅是一点执行效率。

# 字节码提升了代码启动速度

生成字节码的时间要比生成机器代码的时间短,但是直接执行机器代码却比解释执行字节码要更高效,所以在快速启动 JavaScript 代码与花费更多时间获得最优运行性能的代码之间需要找到一个平衡点。

解释器可以快速生成字节码,但字节码通常效率不高。相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。

V8: Hooking up the Ignition to the Turbofan (opens new window)

# 字节码降低了代码复杂度

早期的 V8 代码,无论是基线编译器还是优化编译器,它们都是基于 AST 抽象语法树来将代码转换为机器码的,不同架构的机器码是不一样的,而市面上存在不同架构的处理器又是非常之多,这意味着基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码,这会大大增加代码量。

采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。这是因为字节码的执行过程和 CPU 执行二进制代码的过程类似,相似的执行流程,那么将字节码转换为不同架构的二进制代码的工作量也会大大降低,这就降低了转换底层代码的工作量。

# 如何生成字节码

当 V8 执行一段 JavaScript 代码时,会先对 JavaScript 代码进行解析 (Parser),并生成为 AST 和作用域信息,之后 AST 和作用域信息被输入到一个称为 Ignition 的解释器中,并将其转化为字节码,之后字节码再由 Ignition 解释器来解释执行。

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

上面这段代码在 d8 中使用 –-print-bytecode 命令可以看到总共生成了 8 行字节码,如下:

[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
Parameter count 3
Register count 2
Frame size 16
  0x79e0824ff7a @    0 : a7                StackCheck
  0x79e0824ff7b @    1 : 25 02             Ldar a1
  0x79e0824ff7d @    3 : 34 03 00          Add a0, [0]
  0x79e0824ff80 @    6 : 26 fb             Star r0
  0x79e0824ff82 @    8 : 0c 02             LdaSmi [2]
  0x79e0824ff84 @   10 : 26 fa             Star r1
  0x79e0824ff86 @   12 : 25 fb             Ldar r0
  0x79e0824ff88 @   14 : ab                Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# V8 解释器的架构

因为解释器就是模拟物理机器来执行字节码的,比如可以实现如取指令、解析指令、执行指令、存储数据等,所以解释器的执行架构和 CPU 处理机器代码的架构类似。

通常有两种类型的解释器,基于栈 (Stack-based)和基于寄存器 (Register-based)。

  • 基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等;

  • 基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。

通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系。

大多数解释器都是基于栈的,比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。

而现在的 V8 虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中。

基于寄存器的解释器架构如下图所示:

解释器执行时的流程跟 CPU 是类似的:

  • 使用内存中的一块区域来存放字节码;

  • 使用了通用寄存器 r0,r1,r2,... 这些寄存器用来存放一些中间数据;

  • PC 寄存器用来指向下一条要执行的字节码;

  • 栈顶寄存器用来指向当前的栈顶的位置。

这里需要特别注意的是累加器,它是一个非常特殊的寄存器,用来保存中间的结果。

# 解读字节码

function add(x, y) {
  var z = x + y
  return z
}
console.log(add(1, 2))
1
2
3
4
5
StackCheck  // 检查栈是否达到了溢出的上限,如果栈增长超过某个阈值,会中止该函数的执行并抛出一个 RangeError,表示栈已溢出
Ldar a1     // a1 是第二个寄存器,存放了第二个参数 2,将 a1 寄存器中的参数值 2 加载到累加器中,此时参数 2 就保存到累加器中了
Add a0, [0] // a0 是第一个寄存器,存放了第一个参数 1。从 a0 寄存器加载值 1 并将其与累加器中的值 2 相加,然后将结果 3 再次放入累加器
Star r0     // 将累加器中的数值 3 保存到 r0 寄存器中
LdaSmi [2]  // 将小整数(Smi)2 加载到累加器中,这一句没啥实际用途,但 V8 生成的字节码就是这样
Star r1     // 又将累加器中的 2 加载到寄存器 r1 中,这一句没啥实际用途,但 V8 生成的字节码就是这样
Ldar r0     // 将寄存器 r0 中的值加载到累加器中
Return      // Return 指令会中断当前函数的执行,并将累加器中的值作为返回值
1
2
3
4
5
6
7
8

补充

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

  • 小整数 Smi 主要是为了优化内存存储,其实很简单,就是使用更少的内存空间来存储数据,比如现在系统都是 64 位系统了,那么默认整数数据和指针都是 64 位的,V8 就会考虑将这部分内容压缩为 32 位,但是压缩到 32 位后就不知道这块内存是数据还是整数了,于是拿出了一个位来表示整数还是指针,这种技术也称指针压缩

# 隐藏类

由于在静态语言中,比如 C++,声明一个对象之前需要先定义该对象的结构,编译的时候,每个对象的形状都是固定的,因此可以直接通过偏移量查询来查询对象的属性值。而在动态语言中,比如 JavaScript,运行时对象的属性是可以被修改的,对象没有一个固定的形状,V8 就无法通过偏移量查询来查询对象的属性值。这就是静态语言比动态语言的执行效率更高的原因。

# 什么是隐藏类

在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。隐藏类的引入就是为了提升对象的属性访问速度,并且隐藏类借鉴了部分静态语言的特性。

V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:

  • 对象中所包含的所有的属性名称;

  • 每个属性相对于对象的偏移量。

有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象属性的效率。

# 多个对象共用一个隐藏类

如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样做有两个好处:

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

  • 减少了隐藏类的存储空间。

两个对象的形状相同要满足以下几点:

  • 相同的属性名称;

  • 相等的属性个数;

  • 相同的属性顺序。

# 避免重新构建隐藏类

1. 使用字面量初始化对象时,要保证属性的顺序是一致的。

比如先通过字面量 x、y 的顺序创建了一个 point 对象,然后通过字面量 y、x 的顺序创建一个对象 point2:

let point = {x:100,y:200};
let point2 = {y:100,x:200};
1
2

虽然创建时的对象属性一样,但是它们初始化的顺序不一样,这也会导致形状不同,所以它们会有不同的隐藏类,所以我们要尽量避免这种情况。

2. 尽量使用字面量一次性初始化完整对象属性。

因为每次为对象添加一个属性时,V8 都会为该对象重新设置隐藏类。

3. 尽量避免使用 delete 方法。

delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。

# 通过 d8 查看隐藏类

let point = {x:100,y:200};
%DebugPrint(point);
1
2
d8 --allow-natives-syntax test.js 
1
DebugPrint: 0x19dc080c5af5: [JS_OBJECT_TYPE]
 - map: 0x19dc08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
 - elements: 0x19dc080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x19dc080406e9 <FixedArray[0]> {
    #x: 100 (const data field 0)
    #y: 200 (const data field 1)
 }
0x19dc08284d11: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 20
 - inobject properties: 2
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x19dc08284ce9 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x19dc081c0451 <Cell value= 1>
 - instance descriptors (own) #2: 0x19dc080c5b25 <DescriptorArray[2]>
 - prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
 - constructor: 0x19dc0824116d <JSFunction Object (sfi = 0x19dc081c55ad)>
 - dependent code: 0x19dc080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

可以看到,point 对象的第一个属性就是 map,它指向了 0x19dc08284d11 这个地址,这个地址就是 V8 为 point 对象创建的隐藏类。

# 内联缓存

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)
}
1
2
3
4
5
6
7
8
9

在这段代码中,通常 V8 获取 o.x 的流程是这样的:查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值,在这段代码中 loadX 函数会被反复执行,那么获取 o.x 流程也需要反复被执行。

V8 也对这个反复执行的过程进行了优化,从而加速函数执行,这个优化的策略就是内联缓存 (Inline Cache),简称为 IC

# 什么是内联缓存

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

# 内联缓存的原理

IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据

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

function loadX(o) { 
 o.y = 4
 return o.x
}
1
2
3
4

当 V8 执行这段函数的时候,它会判断 o.y = 4 和 return o.x 这两段是调用点 (CallSite),因为它们使用了对象和属性,那么 V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽。

每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,比如上面这个函数中的两个调用点都使用了对象 o,那么反馈向量两个插槽中的 map 属性也都是指向同一个隐藏类的,因此这两个插槽的 map 地址是一样的

V8 会缓存三种类型的操作:加载 (LOAD) 类型、存储 (STORE) 类型、调用 (CALL) 类型

function foo(){}
function loadX(o) { 
  o.y = 4
  foo()
  return o.x
}
loadX({x:1, y:4})
1
2
3
4
5
6
7

对应的字节码如下:

StackCheck
LdaSmi [4]
StaNamedProperty a0, [0], [0]   // 存储类型
LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]  // 调用类型
LdaNamedProperty a0, [2], [6]   // 加载类型
Return
1
2
3
4
5
6
7
8

这段字节码对应的执行流程如下:

最终生成的反馈向量如下:

有了反馈向量缓存的数据之后,当 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 }
for (var i = 0; i < 90000; i++) {
  loadX(o)
  loadX(o1)
}
1
2
3
4
5
6
7
8
9

在这段代码中,对象 o 和 o1 的形状是不同的,这意味着 V8 为它们创建的隐藏类也是不同的。

第一次执行时 loadX 时,V8 会将 o 的隐藏类记录在反馈向量中,并记录属性 x 的偏移量。那么当再次调用 loadX 函数时,V8 会取出反馈向量中记录的隐藏类,并和新的 o1 的隐藏类进行比较,发现不是一个隐藏类,那么此时 V8 就无法使用反馈向量中记录的偏移量信息了。

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

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

因此,一个反馈向量的一个插槽中是可以包含多个隐藏类的信息的:

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic)

  • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic)

  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)

执行效率:单态 > 多态 > 超态,因此,在实际开发过程中,要避免多态和超态,尽量默认所有的对象属性是不变的,比如写了一个 loadX(o) 的函数,那么当传递参数时,尽量不要使用多个不同形状的 o 对象。

比如下面这两段代码中,第一段代码的执行效率更高,因为每一个 item 类型一样,后续几次调用 toString 可以直接命中,是单态。而第二段代码由于 item 类型间错不同,经常变换,就要同时缓存多个类型,是多态。

let data = [1, 2, 3, 4]
data.forEach((item) => console.log(item.toString())
1
2
let data = ['1', 2, '3', 4]
data.forEach((item) => console.log(item.toString())
1
2

# 消息队列

# 回调函数

回调函数区别于普通函数,在于它的调用方式。只有当某个函数被作为参数,传递给另外一个函数,或者传递给宿主环境,然后该函数在函数内部或者在宿主环境中被调用,我们才称为回调函数。

回调函数有两种不同的形式,同步回调异步回调

同步回调和异步回调的最大区别在于同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部和其他时间点被执行的

// 同步回调
var myArray = ["water", "goods", "123", "like"];
function handlerArray(indexName,index){
  console.log(index + 1 + ". " + indexName); 
}
myArray.forEach(handlerArray)
1
2
3
4
5
6
// 异步回调
function foo() {
  alert("Hello");
}
setTimeout(foo, 3000)
1
2
3
4
5

# 主线程架构

早期浏览器的页面是运行在一个单独的主线程中的,所以要在页面中引入 JavaScript,那么 JavaScript 也必须要运行在和页面相同的线程上,这样才能方便使用 JavaScript 来操纵 DOM,所以从一开始,JavaScript 就被设计成了运行在主线程中

所谓主线程,是指运行窗口的线程,当运行一个窗口时,无论该页面是 Windows 上的窗口系统,还是 Android 或者 iOS 上的窗口系统,它们都需要处理各种事件,诸如有触发绘制页面的事件,有鼠标点击、拖拽、放大缩小的事件,有资源下载、文件读写的事件等等

在页面线程中,当一个事件被触发时,比如用户使用鼠标点击了页面,系统需要将该事件提交给主线程来处理。在大部分情况下,主线程并不能立即响应和处理这些事件,比如在移动鼠标的过程中,每移动一个像素都会产生一个事件,所以鼠标移动的事件会频繁地被触发。在这种情况下,页面线程可能正在处理前一个事件,那么最新的事件就无法被立即执行。

针对这种情况,我们为主线程提供一个消息队列,并将这些待执行的事件添加到消息队列中,然后主线程会不断循环地从消息队列中取出事件、执行事件。我们把主线程每次从消息队列中取出事件,执行事件的过程称为一个任务。整体流程如下图:

# 如何处理 setTimeout

# 如何处理 XMLHttpRequest

因为 XMLHttpRequest 是用来下载网络资源的,但是实际的下载过程却并不适合在主线程上执行,因为下载任务会消耗比较久的时间,如果在主线程上执行,那么会阻塞主线程,这就会拖慢 UI 界面的交互和绘制的效果。所以当主线程从消息队列中取出来了这类下载任务之后,会将其分配给网络线程,让其在网络线程上执行下载过程,这样就不会影响到主线程的执行了。

整个流程可以分为以下几步:

  1. 主线程会从消息队列中取出一个任务,并分析该任务。

  2. 分析过程中发现该任务是一个下载请求,那么主线程就会将该任务交给网络线程去执行。

  3. 网络线程接到请求之后,便会和服务器端建立连接,并发出下载请求。

  4. 网络线程不断地收到服务器端传过来的数据。

  5. 网络线程每次接收到数据时,都会将设置的回调函数和返回的数据信息,如大小、返回了多少字节、返回的数据在内存中存放的位置等信息封装成一个新的事件,并将该事件放到消息队列中。

6.主线程继续循环地读取消息队列中的事件,如果是下载状态的事件,那么主线程会执行回调函数,程序员便可以在回调函数内部编写更新下载进度的状态的代码。

  1. 直到最后接收到下载结束事件,主线程会显示该页面下载完成。

除了下载以外,JavaScript 中获取系统设备信息、文件读取等都是采用了类似的方式来实现的,因此,理解了 XMLHttpRequest 的执行流程,也就理解了这一类异步 API 的执行流程了。

比如下面这两段 Node 中读取文件的代码:

var fs = require('fs')
var data = fs.readFileSync('test.js')
1
2
fs.readFile('test.txt', function(err, data){
  data.toString()  
})
1
2
3

readFileSync 函数执行时会等待文件读取完毕,再执行下一条语句,在该语句后可正常访问其执行结果,获取 data;

readFile 函数执行时不会等待文件读取完毕就会执行下一条语句,如果直接在其后而不是回调函数中操作其执行结果 data 时,程序会出现报错。

本质上 readFileSync 是在主线程上执行的,readFile 在读写线程中执行的

# 异步编程

# V8 如何实现微任务

宏任务就是指消息队列中的等待被主线程执行的事件。每个宏任务在执行时,V8 都会重新创建栈,然后随着宏任务中函数调用,栈也随之变化,最终,当该宏任务执行结束时,整个栈又会被清空,接着主线程继续执行下一个宏任务。

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

JavaScript 中之所以要引入微任务,主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做一个有效的权衡。此外,使用微任务可以改变我们现在的异步编程模型,使得我们可以使用同步形式的代码来编写异步调用。

对于栈溢出问题,虽然我们可以通过将某些函数封装成宏任务的方式来解决,但是宏任务需要先被放到消息队列中,如果某些宏任务的执行时间过久,那么就会影响到消息队列后面的宏任务的执行,而且这个影响是不可控的,因为我们无法知道前面的宏任务需要多久才能执行完成。

微任务解决了宏任务执行时机不可控的问题,V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 JavaScript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。当我们通过 Promise.resolve 生成一个微任务时,该微任务会被 V8 自动添加进微任务队列,等整段代码快要执行结束时,该环境对象也随之被销毁,但是在销毁之前,V8 会先处理微任务队列中的微任务。

关于微任务的执行时机只需要记住以下两点:

  • 首先,如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张

  • 其次,和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的

# V8 如何实现 Promise

V8 Promise 源码全面解读 (opens new window)

# V8 如何实现 async/await

async/await 原理解析

# co 的实现原理

学习 koa 源码的整体架构,浅析 koa 洋葱模型原理和 co 原理 (opens new window)

# V8 的垃圾回收机制

# 垃圾数据的产生

window.test = new Object()
window.test.a = new Uint16Array(100)
1
2

这段代码的内存布局如下:

此时,如果再执行下面这句代码:

window.test.a = new Object()
1

那么内存布局会变成:

可以看到,此时堆中的数组对象就成为了垃圾数据,因为它变得不可达。

# 垃圾回收流程

1. 通过 GC Root 标记空间中的活动对象和非活动对象

目前 V8 采用了可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,这个算法是将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:

  • 通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;

  • 通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。

在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);

  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;

  • 存放栈上变量。

2. 回收非活动对象所占据的内存

3. 做内存整理

一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片。

# 代际假说

代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:

  • 大部分对象都是 “朝生夕死” 的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;

  • 有些对象是不死的,会活得更久,比如全局的 window、DOM、Web API 等对象

这两个特点不仅仅适用于 JavaScript,同样适用于大多数的编程语言,如 Java、Python 等。V8 的垃圾回收策略,就是建立在该假说的基础之上的。

# V8 如何实现垃圾回收

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

新生代通常只支持 1~8M 的容量,而老生代支持的容量就大很多了。

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

  • 副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。

  • 主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。

# 副垃圾回收器和新生代

通常情况下,大多数小的对象都会被分配到新生代,虽然这个区域不大,但是垃圾回收还是比较频繁的。

新生代的内存大小可以使用 --max_new_space_size + 数字来设置,单位是 KB。

新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space),如下图所示:

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

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

不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中

# 主垃圾回收器和老生代

除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:

  • 对象占用空间大;

  • 对象存活时间长。

老生代的内存大小可以使用 --max_old_space_size + 数字来设置,单位是 MB。

主垃圾回收器是采用标记 - 清除(Mark-Sweep)算法进行垃圾回收的。

标记清除算法的工作过程如下:

  1. 首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

  2. 接下来是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉

对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理(Mark-Compact)算法

这个算法的工作过程是:先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存

# V8 如何优化垃圾回收器的执行效率

由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

为了解决全停顿而造成的用户体验的问题,V8 团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,这些技术主要是从两方面来解决垃圾回收效率问题的:

  • 将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;

  • 将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

# 并行回收

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作,其工作模式如下图所示:

采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。

这种方式比较简单,因为在执行垃圾标记的过程中,主线程并不会同时执行 JavaScript 代码,因此 JavaScript 代码也不会改变回收的过程。所以我们可以假定内存状态是静态的,因此只要确保同时只有一个协助线程在访问对象就好了。

# 副垃圾回收器采用并行回收

V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

# 增量回收

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。

因此,后来 V8 又引入了增量标记的方式,我们把这种垃圾回收的方式称之为增量式垃圾回收

所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。

采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作,具体流程可以看下图:

增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的,要实现增量执行,需要满足两点要求:

  • 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。

  • 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。

# V8 如何实现垃圾回收器的暂停和恢复执行

在没有采用增量算法之前,V8 使用黑色和白色来标记数据。

在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据。如下图所示:

如果内存中的数据只有两种状态,非黑即白,那么当暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。

比如垃圾回收器执行了一小段增量回收后,被 V8 暂停了,然后主线程执行了一段 JavaScript 代码,然后垃圾回收器又被恢复了,那么恢复时内存状态就如下图所示:

当垃圾回收器再次被启动的时候,它到底是从 A 节点开始标记,还是从 B 节点开始执行标注过程呢?因为没有其他额外的信息,所以垃圾回收器也不知道该如何处理了。

# 三色标记法

为了解决这个问题,V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:

  • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了;

  • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;

  • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。

引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成。如果没有灰色节点了,就可以进行清理工作了;如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。

因此采用三色标记,可以很好地支持增量式垃圾回收。

# V8 如何处理标记好的数据被 JS 修改了

window.a = Object()
window.a.b = Object()
window.a.b.c=Object()
1
2
3

执行到这段代码时,垃圾回收器标记的结果如下图所示:

此时再执行下面这句代码:

window.a.b = Object() //d
1

执行完之后,垃圾回收器又恢复执行了增量标记过程,由于 b 重新指向了 d 对象,所以 b 和 c 对象的连接就断开了。这时候垃圾回收器标记的结果如下图所示:

这说明存在一个问题,当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了。但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色。

为了解决这个问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点

# 写屏障机制(强三色不变性)

V8 使用写屏障 (Write-barrier) 机制实现这个约束条件,即:

当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。

这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。

因此,每次执行如 window.a.b = value 的写操作之后,V8 会插入写屏障代码,强制将 value 这块内存标记为灰色。

# 并发回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加主线程处理任务的吞吐量 (throughput)。

结合并行回收可以将一些任务分配给辅助线程,但是并行回收依然会阻塞主线程,那么,有没有办法在不阻塞主线程的情况下,执行垃圾回收操作呢?这个方法就是并发回收机制。

所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。并发标记的流程大致如下图所示:

并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行,在执行的同时,辅助线程可以执行垃圾回收操作。

但是并发回收却是这三种技术中最难的一种,主要有以下两个原因:

  • 当主线程执行 JavaScript 时,堆中的内容随时都有可能发生变化,从而使得辅助线程之前做的工作完全无效;

  • 主线程和辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁的一些功能了。

尽管并行回收要额外解决以上两个问题,但是权衡利弊,并行回收这种方式的效率还是远高于其他方式的。

# 主垃圾回收器融合三种机制

不过,这三种技术在实际使用中,并不是单独的存在,通常会将其融合在一起使用,V8 的主垃圾回收器就融合了这三种机制,来实现垃圾回收,如下图:

  • 首先主垃圾回收器主要使用并发标记,可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。

  • 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。

  • 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。

# 几种内存问题

  • 内存泄漏 (Memory leak),它会导致页面的性能越来越差;

  • 内存膨胀 (Memory bloat),它会导致页面的性能会一直很差;

  • 频繁垃圾回收,它会导致页面出现延迟或者经常暂停。

# 内存泄漏

关于常见的内存泄漏问题和解决方法可以看这里 (opens new window)

# 内存膨胀

内存膨胀和内存泄漏有一些差异,内存膨胀主要表现在程序员对内存管理的不科学,比如只需要 50M 内存就可以搞定的,有些程序员却花费了 500M 内存。

额外使用过多的内存有可能是没有充分地利用好缓存,也有可能加载了一些不必要的资源。通常表现为内存在某一段时间内快速增长,然后达到一个平稳的峰值继续运行。

比如一次性加载了大量的资源,内存会快速达到一个峰值。内存膨胀和内存泄漏的关系可以看下图:

可以看到,内存膨胀是快速增长,然后达到一个平衡的位置,而内存泄漏是内存一直在缓慢增长

要避免内存膨胀,我们需要合理规划项目,充分利用缓存等技术来减轻项目中不必要的内存占用。

# 频繁的垃圾回收

除了内存泄漏和内存膨胀,还有另外一类内存问题,那就是频繁使用大的临时变量,导致了新生代空间很快被装满,从而频繁触发垃圾回收。频繁的垃圾回收操作会让用户感觉到页面卡顿。

function strToArray(str) {
  let i = 0
  const len = str.length
  let arr = new Uint16Array(str.length)
  for (; 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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这段代码就会频繁创建临时变量,这种方式很快就会造成新生代内存内装满,从而频繁触发垃圾回收。为了解决频繁的垃圾回收的问题,我们可以考虑将这些临时变量设置为全局变量。

function strToArray(str, arr) {
  let i = 0
  const len = str.length
  for (; i < len; ++i) {
    arr[i] = str.charCodeAt(i)
  }
  return arr;
}

function foo() {
  let i = 0
  let str = 'test V8 GC'
  let arr = new Uint16Array(str.length)
  while (i++ < 1e5) {
    strToArray(str, arr);
  }
}

foo()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
上次更新时间: 2024年01月15日 22:42:06