V8垃圾回收GC

GC 全称 Garbage Collection ,即:垃圾回收

计算机程序垃圾指的是什么呢?

写过计算机程序的都知道,程序中无时无刻伴不随着变量的引用,赋值,运算等操作。于是乎存在着某些变量在使用过后,程序不会再用到它们,但是他们依然占据着一定的内存空间。内存中这样不会被程序再次使用的数据便可称之为‘垃圾’。

什么是回收?

简而言之,回收就是将上面讲到的 程序运行时产生的‘垃圾’ 释放掉,以便于程序再次使用这块内存区域。

GC 的历史

  • 1960 年 John McCarthy 首次发布著名的CG 算法 ,GC 标记-清除算法
  • 1960 年 George E. Collins 发布了引用计数的GC算法
  • 1963 年 Marvin L. Minsky 发布了 GC 赋值算法

令人惊叹的是,目前所有的GC算法只不过是上述3种算法的组合或应用,也就是说从1963年赋值算法诞生时,到目前为止没有诞生新的GC算法!

GC 中的基本概念

首先我们明确个知识点,GC在内存中销毁移动的基本单位是对象。

对象

对象由头(head)和 域(field)构成。

image

头 head

对象中保存对象本身信息的部分称之为‘头’。head中的信息用户无法访问和修改,包含下列信息。

  • 对象的大小
  • 对象种类

域 field

field 对于我们而言比较熟悉,在JavaScript使用属性操作符便可以直接访问的部分被称之为域。域包含两种数据类型。

  • 指针 , 即:引用数据类型
  • 非指针, 即:基本数据类型,例如 true , false , 1, ……。

mutator

指的是修改CG对象之间的引用关系。更准确的来讲它就是 ‘应用程序’本身,也就是我们写的代码。

堆 heap

堆指的是用于动态(也就是执行程序时)存放对象的内存空间。当 mutator 申请存放对象时, 所需的内存空间就会从这个堆中被分配给 mutator。

活动对象/非活动对像

也就是分配到内存空间中的对象中那些能够通过 mutator 引用的对象称之为 ‘活动对象’,反之为‘非活动对象’即:垃圾。

根 root

在CG世界中,根指的是对象指针的起点部分。也就是mutator (应用程序) 中的全局变量,通过递归这些根(全局变量)可以遍历到的对象就是活动对象,反之就是非活动对象(垃圾)。

image

(根与堆中对象的关系)

GC标记-清除算法

如该算法的名称所描述,它的执行可以分为两个阶段,即:标记清除标记就是通过上面提到的根(全局对象)能遍历到的内存中的对象标记为活动对象,标记完成之后接下来就清除内存中没有被标记的对象,也就是非活动对象。通过这两个阶节阶段实现了内存空间的重复利用

标记阶段

标记过程实际上可以通过伪代码来更加具体展现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*标记函数*/
mark_phase(){
// 遍历全局对象,即:根
for(r : $roots)
mark(*r)
}

/* 标记遍历到的对象,然后继续遍历该对象的引用对象。 标记过程采用的是深度优先算法 */
mark(obj){
// 遍历到已经设置为 true 的对象则不再处理
if(obj.mark == FALSE)
obj.mark = TRUE
// 标记后继续遍历当前对象的引用对象,直至所有的活动对象都被遍历到
for(child : children(obj))
mark(*child)
}

清除阶段

清除阶段的遍历过程是从堆的首地址开始,一个个的遍历对象的标志位。

