V8 引擎机制

V8 引擎机制

最近在梳理 JavaScript 相关知识,发现 V8 引擎机制相关内容还是较多的而且在之前工作的过程当中也是涉及比较少的,所以就抽些时间出来,在这里大致的整理一下,主要参考的是 V8 执行流程概述 这篇文章,内容有所调整,主要是为了方便自己理解,如果想了解更为详细的流程可以参考原文

在开始之前,我们先来简单的了解一下 V8 解析 JavaScript 的过程分为哪些步骤,这样我们可以在全局上对 V8 的整个执行流程有一个比较清晰的认识,简单来说,有以下几个步骤

  • 预解析,检查语法错误但不生成 AST
  • 生成 AST,经过词法/语法分析,生成抽象语法树
  • 生成字节码,基线编译器(Ignition)将 AST 转换成字节码
  • 生成机器码,优化编译器(Turbofan)将字节码转换成优化过的机器码,此外在逐行执行字节码的过程中,如果一段代码经常被执行,那么 V8 会将这段代码直接转换成机器码保存起来,下一次执行就不必经过字节码,优化了执行速度

下面我们就从头开始,也就是什么是 V8 引擎开始看起

V8 引擎

看到 V8 这个词,我们可能会联想到发动机,因为 V8V10V12 发动机这种概念可能都有所耳闻,的确,V8 的名字正是来源于汽车的 V8 缸发动机,因为马力十足而广为人知,V8 引擎的命名是 Google 向用户展示它是一款强力并且高速的 JavaScript 引擎

V8 未诞生之前,早期主流的 JavaScript 引擎是 JavaScriptCore 引擎(Safari),JavaScriptCore 是主要服务于 Webkit 浏览器内核,它们都是由苹果公司开发并开源出来,据说 Google 是不满意 JavaScriptCoreWebkit 的开发速度和运行速度,Google 另起炉灶开发全新的 JavaScript 引擎和浏览器内核引擎,所以诞生了 V8Chromium 两大引擎,到现在已经是最受欢迎的浏览器相关软件,当然发展至今,V8 不在局限于浏览器内核引擎,也应用于很多场景,例如流行的 Node.jsWeex 等,在 V8 当中,有以下几个比较重要的部件

  • Ignition(基线编译器)
  • TurboFan(优化编译器)
  • Orinoco(垃圾回收器)
  • LiftoffWebAssembly 基线编译器)

这里有个需要注意的地方,Liftoff 是从 V8 6.8 开始启用的针对 WebAssembly 的基线编译器,但是 WebAssembly 相关内容不在本文范围内,所以我们这里也就不做介绍了,相关内容可以参考 V8 引擎中全新的 WebAssembly 这篇文章

早期架构

V8 引擎的诞生带着使命而来,就是要在速度和内存回收上进行革命的,JavaScriptCore 的架构是采用生成字节码的方式,然后执行字节码,Google 觉得 JavaScriptCore 这套架构不行,生成字节码会浪费时间,不如直接生成机器码快,所以 V8 在前期的架构设计上是非常激进的,采用了直接编译成机器码的方式,后期的实践证明 Google 的这套架构速度是有改善,但是同时也造成了内存消耗问题,下面是 V8 的初期流程图

早期 V8 执行管道由基线编译器 Full-Codegen 与优化编译器 Crankshaft 组成,V8 首先用 Full-Codegen 把所有的代码都编译一次,生成对应的机器码,JavaScript 在执行的过程中,V8 内置的 Profiler 筛选出热点函数并且记录参数的反馈类型,然后交给 Crankshaft 来进行优化,所以 Full-Codegen 本质上是生成的是未优化的机器码,而 Crankshaft 生成的是优化过的机器码

缺陷

但是随着版本的引进,网页的复杂化,V8 也渐渐的暴露出了自己架构上的缺陷,比如下面这些

  • Full-Codegen,编译直接生成机器码,导致内存占用大,编译时间长,启动速度慢等
  • Crankshaft,无法优化 try-catch/finally 等关键字划分的代码块,如果新加语法支持,需要为此编写适配不同 CPU 的架构代码

新的架构

为了解决上述缺点,经过多年演进 V8 目前形成了由解析器、基线编译器(Ignition)和优化编译器(TurboFan)组成的 JavaScript 执行管道,也就是下图这样