伪代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sweep_phase(){
// $heap_start 指针指堆中的第一个对象的指针
sweeping = $heap_start
// 遍历堆时的边界控制,不能超出堆的大小
while(sweeping < $heap_end)
if(sweeping.mark == TRUE)
// 遇到标记为 true 的对象,则取消标志位,准备下一次GC
sweeping.mark = FALSE
else
// 这里就是关键的回收处理了,首先$free_list 就是‘空闲链表’,程序需要的内存就是从其中分配获得。
// 可能有小伙伴看不懂这里,就详细解释一下:
/*
$free_list 是指向‘空闲链表’的指针,将它赋值给要回收对象的 next 域,那么接下来这个要被回收
的对象就变成了‘空闲链表’的头,即:header,也就是这个对象被加入到了空闲链表中,接下来就会被
当做空闲空间来分配给应用程序。最后再将头指针赋给 $free_list 变量,也就是 $free_list 回指向
空闲链表,然后继续遍历……
*/
sweeping.next = $free_list
$free_list = sweeping

// size 保存着被回收对象的大小,这步操作实际上就是移动至堆中的下一个对象,进行回收操作。
sweeping += sweeping.size
}

通过下图可以形象的看出完成一次 GC 回收过后堆的状态:
image

(一次GC回收过后堆的状态)

引用计数法

GC 被本质上来说就是一种研究如何释放无法被引用对象的技术。那么,可以想到,如果让对象自己记录一下,它有没有被程序引用。这就是引用计数法
image

(引用计数法的对象)

引用计数法mutator(应用程序)的执行密切相关,也就是在程序处理数据对象的过程中通过增减计数器来实现内存管理。 在对象的生成和被引用时会发生计数器的增减,也就是 new_obj() 函数和 update_ptr() 函数。

new_obj() 函数,分配内存

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
new_obj(size){
// 从‘空闲链表’中为程序分配空间
obj = pickup_chunk(size, $free_list)
// 分配空间失败
if(obj == NULL)
allocation_fail()
else
// 为分配成功的对象设置计数器,并初始化
obj.ref_cnt = 1
return obj
}

update_ptr()函数,程序引用该对象

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
update_ptr(ptr, obj){
// obj 对象的计数器增量操作
inc_ref_cnt(obj)
// ptr 指针原来执行的对象计数器减量操作
dec_ref_cnt(*ptr)
// ptr 指正指向 obj
*ptr = obj
}

// 该函数就是让指针 ptr 要指向 obj 对象,实际中的代码类似于:
/*
var a = {};
var b = {};
a 对象计数器自增,b 对象计数器自减,
b=a;
*/

inc_ref_cnt() 函数伪代码实现:

1
2
3
4
// 这里很简单 ,就是计数器自增
inc_ref_cnt(obj){
obj.ref_cnt++
}

dec_ref_cnt() 函数伪代码实现:

1
2
3
4
5
6
7
8
9
10
11
dec_ref_cnt(obj){
// obj 对象计数器自减
obj.ref_cnt--
// 如果计数器等于 0 ,也就是说 obj 对象变为垃圾了
if(obj.ref_cnt == 0)
// 那么被 obj 所引用的对象也应该做减量操作
for(child : children(obj))
dec_ref_cnt(*child)
// 将 obj 对象连接至空闲链表
reclaim(obj)
}

缺点

循环引用,造成内存无法被回收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注意:例子仅用于描述场景,不符合真实环境情况

funciton Person(name,lover) {
this.name = name;
this.lover= lover;
}

let jxy = new Person("jxy");
let xxx = new Person("xxx")
jxy.lover = xxx;
xxx.lover = jxy;

xxx = null;
jxy = null;

// 当GC采用引用计数法管理内存的时候,在上面例子中虽然变量都被赋值为空,但是两个对象本身确相互
// 引用,这样就导致了内存无法被有效回收,即:内存泄露复制代码

GC复制算法

复制算法顾名思义,就是将堆中的所有活动对象复制到另外一个空间,然后原来的空间全部回收掉。这样的好处就是防止出现内存的碎片化,易于随后为程序分配新的空间。可以形象的理解为下图:

image

复制算法

我们把原来的活动对象空间称之为 From 空间,将要复制到的新空间称之为 To 空间。当 From 空间被完全占满时,GC 会将活动对象复制到 To 空间。复制完成后,该算法会把 From 空间和 To 空间互换,本次 GC 也就结束了。GC 复制算法概要如下图所示:

image

(GC 复制算法概要)

这里再说明一下,mutator 就是应用程序本身,一次回收完成之后,程序会继续执行,再次产生垃圾,新的 From 空间会被填满,然后 GC 又开始新的一轮回收操作,回收操作伴随程序的整个生命周期。

下面我们通过伪代码来的看一下 GC 复制算法的具体实现思路。

copying() 函数伪代码:

1
2
3
4
5
6
7
8
9
10
copying(){	
// 用 $free 变量记录 To 空间的开始位置
$free = $to_start
// 遍历所有的根对象,使用 copy 方法将他们复制到 To 空间
for(r : $roots)
// 返回的 *r 是对象被复制到 To 空间后新的指针。
*r = copy(*r)
// 复制完成之后,交换 From 和 To 空间
swap($from_start, $to_start)
}

copy()函数伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* copy 函数将作为参数给出的对象复制,再递归复制其子对象 */
copy(obj){
// obj 对象的 tag 域用于标记是否是一个已经被赋值过的对象
if(obj.tag != COPIED)
// 使用 copy_data 方法具体来拷贝 obj 对象,同时传入To 空间地址,以及 obj d对象的大小
copy_data($free, obj, obj.size)
// 拷贝完成之后,标记一下,该对象已经被赋值过了. $free 变量指向新的 obj
obj.tag = COPIED
// 旧的 obj 对象的 forwarding 域保存新的 obj 对象的指针,用于后面将其赋给程序中原始的指向
obj.forwarding = $free
// $free 跳过已经被复制的 obj 的空间,指向 To 空间的空闲位置,方便下一次复制使用
$free += obj.size
// 递归复制 obj 对象的引用对象
for(child : children(obj.forwarding))
*child = copy(*child)
// 当拷贝完成之后返回新对象的指针
return obj.forwarding
}

Chrome V8 的垃圾回收

前面写了那么多,那么到底我们常用的浏览器使用的是那种回收算法呢?我想这可能是小伙伴们最关心的了。那么以我们最喜爱的 Chrome 为例,它使用的是多种回收算法的组合优化,而非某种单一算法。V8 的 GC 算法统称为分代垃圾回收算法,也就是通过记录对象的引用次数,将超过一定引用次数的对象划分为老年对象,剩下的称之为新生代对象,然后分别对他们采用不同到的垃圾回收算法那这样划分到底有什么优势呢,我们知道程序中生成的大多数对象其实都是产生之后随即丢弃。以下面代码为例,函数内部生成了对象,在该函数执行完毕,出栈之后,包括函数本身以及它内部的变量都会立刻成为垃圾:

1
2
3
4
5
// 该函数的执行上下文环境非全局作用域
function foo() {
var a = {c:1};
var c = {c:2};
}

那么对于这种新生代对象来说,回收就会变得很频繁,如果使用 GC 标记清除算法,那么就意味着每次清除过程需要处理很多的对象,会浪费大量的的时间。于是如果对新生代对象采用 GC 复制算法的只需要将活动对象复制出来,然后将整个 From 清空即可,无需再去遍历需要清除的对象,达到优化的目的。而针对老年对象则不同,它们都有多个引用,也就意味着它们成为非活动对象的概率较小,也就可以理解为老年对象不会轻易变成垃圾。再进一步也就是老对象产生的垃圾很少,如果采用复制算法的话得不偿失,大量的老年对象被复制来复制去也会增加负担,所以针对老年对象采用的是标记清除法,需要清除的老年对象只是少数,这样标记清除算法会更有优势还有随着程序的执行新生代的对象会变成老年对象,这个具体过程比较复杂,小的能力有限,这里也就一笔带过了。既然对象分为新生带对像和老年对象,那么它们在堆中是如何分布的呢,请看下图:

image

(V8 的 VM 堆结构示意图 )