解析器将 JavaScript 源代码转换成 AST,基线编译器(Ignition)将 AST 编译为字节码,当代码满足一定条件时,将被优化编译器重新编译生成优化的字节码

IgnitionV8 的解释器,背后的原始动机是减少移动设备上的内存消耗,Ignition 的字节码可以直接用 TurboFan 生成优化的机器代码,而不必像 Crankshaft 那样从源代码重新编译,Ignition 的字节码在 V8 中提供了更清晰且更不容易出错的基线执行模型,简化了去优化机制,这是 V8 自适应优化的关键特性,最后由于生成字节码比生成 Full-codegen 的基线编译代码更快,因此激活 Ignition 通常会改善脚本启动时间,从而改善网页加载

TurboFanV8 的优化编译器,TurboFan 项目最初于 2013 年底启动,旨在解决 Crankshaft 的缺点,Crankshaft 只能优化 JavaScript 语言的子集,例如它不是设计用于使用结构化异常处理优化 JavaScript 代码,即由 JavaScripttry-catchfinally 关键字划分的代码块,很难在 Crankshaft 中添加对新语言功能的支持,因为这些功能几乎总是需要为九个支持的平台编写特定于体系结构的代码

在采用新架构后,不同架构下 V8 的内存对比,如下图

可以明显看出 Ignition + TurboFan 架构比 Full-codegen + Crankshaft 架构内存降低一半多,我们可以再来看看网页速度提升对比

可以明显看出 Ignition + TurboFan 架构比 Full-codegen + Crankshaft 架构 70% 网页速度是有提升的

解析器与 AST

学过编译原理的同学可能知道,JavaScript 文件只是一个源码,机器是无法执行的,词法分析就是把源码的字符串分割出来,生成一系列的 Token,如下图可知不同的字符串对应不同的 Token 类型

词法分析完后,接下来的阶段就是进行语法分析,语法分析语法分析的输入就是词法分析的输出,输出是 AST 抽象语法树,当程序出现语法错误的时候,V8 在语法分析阶段抛出异常

但是解析代码需要时间,所以 JavaScript 引擎会尽可能避免完全解析源代码文件,而另一方面又因为在一次用户访问中,页面中会有很多代码其实是不会被执行到的,比如一些通过用户交互行为触发的动作,正因为如此,所有主流浏览器都实现了惰性解析(Lazy Parsing),解析器不必为每个函数生成 AST,而是可以决定预解析(pre-parsing)或完全解析它所遇到的函数,预解析会检查源代码的语法并抛出语法错误,但不会解析函数中变量的作用域或生成 AST,完全解析则将分析函数体并生成源代码对应的 AST 数据结构,相比正常解析,预解析的速度快了两倍

生成 AST

生成 AST 主要经过两个阶段,分词和语义分析,AST 旨在通过一种结构化的树形数据结构来描述源代码的具体语法组成,常用于语法检查(静态代码分析)、代码混淆、代码优化等,我们可以借助 AST Explorer 工具来生成 JavaScript 代码的 AST,比如我们的函数为

1
2
3
function add(x, y) {
return x + y;
}

编译后 JSON 大概是下面这样的

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
{
"type": "Program",
"start": 0,
"end": 38,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 38,
"id": {
"type": "Identifier",
"start": 9,
"end": 12,
"name": "add"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 13,
"end": 14,
"name": "x"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"name": "y"
}
],
"body": {
"type": "BlockStatement",
"start": 19,
"end": 38,
"body": [
{
"type": "ReturnStatement",
"start": 23,
"end": 36,
"argument": {
"type": "BinaryExpression",
"start": 30,
"end": 35,
"left": {
"type": "Identifier",
"start": 30,
"end": 31,
"name": "x"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 34,
"end": 35,
"name": "y"
}
}
}
]
}
}
],
"sourceType": "module"
}

也就类似于下图这样

但是这里需要注意的是,上图仅描述 AST 的大致结构,V8 有一套自己的 AST 表示方式,生成的 AST 结构有所差异,详细可见 ast.h

基线编译器 Ignition

接下来就是根据抽象语法树生成字节码,V8 引入 JITJust In Time,即时编译)技术,通过 Ignition 基线编译器快速生成字节码进行执行,如下图可以看出 add 函数生成对应的字节码

BytecodeGenerator 类的作用是根据抽象语法树生成对应的字节码,不同的节点会对应一个字节码生成函数

优化编译器 TurboFan

编译器需要考虑的函数输入类型变化越少,生成的代码就越小、越快,众所周知,JavaScript 是弱类型语言,ECMAScript 标准中有大量的多义性和类型判断,因此通过基线编译器生成的代码执行效率低下

Turbofan 是根据字节码和热点函数反馈类型生成优化后的机器码,Turbofan 很多优化过程,基本和编译原理的后端优化差不多,采用的 sea-of-node

比如我们针对之前提到的 add 函数优化

1
2
3
4
5
6
7
function add(x, y) {
return x + y;
}

add(1, 2);

%OptimizeFunctionOnNextCall(add);

V8 是有函数可以直接调用指定优化哪个函数,执行 %OptimizeFunctionOnNextCall 主动调用 Turbofan 优化 add 函数,根据上次调用的参数反馈优化 add 函数,很明显这次的反馈是整型数,所以 turbofan 会根据参数是整型数进行优化直接生成机器码,下次函数调用直接调用优化好的机器码

注意执行 V8 需要加上 --allow-natives-syntaxOptimizeFunctionOnNextCall 为内置函数,只有加上 --allow-natives-syntaxJavaScript 才能调用内置函数,否则执行会报错

JavaScriptadd 函数生成对应的机器码如下

如果把 add 函数的传入参数改成字符

1
2
3
4
5
6
7
function add(x, y) {
return x+y;
}

add('1', '2');

%OptimizeFunctionOnNextCall(add);

优化后的add函数生成对应的机器码如下

对比上面两图可以发现,add 函数传入不同的参数,经过优化生成不同的机器码

  • 如果传入的是整型,则本质上是直接调用 add 汇编指令
  • 如果传入的是字符串,则本质上是调用 V8 的内置 Add 函数

至此,整个 V8 的执行流程就算是结束了,这里我们关于 V8 的相关内容就介绍到这里,其实主要目的也只是简单的了解其运行原理,想要深入了解可以另外查询资料深入学习

下面我们再来看看 JavaScript 当中的内存管理和垃圾回收机制

JavaScript 中的内存管理

CC++ 这样的底层语言当中,我们如果想要开辟一块堆内存的话,需要先计算需要内存的大小,然后自己通过 malloc() 函数去手动分配,在用完之后,还要时刻记得用 free() 函数去清理释放,否则这块内存就会被永久占用,造成内存泄露

而对于 JavaScript 来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时『自动』释放内存,而这个自动释放内存的过程就被称为『垃圾回收机制』,正因为自动垃圾回收机制的存在,虽然不需要我们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,造成循环引用等情况,导致内存泄露

下面我们就先从内存的生命周期开始看起,这有助于我们更好的理解下面将要介绍到的垃圾回收机制,通常而言,JavaScript 环境中分配的内存有如下生命周期,也就是我们熟知的三个步骤

  • 内存分配,当我们申明变量、函数、对象的时候,系统会自动为它们分配内存
  • 内存使用,即读写内存,也就是使用变量、函数等
  • 内存回收,使用完毕,由垃圾回收机制自动回收不再使用的内存

内存分配

在上面我们也有提到过,JavaScript 在定义变量时就已经完成了内存分配,比如下面这几个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var n = 123                // 给数值变量分配内存
var s = 'abc' // 给字符串分配内存

var o = { // 给对象及其包含的值分配内存
a: 1,
b: null
}

var a = [1, null, 'abc'] // 给数组及其包含的值分配内存(就像对象一样)

function f(a) { // 给函数(可调用的对象)分配内存
return a + 2
}

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function () {
someElement.style.backgroundColor = 'blue'
}, false)

有些函数调用结果是分配对象内存

1
2
3
var d = new Date()                     // 分配一个 Date 对象

var e = document.createElement('div') // 分配一个 DOM 元素

有些方法分配新变量或者新对象

1
2
3
4
5
6
7
8
9
10
// 因为字符串是不变量,JavaScript 可能决定不分配内存,所以只是存储了 [0 - 3] 的范围
var s = 'abc'

// s2 是一个新的字符串
var s2 = s.substr(0, 3)