这里我们只需要知道堆被分为新生带空间,和老年代空间即可。除了新生代空间中方的 From 空间和 To 空间外,老年代空间中细分优化,各位大佬请自由探索,小的能有限,就不敢造次了o(╥﹏╥)o。

V8 引擎如何进行垃圾内存的回收?

JS 语言不像 C/C++, 让程序员自己去开辟或者释放内存,而是类似Java,采用自己的一套垃圾回收算法进行自动的内存管理。作为一名资深的前端工程师,对于JS内存回收的机制是需要非常清楚, 以便于在极端的环境下能够分析出系统性能的瓶颈,另一方面,学习这其中的机制,也对我们深入理解JS的闭包特性、以及对内存的高效使用,都有很大的帮助。

V8 内存限制

在其他的后端语言中,如Java/Go, 对于内存的使用没有什么限制,但是JS不一样,V8只能使用系统的一部分内存,具体来说,在64位系统下,V8最多只能分配1.4G, 在 32 位系统中,最多只能分配0.7G。你想想在前端这样的大内存需求其实并不大,但对于后端而言,nodejs如果遇到一个2G多的文件,那么将无法全部将其读入内存进行各种操作了。

我们知道对于栈内存而言,当ESP指针下移,也就是上下文切换之后,栈顶的空间会自动被回收。但对于堆内存而言就比较复杂了,我们下面着重分析堆内存的垃圾回收。

上一篇我们提到过了,所有的对象类型的数据在JS中都是通过堆进行空间分配的。当我们构造一个对象进行赋值操作的时候,其实相应的内存已经分配到了堆上。你可以不断的这样创建对象,让 V8 为它分配空间,直到堆的大小达到上限。

那么问题来了,V8 为什么要给它设置内存上限?明明我的机器大几十G的内存,只能让我用这么一点?

究其根本,是由两个因素所共同决定的,一个是JS单线程的执行机制,另一个是JS垃圾回收机制的限制。

首先JS是单线程运行的,这意味着一旦进入到垃圾回收,那么其它的各种运行逻辑都要暂停; 另一方面垃圾回收其实是非常耗时间的操作,V8 官方是这样形容的:

以 1.5GB 的垃圾回收堆内存为例,V8 做一次小的垃圾回收需要50ms 以上,做一次非增量式(ps:后面会解释)的垃圾回收甚至要 1s 以上。

可见其耗时之久,而且在这么长的时间内,我们的JS代码执行会一直没有响应,造成应用卡顿,导致应用性能和响应能力直线下降。因此,V8 做了一个简单粗暴的选择,那就是限制堆内存,也算是一种权衡的手段,因为大部分情况是不会遇到操作几个G内存这样的场景的。

不过,如果你想调整这个内存的限制也不是不行。配置命令如下:

1
2
// 这是调整老生代这部分的内存,单位是MB。后面会详细介绍新生代和老生代内存
node --max-old-space-size=2048 xxx.js

或者

1
2
// 这是调整新生代这部分的内存,单位是 KB。
node --max-new-space-size=2048 xxx.js

新生代内存的回收

V8 把堆内存分成了两部分进行处理——新生代内存和老生代内存。顾名思义,新生代就是临时分配的内存,存活时间短, 老生代是常驻内存,存活的时间长。V8 的堆内存,也就是两个内存之和。

image

根据这两种不同种类的堆内存,V8 采用了不同的回收策略,来根据不同的场景做针对性的优化。

首先是新生代的内存,刚刚已经介绍了调整新生代内存的方法,那它的内存默认限制是多少?在 64 位和 32 位系统下分别为 32MB 和 16MB。够小吧,不过也很好理解,新生代中的变量存活时间短,来了马上就走,不容易产生太大的内存负担,因此可以将它设的足够小。

那好了,新生代的垃圾回收是怎么做的呢?

首先将新生代内存空间一分为二:

image

其中From部分表示正在使用的内存,To 是目前闲置的内存。