// 新数组 a3 有四个元素,是 a1 连接 a2 的结果
var a1 = ['a', 'b']
var a2 = ['c', 'd']
var a3 = a.concat(a2)

内存使用

使用值的过程实际上是对分配内存进行读取与写入的操作,读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数

1
2
3
var a = 10      // 分配内存

console.log(a) // 对内存的使用

内存回收

JavaScript 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢?其实简单来说,就是找出那些不再继续使用的值,然后释放其占用的内存,并且大多数内存管理的问题都在这个阶段

但是在这里最艰难的任务就是找到不再需要使用的变量,不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在,当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收,全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收

因为自动垃圾回收机制的存在,所以通常我们可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的,不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况

虽然无法回收所有的极端情况,但是这里也会存在着一个小问题,那就是我们该如何判断此时是否可以进行回收了呢?这也就是我们下面将要介绍的 JavaScript 当中的垃圾回收机制

垃圾回收机制

垃圾回收算法主要依赖于『引用』的概念,在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象,例如一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用),对象的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域),下面我们就先从『引用计数』开始看起

另外这里需要注意的一点就是,我们所说的垃圾回收机制,其实主要指的是『堆内存』到底是如何进行垃圾回收并进行优化的

引用计数

引用计数的垃圾收集策略不太常见,含义是跟踪记录每个值被引用的次数,当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1,如果同一个值又被赋给另一个变量,则该值的引用次数加 1,相反如果包含对这个值引用的变量改变了引用对象,则该值引用次数减 1

当这个值的引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来,这样当垃圾收集器下次再运行时,它就会释放那些引用次数为 0 的值所占用的内存

这也是最初级的垃圾收集算法,此算法把『对象是否不再需要』简化定义为『对象有没有其他对象引用到它』,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,先来看下面几个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var o = {       // 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量 o(我们这里称为原始对象)
a: { // 很显然,没有一个可以被垃圾收集
b: 2
}
}

var o2 = o // o2 变量是第二个对原始对象的引用
o = 1 // 现在,原始对象的原始引用o被o2替换了

var oa = o2.a // 引用原始对象的 a 属性,现在原始对象有两个引用了,一个是 o2,一个是 oa

o2 = 'yo' // 最初的对象现在已经是零引用了,显然可以被垃圾回收了
// 然而它的属性 a 的对象还在被 oa 引用,所以还不能回收

oa = null // 我们将 oa 置为 null,现在它就可以被回收了

由上面可以看出,引用计数算法虽然是个简单有效的算法,但是它却存在着一个致命的问题,那就是『循环引用』,如果两个对象相互引用,尽管它们已不再使用,垃圾回收不会进行回收,导致内存泄露,比如下面这个循环引用的例子

1
2
3
4
5
6
7
8
9
function f() {
var o = {}
var o2 = {}
o.a = o2 // o 引用 o2
o2.a = o // o2 引用 o
return 'abc'
}

f()

上面我们申明了一个函数 f,其中包含两个相互引用的对象,在调用函数结束后,对象 o1o2 实际上已离开函数范围,因此不再需要了,也就是说可以被回收了,然而根据引用计数的原则考虑到它们互相都有至少一次引用,因此这部分内存不会被回收,这样一来内存泄露就不可避免了

下面我们再来看一个平常经常会遇到的示例,其实我们大部分人时刻都在写着循环引用的代码,比如下面这个例子,相信大家都这样写过

1
2
3
4
5
var el = document.getElementById('#el')

el.onclick = function (event) {
console.log(`clicked`)
}

我们为一个元素的点击事件绑定了一个匿名函数,我们通过 event 参数是可以拿到相应元素 el 的信息的,但是我们仔细想想,这是不是就是一个循环引用呢?

el 有一个属性 onclick 引用了一个函数(其实也是个对象),函数里面的参数又引用了 el,这样 el 的引用次数一直是 2,即使当前这个页面关闭了,也无法进行垃圾回收,如果这样的写法很多很多,就会造成内存泄露,所以我们一般可以通过在页面卸载时清除事件引用,这样就可以被回收了

1
2
3
4
// 页面卸载时将绑定的事件清空
window.onbeforeunload = function () {
el.onclick = null
}

下面我们再来看另外一种垃圾回收机制,也是现代浏览器使用较多的『标记清除』

标记清除

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为进入环境,从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们,而当变量离开环境时,则将其标记为离开环境

标记清除算法将『不再使用的对象』定义为『无法达到的对象』,简单来说就是从根部(在 JavaScript 中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,都是还需要使用的,那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象),但反之未必成立,此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep

  • 标记阶段,垃圾回收器会从根对象开始遍历,每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象
  • 清除阶段,垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作

它的流程是下面这样的

  • 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记
  • 从根部出发将能触及到的对象的标记清除
  • 那些还存在标记的变量被视为准备删除的变量
  • 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间

如果放到具体阶段,那么顺序是是下图这样

  • 在标记阶段,从根对象 1 可以访问到 B,从 B 又可以访问到 E,那么 BE 都是可到达对象,同样的道理 FGJK 都是可到达对象
  • 在回收阶段,所有未标记为可到达的对象都会被垃圾回收器回收

这样一来,循环引用不再是问题了,我们再来看之前循环引用的例子

1
2
3
4
5
6
7
8
9
function f() {
var o = {}
var o2 = {}
o.a = o2 // o 引用 o2
o2.a = o // o2 引用 o
return 'abc'
}

f()

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取它们的引用,因此它们将会被垃圾回收器回收,正因为如此,从 2012 年起,所有现代浏览器都使用了『标记清除』垃圾回收算法,所有对 JavaScript 垃圾回收算法的改进都是基于『标记清除』算法的改进,并没有改进算法本身和它对『对象是否不再需要』的简化定义

另外一个需要我们注意的地方就是『何时开始垃圾回收』,通常来说在使用标记清除算法时,未引用对象并不会被立即回收,取而代之的做法是,垃圾对象将一直累计到内存耗尽为止,当内存耗尽时,程序将会被挂起,垃圾回收开始执行

虽然我们在上面列举了『标记清除』的许多优点,但是同样的『标记清除』算法也是存在一些缺陷的

  • 那些无法从根对象查询到的对象都将被清除
  • 垃圾收集后有可能会造成大量的内存碎片,像上面的图片所示,垃圾收集后内存中存在三个内存碎片,假设一个方格代表 1 个单位的内存,如果有一个对象需要占用 3 个内存单位的话,那么就会导致 Mutator 一直处于暂停状态,而 Collector 一直在尝试进行垃圾收集,直到 Out of Memory

V8 垃圾回收策略