当进行垃圾回收时,V8 将From部分的对象检查一遍,如果是存活对象那么复制到To内存中(在To内存中按照顺序从头放置的),如果是非存活对象直接回收即可。

当所有的From中的存活对象按照顺序进入到To内存之后,From 和 To 两者的角色对调,From现在被闲置,To为正在使用,如此循环。

那你很可能会问了,直接将非存活对象回收了不就万事大吉了嘛,为什么还要后面的一系列操作?

注意,我刚刚特别说明了,在To内存中按照顺序从头放置的,这是为了应对这样的场景:

image

深色的小方块代表存活对象,白色部分表示待分配的内存,由于堆内存是连续分配的,这样零零散散的空间可能会导致稍微大一点的对象没有办法进行空间分配,这种零散的空间也叫做内存碎片。刚刚介绍的新生代垃圾回收算法也叫Scavenge算法。

Scavenge 算法主要就是解决内存碎片的问题,在进行一顿复制之后,To空间变成了这个样子:

image

是不是整齐了许多?这样就大大方便了后续连续空间的分配。

不过Scavenge 算法的劣势也非常明显,就是内存只能使用新生代内存的一半,但是它只存放生命周期短的对象,这种对象一般很少,因此时间性能非常优秀。

老生代内存的回收

刚刚介绍了新生代的回收方式,那么新生代中的变量如果经过多次回收后依然存在,那么就会被放入到老生代内存中,这种现象就叫晋升。

发生晋升其实不只是这一种原因,我们来梳理一下会有那些情况触发晋升:

  • 已经经历过一次 Scavenge 回收。
  • To(闲置)空间的内存占用超过25%。

现在进入到老生代的垃圾回收机制当中,老生代中累积的变量空间一般都是很大的,当然不能用Scavenge算法啦,浪费一半空间不说,对庞大的内存空间进行复制岂不是劳民伤财?

那么对于老生代而言,究竟是采取怎样的策略进行垃圾回收的呢?

第一步,进行标记-清除。这个过程在《JavaScript高级程序设计(第三版)》中有过详细的介绍,主要分成两个阶段,即标记阶段和清除阶段。首先会遍历堆中的所有对象,对它们做上标记,然后对于代码环境中使用的变量以及被强引用的变量取消标记,剩下的就是要删除的变量了,在随后的清除阶段对其进行空间的回收。

当然这又会引发内存碎片的问题,存活对象的空间不连续对后续的空间分配造成障碍。老生代又是如何处理这个问题的呢?

第二步,整理内存碎片。V8 的解决方式非常简单粗暴,在清除阶段结束后,把存活的对象全部往一端靠拢。

image

由于是移动对象,它的执行速度不可能很快,事实上也是整个过程中最耗时间的部分。

增量标记

由于JS的单线程机制,V8 在进行垃圾回收的时候,不可避免地会阻塞业务逻辑的执行,倘若老生代的垃圾回收任务很重,那么耗时会非常可怕,严重影响应用的性能。那这个时候为了避免这样问题,V8 采取了增量标记的方案,即将一口气完成的标记任务分为很多小的部分完成,每做完一个小的部分就”歇”一下,就js应用逻辑执行一会儿,然后再执行下面的部分,如果循环,直到标记阶段完成才进入内存碎片的整理上面来。其实这个过程跟React Fiber的思路有点像,这里就不展开了。

经过增量标记之后,垃圾回收过程对JS应用的阻塞时间减少到原来了1 / 6, 可以看到,这是一个非常成功的改进。

JS垃圾回收的原理就介绍到这里了,其实理解起来是非常简单的,重要的是理解它为什么要这么做,而不仅仅是如何做的,希望这篇总结能够对你有所启发。

V8 执行一段JS代码的过程?

站在 V8 的角度,理解其中的执行机制,也能够帮助我们理解很多的上层应用,包括Babel、Eslint、前端框架的底层机制。那么,一段 JavaScript 代码放在 V8 当中究竟是如何执行的呢?