自动垃圾回收有很多算法,由于不同对象的生存周期不同,所以无法只用一种回收策略来解决问题,这样效率会很低,所以 V8 采用了一种代回收的策略,将内存分为两个生代,新生代(new generation)和老生代(old generation

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象,分别对新老生代采用不同的垃圾回收算法来提高效率,对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫『晋升』

而这种垃圾回收的方式我们称之为『分代回收』(Generation GC),本质上和 Java 回收策略思想是一致的,目的是通过区分『临时』与『持久』对象(也就是我们经常听闻的『新生代』与『老生代』对象)

  • 多回收『临时对象区』(young generation
  • 少回收『持久对象区』(tenured generation

减少每次需遍历的对象,从而减少每次 GC 的耗时,Chrome 浏览器所使用的 V8 引擎就是采用的分代回收策略,如下图所示

但是在展开回收策略之前,我们需要先来了解一下 V8 当中的内存限制

V8 内存限制

Node.js 中,JavaScript 能使用的内存是有限制的,通常来说

  • 64 位系统下约为 1.4GB
  • 32 位系统下约为 0.7GB

对应到分代内存中,默认情况下

  • 32 位系统新生代内存大小为 16MB,老生代内存大小为 700MB
  • 64 位系统新生代内存大小为 32MB,老生代内存大小为 1.4GB

这个限制在 Node.js 启动的时候可以通过传递 --max-old-space-size--max-new-space-size 来调整

1
2
node --max-old-space-size=1700 app.js  // 单位为 MB
node --max-new-space-size=1024 app.js // 单位为 MB

上述参数在 V8 初始化时生效,一旦生效就不能再动态改变,但是这里我们可能会有一个疑问,那就是 V8 为什么会有内存限制呢?主要原因有以下几点

  • 表面上的原因是 V8 最初是作为浏览器的 JavaScript 引擎而设计,不太可能遇到大量内存的场景
  • 而深层次的原因则是由于 V8 的垃圾回收机制的限制,由于 V8 需要保证 JavaScript 应用逻辑与垃圾回收器所看到的不一样,V8 在执行垃圾回收时会阻塞 JavaScript 应用逻辑,直到垃圾回收结束再重新执行 JavaScript 应用逻辑,这种行为被称为『全停顿』(stop-the-world
  • V8 的堆内存为 1.5GBV8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式的垃圾回收甚至要 1s 以上,这样浏览器将在 1s 内失去对用户的响应,造成假死现象,如果有动画效果的话,动画的展现也将显著受到影响

在简单了解完 V8 内存限制相关内容以后,下面我们就来正式的看看 V8 垃圾回收策略相关内容,先从新生代算法开始看起

新生代算法(Scavenge)

新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收,而新生代中的对象主要通过 Scavenge 算法进行垃圾回收,在 Scavenge 的具体实现中,主要采用 Cheney 算法,Cheney 算法将内存平均分成两块相等的内存空间,叫做 semispace,每块内存大小 8MB32 位)或 16MB64 位),一块处于使用状态,一块处于闲置状态

它的主要流程是下面这样的

  • Cheney 算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,这两个空间中只有一个处于使用中,一个处于闲置状态
  • 处于使用状态的空间称为 From 空间,处于闲置的空间称为 To 空间
  • 分配对象时,先是在 From 空间中进行分配,当开始垃圾回收时,会检查 From 空间中的存活对象,并将这些存活对象复制到 To 空间中,而非存活对象占用的空间被释放
  • 完成复制后,From 空间和 To 空间的角色互换
  • 简而言之,垃圾回收过程中,就是通过将存活对象在两个空间中进行复制

但是 Scavenge 算法也是存在一定缺点的,那就是只能使用堆内存中的一半,但由于它只复制存活的对象,对于生命周期短的场景存活对象只占少部分,所以在时间效率上有着优异的表现

晋升

以上我们所说的新生代算法是在纯 Scavenge 算法中,但是在分代式垃圾回收的前提下,From 空间中存活的对象在复制到 To 空间之前需要进行检查,在一定条件下,需要将存活周期较长的对象移动到老生代中,这个过程称为对象『晋升』,对象晋升的条件主要有两个

第一点,对象从 From 空间复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收,如果已经经历过了,会将该对象从 From 空间移动到老生代空间中,如果没有,则复制到 To 空间,总结来说,如果一个对象是第二次经历从 From 空间复制到 To 空间,那么这个对象会被移动到老生代中

第二点,当要从 From 空间复制一个对象到 To 空间时,如果 To 空间已经使用了超过 25%,则这个对象直接晋升到老生代中,设置 25% 这个阈值的原因是当这次 Scavenge 回收完成后,这个 To 空间会变为 From 空间,接下来的内存分配将在这个空间中进行,如果占比过高,会影响后续的内存分配

老生代算法(Mark-Sweep,Mark-Compact)

在老生代中,存活对象占较大比重,如果继续采用 Scavenge 算法进行管理,就会存在两个问题

  • 由于存活对象较多,复制存活对象的效率会很低
  • 采用 Scavenge 算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重

所以 V8 在老生代中主要采用了 Mark-SweepMark-Compact 相结合的方式进行垃圾回收

Mark-Sweep(标记-清除算法)

这个算法我们在上文已经介绍过了,这里再简单的总结一下

  • Scavenge 不同,Mark-Sweep 并不会将内存分为两份,所以不存在浪费一半空间的行为,Mark-Sweep 在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象
  • 也就是说,Scavenge 只复制活着的对象,而 Mark-Sweep 只清除死了的对象,活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因

但是这个算法有个比较大的问题是,内存碎片太多,如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的,所以在此基础上提出 Mark-Compact 算法

Mark-Compact

为了解决 Mark-Sweep 的内存碎片问题,Mark-Compact 就被提出来了,Mark-Compact 是标记整理的意思,是在 Mark-Sweep 的基础上演变而来的

Mark-Compact 在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存

两者结合

V8 的回收策略中,Mark-SweepMark-Conpact 两者是结合使用的,由于 Mark-Conpact 需要移动对象,所以它的执行速度不可能很快

在取舍上,V8 主要使用 Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用 Mark-Compact

总结

其实简单来说,V8 的垃圾回收机制分为新生代和老生代

  • 新生代主要使用 Scavenge 进行管理,主要实现是 Cheney 算法,将内存平均分为两块,使用空间叫 From,闲置空间叫 To,新对象都先分配到 From 空间中,在空间快要占满时将存活对象复制到 To 空间中,然后清空 From 的内存空间,此时调换 From 空间和 To 空间,继续进行内存分配,当满足那两个条件时对象会从新生代晋升到老生代
  • 老生代主要采用 Mark-SweepMark-Compact 算法,一个是标记清除,一个是标记整理,两者不同的地方是,Mark-Sweep 在垃圾回收后会产生碎片内存,而 Mark-Compact 在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另一侧内存,这样空闲的内存都是连续的,但是带来的问题就是速度会慢一些,在 V8 中,老生代是 Mark-SweepMark-Compact 两者共同进行管理的

内存泄漏

在梳理完垃圾回收机制相关内容以后,最后我们再来简单的了解一下内存泄漏相关问题,那么什么是内存泄漏呢?

简单来说,程序的运行需要内存,只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存,对于持续运行的服务进程(daemon),必须及时释放不再用到的内存,否则内存占用越来越高,轻则影响系统性能,重则导致进程崩溃

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏,这就要求实时查看内存的占用情况,这一点我们可以通过 Chrome 的开发者工具来查看内存占用情况

  1. 打开开发者工具,选择 Performance 面板
  2. 在顶部勾选 Memory
  3. 点击左上角的 record 按钮
  4. 在页面上进行各种操作,模拟用户的使用情况
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图

我们有两种方式来判定当前是否有内存泄漏

  1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
  2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

而如果是在服务器环境中的话,则可以使用 Node.js 提供的 process.memoryUsage 方法查看内存情况

1
2
3
4
5
6
7
console.log(process.memoryUsage())
// {
// rss: 27709440,
// heapTotal: 5685248,
// heapUsed: 3449392,
// external: 8772
// }

process.memoryUsage 返回一个对象,包含了 Node.js 进程的内存占用信息,该对象包含四个字段,单位是字节,含义如下

  • rssresident set size),所有内存占用,包括指令区和堆栈
  • heapTotal,堆所占用的内存,包括用到的和没用到的
  • heapUsed,用到的堆的部分,
  • externalV8 引擎内部的 C++ 对象占用的内存

通常我们判断内存泄漏均是以 heapUsed 字段为准

常见的内存泄露

下面我们来了解几种常见的 JavaScript 当中的内存泄漏

意外的全局变量

JavaScript 处理未定义变量的方式比较宽松,未定义的变量会在全局对象创建一个新变量,在浏览器中,全局对象是 window

1
2
3
4
5
6
7
8
// 忘记使用 var/const/let,意外创建了一个全局变量 bar,此例泄漏了一个简单的字符串
function foo(arg) {
bar = 'this is a hidden global variable'
}

function foo(arg) {
window.bar = 'this is an explicit global variable'
}

另一种意外的全局变量可能由 this 创建

1
2
3
4
5
6
function foo() {
this.variable = 'potential accidental global'
}

// this 指向了全局对象(window)
foo()

针对于上述情况,解决办法也很简单,我们可以在 JavaScript 文件头部加上 'use strict',使用严格模式解析 JavaScript 避免意外的全局变量,此时上例中的 this 指向 undefined,如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义

尽管我们讨论了一些意外的全局变量,但是仍有一些明确的全局变量产生的垃圾,它们被定义为不可回收(除非定义为空或重新分配),尤其当全局变量用于临时存储和处理大量信息时,需要多加小心,如果必须使用全局变量存储大量数据时,确保用完以后把它设置为 null 或者重新定义

与全局变量相关的增加内存消耗的一个主因是缓存,缓存数据是为了重用,缓存必须有一个大小上限才有用,高内存消耗导致缓存突破上限,因为缓存内容无法被回收

循环引用
1
2
3
4
5
6
7
function func() {
let A = {}
let B = {}

A.a = B // A 引用 B
B.a = A // B 引用 A
}

对于纯粹的 ECMAScript 对象而言,只要没有其他对象引用对象 AB,也就是说它们只是相互之间的引用,那么仍然会被垃圾收集系统识别并回收处理,但是在 Internet Explorer 中,如果循环引用中的任何对象是 DOM 节点或者 ActiveX 对象,垃圾收集系统则不会发现它们之间的循环关系与系统中的其他对象是隔离的并释放它们,最终它们将被保留在内存中,直到浏览器关闭

如果想要解决这个问题,只需要将 AB 都设为 null 即可

被遗忘的定时器或延时器

JavaScript 中使用 setIntervalsetTimeout 很常见,但是使用完之后通常会忘记清理

1
2
3
4
5
6
7
8
9
let result = getData()

setInterval(function () {
let node = document.getElementById('id')
if (node) {
// 处理 node 和 result
node.innerHTML = JSON.stringify(result)
}
}, 1000)

上面示例当中几个需要注意的地方

  • 最好将获取 node 的操作放到定时器之外
  • setIntervalsetTimeout 中的 this 指向的是 window 对象,所以内部定义的变量也挂载到了全局
  • if 内引用了 result 变量,如果没有清除,setInterval 的话 result 也得不到释放,同理 setTimeout 也一样

解决办法,用完后记得使用 clearIntervalclearTimeout 来清除定时器

闭包

JavaScript 当中的闭包有一个十分关键的点,那就是匿名函数可以访问其父级作用域的变量

1
2
3
4
5
6
function bindEvent() {
let obj = document.createElement('id')
obj.onclick = function () {
// ...
}
}

闭包可以维持函数内局部变量,使其得不到释放,上例定义事件回调时,由于是函数内定义函数,并且内部函数,也就是事件回调的引用外暴了,形成了闭包

解决办法有两种

  1. 将事件处理函数定义在外部,解除闭包
  2. 在定义事件处理函数的外部函数中,删除对 DOM 的引用,通常而言在闭包中,作用域中没用的属性可以删除,以减少内存消耗
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 方法一
function bindEvent() {
let obj = document.createElement('id')
obj.onclick = onclickHandler
}

function onclickHandler() {
// ...
}


// 方法二
function bindEvent() {
let obj = document.createElement('id')
obj.onclick = function () {
// ...
}
obj = null
}
DOM 引起的内存泄露

当页面中元素被移除或替换时,若元素绑定的事件仍没被移除,在 IE 中不会作出恰当处理,此时要先手工移除事件,不然会存在内存泄露

1
2
3
4
5
let btn = document.getElementById('btn')

btn.onclick = function () {
document.getElementById('id').innerHTML = 'abc'
}

解决办法有两种

  1. 手动移除事件
  2. 采用事件委托
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 手动移除事件
let btn = document.getElementById('btn')

btn.onclick = function () {
btn.onclick = null
document.getElementById('id').innerHTML = 'abc'
}

// 采用事件委托
document.onclick = function (event) {
event = event || window.event
if (event.target.id == 'btn') {
document.getElementById('id').innerHTML = 'abc'
}
}

另外未清除的 DOM 引用也可能会引起内存泄露

1
2
3
let myDiv = document.getElementById('id')

document.body.removeChild(myDiv)

myDiv 不能回收,因为存在变量 myDiv 对它的引用,解决办法也很简单,直接将 myDiv 设为 null 即可

最后我们再来看一个可能会忽略的问题,那就是 DOM 对象添加的属性是一个对象的引用,这种情况下也可能会引起内存泄露

1
2
3
let MyObject = {}

document.getElementById('myDiv').myProp = MyObject

解决办法就是在页面 onunload 事件中进行释放,比如 document.getElementById('myDiv').myProp = null

自动类型转换
1
2
3
let s = 'abc'

console.log(s.length)

s 本身是一个 string 而非 object,它没有 length 属性,所以当访问 length 时,JavaScript 引擎会自动创建一个临时 String 对象来封装 s,而这个对象一定会泄露

解封办法就是记得所有值类型做运算之前先显式转换一下

1
2
3
let s = 'abc'

console.log(new String(s).length)

小结

其实关于如何避免内存泄漏,我们只需要记住一个原则『不用的东西,及时归还』

  • 减少不必要的全局变量,使用严格模式避免意外创建全局变量
  • 在你使用完数据后,及时解除引用(闭包中的变量,DOM 引用,定时器清除等)
  • 组织好逻辑,避免死循环等造成浏览器卡顿,崩溃的问题

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×