首先需要明白的是,机器是读不懂 JS 代码,机器只能理解特定的机器码,那如果要让 JS 的逻辑在机器上运行起来,就必须将 JS 的代码翻译成机器码,然后让机器识别。JS属于解释型语言,对于解释型的语言说,解释器会对源代码做如下分析:

通过词法分析和语法分析生成 AST(抽象语法树)
生成字节码
然后解释器根据字节码来执行程序。但 JS 整个执行的过程其实会比这个更加复杂,接下来就来一一地拆解。

1.生成 AST

生成 AST 分为两步——词法分析和语法分析。

词法分析即分词,它的工作就是将一行行的代码分解成一个个token。 比如下面一行代码:

1
let name = 'sanyuan'

其中会把句子分解成四个部分:

image

即解析成了四个token,这就是词法分析的作用。

接下来语法分析阶段,将生成的这些 token 数据,根据一定的语法规则转化为AST。举个例子:

1
2
let name = 'sanyuan'
console.log(name)

最后生成的 AST 是这样的:

image

当生成了 AST 之后,编译器/解释器后续的工作都要依靠 AST 而不是源代码。顺便补充一句,babel 的工作原理就是将ES5 的代码解析生成ES5的AST,然后将ES5 的 AST 转换为 ES6 的AST,最后才将 ES6 的 AST 转化为具体的 ES6 代码。由于本文着重阐述原理,关于 babel 编译的细节就不展开了,推荐大家去读一读荒山的babel文章, 帮你打开新世界的大门: )

回到 V8 本身,生成 AST 后,接下来会生成执行上下文,关于执行上下文,可以参考上上篇《JavaScript内存机制之问——数据是如何存储的?》中对于上下文压栈出栈过程的讲解。

#2. 生成字节码
开头就已经提到过了,生成 AST 之后,直接通过 V8 的解释器(也叫Ignition)来生成字节码。但是字节码并不能让机器直接运行,那你可能就会说了,不能执行还转成字节码干嘛,直接把 AST 转换成机器码不就得了,让机器直接执行。确实,在 V8 的早期是这么做的,但后来因为机器码的体积太大,引发了严重的内存占用问题。

给一张对比图让大家直观地感受以下三者代码量的差异:

image

很容易得出,字节码是比机器码轻量得多的代码。那 V8 为什么要使用字节码,字节码到底是个什么东西?

子节码是介于AST 和 机器码之间的一种代码,但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码然后执行。

字节码仍然需要转换为机器码,但和原来不同的是,现在不用一次性将全部的字节码都转换成机器码,而是通过解释器来逐行执行字节码,省去了生成二进制文件的操作,这样就大大降低了内存的压力。

执行代码

接下来,就进入到字节码解释执行的阶段啦!

在执行字节码的过程中,如果发现某一部分代码重复出现,那么 V8 将它记做热点代码(HotSpot),然后将这么代码编译成机器码保存起来,这个用来编译的工具就是V8的编译器(也叫做TurboFan) , 因此在这样的机制下,代码执行的时间越久,那么执行效率会越来越高,因为有越来越多的字节码被标记为热点代码,遇到它们时直接执行相应的机器码,不用再次将转换为机器码。

其实当你听到有人说 JS 就是一门解释器语言的时候,其实这个说法是有问题的。因为字节码不仅配合了解释器,而且还和编译器打交道,所以 JS 并不是完全的解释型语言。而编译器和解释器的 根本区别在于前者会编译生成二进制文件但后者不会。

并且,这种字节码跟编译器和解释器结合的技术,我们称之为即时编译, 也就是我们经常听到的JIT。

这就是 V8 中执行一段JS代码的整个过程,梳理一下:

  • 首先通过词法分析和语法分析生成 AST
  • 将 AST 转换为字节码
  • 由解释器逐行执行字节码,遇到热点代码启动编译器进行编译,生成对应的机器码, 以优化执行效率

关于这个问题的拆解就到这里,希望对你有所启发。

文章参考