一、基本概念
1.1 对象/头/域
对象这个词,在不同的使用场合其意思各不相同。比如,在面向对象编程中,它指“具有属性和行为的事物”,然而在 GC 的世界中,对象表示的是“通过应用程序利用的数据的集合”。
对象配置在内存空间里。GC 根据情况将配置好的对象进行移动或销毁操作。因此,对象是 GC 的基本单位。本文中的所有“对象”都表示这个含义。一般来说,对象由头(header)和域(field)构成。
头
对象中保存对象本身信息的部分称之为头。头中的信息用户无法访问和修改,包含下列信息:
●对象的大小
●对象种类
域
对象中可访问的部分称为“域”。可以将其想成 C 语言中结构体的成员,这样就很简单了吧。对象使用者会引用或替换对象的域值。域中的数据类型大致分为以下 2 种。
●指针:指针是指向内存空间中某块区域的值;
●非指针:在编程中直接使用值本身。数值、字符以及真假值都是非指针;
1.2 mutator
mutator 用于更改 GC 对象间的引用关系,用一句话概括的话, 它的实体就是“应用程序”。它实际进行的操作有以下两种:
●生成对象;
●更新指针;
mutator 在进行这些操作时,对象间的引用关系也会“改变”。伴随这些变化会产生垃圾,而 GC 就是负责回收这些垃圾的机制。
1.3 堆
堆指的是用于动态存放对象的内存空间。当 mutator 申请存放对象时,所需的内存空间就会从这个堆中被分配给 mutator。
GC 是管理堆中已分配对象的机制。在开始执行 mutator 前,GC 要分配用于堆的内存空间。一旦开始执行 mutator,程序就会按照 mutator 的要求在堆中存放对象。等到堆被对象占满后,GC 就会启动,从而分配可用空间。
1.4 根/可达性
JavaScript 中主要的内存管理概念是 可达性(reachable)。“可达”可达是那些以某种方式可访问或可用的值。它们一定是存储在内存中的。这里列出固有的可达值的基本集合:
●当前函数的局部变量和参数。
●嵌套调用时,当前调用链上所有函数的变量与参数。
●全局变量。
●(以及一些内部的)
这些值被称作 根(roots)。
如果一个值可以通过引用或引用链从根访问任何其他值,则认为该值是可达的。比如说,如果全局变量中有一个对象,并且该对象有一个属性引用了另一个对象,则该对象被认为是可达的,而且它引用的内容也是可达的。
1.5 活动对象/垃圾对象
从根可访问的所有对象都被视为活动对象(live object)。活动对象引用的任何对象也被视为活动对象。反之则为垃圾对象(garbage)
1.6 什么是垃圾回收
说起垃圾回收(Garbage Collection,GC),就不得不提内存管理的概念,内存管理的流程有三个环节:
1申请:分配你所需要的内存;
2使用:对分配到的内存进行读、写;
3释放:不需要时将其释放;
在没有 GC 的世界里,释放无用内存空间的事情需要由程序员自己来处理。就是说当程序员认为空间没用了,就手动地释放其占用的内存。但是这样显然非常繁琐,如果有所遗漏,就可能造成资源浪费甚至内存泄露。
有了 GC,程序员就不需要再手动的去控制内存的释放。GC 把程序不用的内存空间视为垃圾,而释放垃圾的过程称为垃圾回收。
垃圾回收的过程涉及到两个阶段:
1区分活动对象与垃圾对象;
2回收垃圾对象的内存,使得程序可以重复使用这些内存;
1.7 为什么需要垃圾回收
在没有 GC 的世界里,程序员必须自己手动进行内存管理,必须清楚地确保必要的内存空间,释放不要的内存空间。程序员在手动进行内存管理时,申请内存尚不存在什么问题,但在释放不要的内存空间时,就必须一个不漏地释放。这非常地麻烦。如果忘记释放内存空间,该内存空间就会发生内存泄露,即无法被使用,但它又会持续存在下去。如果将发生内存泄露的程序放着不管,总有一刻内存会被占满,甚至还可能导致系统崩溃。
为了省去上述手动内存管理的麻烦,人们开发出了 GC。如果把内存管理交给计算机,程序员就不用去想着释放内存了。在手动内存管理中,程序员要判断哪些是不用的内存空间(垃圾),留意内存空间的寿命。有了 GC 后,这一切都可以交给 GC 来做,程序员就不用再去担心因为忘了释放内存等而导致 BUG,从而大大减轻了负担。GC 能让程序员告别恼人的内存管理,把精力集中在更本质的编程工作上。
1.8 垃圾回收算法评价标准
评价 GC 算法的性能时,常用的标准有如下四个:
●吞吐量
●最大暂停时间
●堆使用效率
●访问的局部性
下面我们逐一进行说明。
1.8.1 吞吐量
吞吐量(throughput)指的是“在单位时间内的处理能力”,以上图为例,在 mutator 整个执行过程中,GC 一共启动了 3 次,我们把花费的时间分别设为 A、B、C、堆大小设为 HEAP_SIZE。这种情况下 GC 的吞吐量为 HEAP_SIZE /(A + B + C)。
判断各算法吞吐量的好坏时不能一概而论。打个比方,GC 复制算法和 GC 标记 - 清除算法相比,当活动对象越少时吞吐量越高。因为 GC 复制算法只检查活动对象,而 GC 标记 - 清除算法则会检查所有的活动和非活动对象。随着活动对象的增多,各 GC 算法表现出的吞吐量也会相应地变化。极端情况下,甚至会出现 GC 标记 - 清除算法比 GC 复制算法表现的吞吐量更高的情况。
1.8.2 最大暂停时间
几乎所有的 GC 算法,都会在 GC 执行过程中令 mutator 暂停执行。最大暂停时间指的是因执行 GC 而暂停执行 mutator 的最长时间。如果在浏览 Web 网页的时候发生 GC,浏览器就会看似卡住,用户体验就会不太好,所以我们不希望执行过程中长时间受到 GC 的影响,这种情况下就需要缩短最大暂停时间。
1.8.3 堆使用效率
左右堆使用效率的因素有两个:
●头的大小;
●堆的用法;
首先是头的大小。在堆中堆放的信息越多,GC 的效率也就越高,吞吐量也就随之得到改善。但毋庸置疑,头越小越好。因此为了执行 GC,需要把在头中堆放的信息控制在最小限度。
其次,根据堆的用法,堆使用效率也会出现巨大的差异。举个例子,GC 复制算法中将堆二等分,每次只使用一半,交替进行,因此总是只能利用堆的一半。相对而言,GC 标记 - 清除算法和引用计数法就能利用整个堆。
堆使用效率和吞吐量,以及最大暂停时间不可兼得。简单地说就是:可用的堆越大,GC 运行越快;相反,越想有效地利用有限的堆,GC 花费的时间就越长。
1.8.4 访问的局部性
一般我们会把所有的数据都放在内存里,当 CPU 访问数据时,仅把要使用的数据从内存读取到缓存。与此同时,我们还将它附近的所有数据都读取到缓存中,从而压缩读取数据所需要的时间。
具有引用关系的对象之间通常很可能存在连续访问的情况。这在多数程序中都很常见,称为“访问的局部性”。考虑到访问的局部性,把具有引用关系的对象安排在堆中较近的位置,就能提高在缓存中读取到想利用的数据的概率,令 mutator 高速运行。
二、常用垃圾回收算法
2.1 引用计数算法
GC 是一种释放怎么都无法被引用的对象的机制。那么人们自然而然地就会想到,可以让所有对象事先记录下有多少程序引用自己。让各对象知道自己的“人气指数”,从而让没有人气的对象自己消失。
引用计数算法(Reference Counting GC)中引入了一个概念,那就是计数器。计数器表示的是对象的人气指数,也就是有多少程序引用了这个对象(被引用数)。当对象的被引用次数变为零时就将其释放。
优点
吞吐量较大,当对象的被引用次数变为零时就将其释放。可以即刻回收垃圾,将内存管理开销分摊在程序运行过程中,垃圾被立即回收,因此可以持续操作即将填满的堆。
最大暂停时间短,只有通过 mutator 更新指针时程序才会执行垃圾回收。也就是说,每次通过执行mutator 生成垃圾对象时这部分垃圾都会被回收,因而大幅度地削减了赋值器的最大暂停时间。
缺点
无法回收循环引用的对象:
![](/i/l/?n=22&i=blog/2210563/202209/2210563-20220920102946393-1243054392.png)
时间开销大,计数器值的增减处理繁重,在引用计数法中,每当指针更新时,计数器的值都会随之更新,因此值的增减处理必然会变得繁重。
空间开销大,计数器占用的位数多,用于引用计数的计数器最大必须能数完堆中所有对象的引用数。假如我们用的是 32 位机器,那么就有可能要让 232 个对象同时引用一个对象。考虑到这种情况,就有必要确保各对象的计数器有 32 位大小。也就是说,对于所有对象,必须留有 32 位的空间。这就让内存空间的使用效率大大降低了。打比方说,假如对象只有 2 个域,那么其计数器就占了它整体的 1/3 。
标记 - 清除算法(Mark Sweep GC)由标记阶段和清除阶段构成:
●标记阶段:把所有活动对象都做上标记的阶段。
○考虑到深度优先遍历比广度优先遍历内存使用量要小一些,在标记阶段常用深度优先遍历;
○标记阶段会标记所有的活动对象,由此可知,标记时间与活动对象的总数成正比;
●清除阶段:把那些没有标记的对象,也就是非活动对象回收的阶段。
○在清除阶段,GC 会遍历整个堆,也就是说,清除时间与堆大小成正比;
通过这两个阶段,就可以令不能利用的内存空间重新得到利用。
优点
堆空间利用率较高:可以在整个堆中存放对象,堆使用效率较高;
兼容保守式 GC 算法:保守式 GC 算法中,对象是不能被移动的。而 GC 标记 - 清除算法因为不会移动对象,所以非常适合搭配保守式 GC 算法。事实上,在很多采用保守式 GC 算法的处理程序中也用到了 GC 标记 - 清除算法。
缺点
碎片化:经过多轮空间分配和垃圾回收后,堆从一整片完整的空间被划分为了很多不连续的碎片。空闲链表中的每个节点就都指向这样一个大小不一的分块。如下图所示
分配速度受限:由于分块不是连续的,每次分配都必须遍历空闲链表,找到足够大的分块。最坏的情况下,每次进行分配都会要遍历整个空闲链表。
2.3 复制算法
复制算法(Copying GC)就是只把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。我们将复制活动对象的原空间称为 From 空间,将粘贴活动对象的新空间称为 To 空间。
GC 复制算法是利用 From 空间进行分配的。当 From 空间被完全占满时,GC 会将活动对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。From 空间和 To 空间大小必须一致。这是为了保证能把 From 空间中的所有活动对象都收纳到 To 空间里。
优点
优秀的吞吐量:因为复制算法只搜索并复制活动对象,所以跟标记 - 清除算法相比,它能在较短时间内完成 GC。也就是说,其吞吐量优秀。尤其是堆越大,差距越明显。
不会发生碎片化:复制算法每次运行 GC 时都会执行整理,因此不会发生碎片化。
可实现高速分配:复制算法不使用空闲链表,因为分块是一个连续的内存空间,只要这个分块大小不小于所申请的大小,那么就可以进行分配了。比起 GC 标记 - 清除算法和引用计数法等使用空闲链表的分配,GC 复制算法明显快得多。
缺点
堆使用效率低下:复制算法把堆二等分,通常只能利用其中的一半来安排对象。也就是说,只有一半堆能被使用。相比其他能使用整个堆的 GC 算法而言,可以说这是复制算法的一个重大的缺陷。通过搭配使用 标记 - 清除算法可以改善这个缺点。
不兼容保守式 GC 算法:复制算法必须移动对象重写指针,所以有着跟保守式 GC 算法不相容的性质
2.4 标记 - 整理算法
标记 - 整理算法(Mark Compact GC)是将标记 - 清除算法与复制算法相结合的产物,该算法由标记阶段和整理阶段构成:
●标记阶段:与标记 - 清除算法的标记阶段一致;
●整理阶段:移动活动对象的位置,使活动对象聚集到堆的一端。
○整理阶段并不会改变对象的排列顺序,只是缩小了它们之间的空隙。
![](/i/l/?n=22&i=blog/2210563/202209/2210563-20220920105121626-1976592467.png)
优点
堆空间利用率较高,可以在整个堆中存放对象,堆使用效率较高;
没有碎片化的问题,经过整理后的堆内存空间是连续的;
缺点
吞吐量较差,整理过程需要多次整堆遍历,速度较慢,吞吐量较差;
2.5 分代垃圾回收
分代垃圾回收(Generational GC)在对象中引入了“年龄”的概念,通过优先回收容易成为垃圾的对象,提高垃圾回收的效率。
人们从众多程序案例中总结出了一个经验:“大部分的对象在生成后马上就变成了垃圾,很少有对象能活得很久。”分代垃圾回收利用该经验,在对象中导入了“年龄”的概念,经历过一次 GC 后活下来的对象年龄为 1 岁。
分代垃圾回收中把对象分类成几代,针对不同的代使用不同的 GC 算法,我们把刚生成的对象称为新生代对象(new generation),到达一定年龄的对象则称为老年代对象(old generation)。
我们将对新对象执行的 GC 称为新生代 GC(minor GC),新生代 GC 的前提是大部分新生代对象都没存活下来,GC 在短时间内就结束了。
新生代 GC 将存活了一定次数的新生代对象当作老年代对象来处理。我们把类似于这样的新生代对象提升为老年代对象的情况称为晋升(promotion)。
因为老年代对象很难成为垃圾,所以我们对老年代对象减少执行 GC 的频率。相对于新生代 GC,我们将面向老年代对象的 GC 称为老年代 GC(major GC)。
优点
吞吐量得到改善,“很多对象年纪轻轻就会死”这一法则虽然是经验之谈,不过还是适用于大多数情况的。以这个法则为前提,新生代 GC 只将刚生成的对象当成对象,这样一来就能减少时间上的消耗。反过来,因为老年代 GC 是针对很难变成垃圾的老年代对象执行的,所以要比新生代 GC 花的时间长。
缺点
在部分程序中会起到反作用,“很多对象年纪轻轻就会死”这个法则毕竟只适合大多数情况,并不适用于所有程序。当然,对象会活得很久的程序也有很多。对这样的程序执行分代垃圾回收,就会产生以下两个问题:
1新生代 GC 所花费的时间增多;
2老年代 GC 频繁运行;
当“很多对象年纪轻轻就会死”这个法则不成立时,分代垃圾回收就没有什么作用,或者说反而可能会起到反作用。这种情况下我们还是使用基本算法更好。
2.6 增量式垃圾回收
增量式垃圾回收(Incremental GC)是一种通过逐渐推进垃圾回收来控制 mutator 最大暂停时间的方法。
通常的 GC 处理很繁重,一旦 GC 开始执行,不过多久赋值器就没法执行了,这是常有的事。也就是说,GC 本来是从事幕后工作的,可是它却一下子嚣张起来,害得赋值器这个主角都没法发挥作用了。我们将像这样的在执行时害得赋值器完全停止运行的 GC 叫作停止型 GC 。停止型 GC 的示意图如下:
根据应用程序(mutator)的用途不同,有时停止型 GC 是很要命的。因此人们想出了增量式垃圾回收这种方法。增量(incremental)这个词有“慢慢发生变化”的意思。就如它的名字一样,增量式垃圾回收是将 GC 和赋值器一点点交替运行的手法。增量式垃圾回收的示意图如下:
描述增量式垃圾回收的算法时我们有个方便的概念,那就是 Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking)。顾名思义,这个算法就是将 GC 中的对象按照各自的情况分成三种:
●白色:还未搜索过的对象;
●灰色:正在搜索的对象;
●黑色:搜索完成的对象;
GC 开始运行前所有的对象都是白色。GC 一开始运行,所有从根能到达的对象都会被标记,然后被堆到栈里。GC 只是发现了这样的对象,但还没有搜索完它们,所以这些对象就成了灰色对象。灰色对象会被依次从栈中取出,其子对象也会被涂成灰色。当其所有的子对象都被涂成灰色时,对象就会被涂成黑色。当 GC 结束时已经不存在灰色对象了,活动对象全部为黑色,垃圾则为白色。
三色标记算法能应用于所有搜索型 GC 算法,以将 GC 标记 - 清除算法增量式运行为例,增量式的 GC 标记 - 清除算法可分为以下三个阶段:
●根查找阶段;
●标记阶段;
●清除阶段;
我们在根查找阶段把能直接从根引用的对象涂成灰色。在标记阶段查找灰色对象,将其子对象也涂成灰色,查找结束后将灰色对象涂成黑色。在清除阶段则查找堆,将白色对象连接到空闲链表,将黑色对象变回白色。
优点
缩短最大暂停时间:增量式垃圾回收不是一口气运行 GC,而是和 mutator 交替运行的,因此不会长时间妨碍到 mutator 的运行。增量式垃圾回收适合那些比起提高吞吐量,更重视缩短最大暂停时间的应用程序。
缺点
降低了吞吐量:在复制算法部分提到过,缩短 GC 时间可以提高吞吐量,但在增量式 GC 里缩短的是最大暂停时间,所以就会牺牲一定的吞吐量。
三、V8 的垃圾回收策略
V8 实现了准确式 GC,GC 算法方面采用了分代垃圾回收。分代垃圾回收的策略如下:
●新生代对象:GC 复制算法、GC 标记 - 整理算法;
●老生代对象:GC 标记 - 清除算法、GC 标记 - 整理算法;
![](/i/l/?n=22&i=blog/2210563/202209/2210563-20220920110304760-802611376.png)
V8 将空间一分为二,默认情况下:
●32 位系统:新生代内存大小为 16MB,老生代内存大小为 700MB ;
●64 位系统:新生代内存大小为 32MB,老生代内存大小为 1.4GB ;
3.1 如何回收新生代对象
新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。
由于新生代对象生存时间比较短,大多数都是要回收的对象,如果采用标记 - 清除算法则内存空间碎片化严重,所以采用复制算法可以灵活高效,且便与整理空间。
V8 中新生代对象回收过程使用 GC 复制算法 和 GC 标记 - 整理算法 ,回收的详细过程如下:
1将新生代存储区分为两个等大小的空间,使用空间为 From,空闲空间为 To ;
2活动对象存储于 From 空间,经过标记整理后将活动对象拷贝至 To 空间;
3From 空间和 To 空间交换完成释放;
当把活动对象从 From 空间拷贝至 To 空间时,出现以下情况活动对象将被晋升到老生代存储区:
1经过一轮 GC 仍然存活的对象,会被晋升到老生代存储区;
2当 To 空间使用率超过 25% 时,该对象直接晋升到老生代中;设置 25% 这个阈值的原因是当这次回收完成后,这个 To 空间会变为 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
3.2 如何回收老年代对象
在老生代中,存活对象占较大比重,如果继续采用 GC 复制算法 和 GC 标记 - 整理算法 算法进行管理,就会存在两个问题:
1效率低下:由于存活对象较多,复制存活的对象的效率会变的很低;
2堆利用率低下:复制算法把存储区一分为二,利用率直接降低了一半,并且老生代所占堆内存远大于新生代,所以浪费会很严重;
所以在老年代存储区主要使用的 GC 标记 - 清除 、 GC 标记 - 整理 和 增量标记 算法,相对于新生代 GC 来说堆的利用率更高。回收过程如下:
1首先,使用 GC 标记 - 清除算法,完成垃圾空间的回收,此时可能会有内存空间碎片化的问题;
2当对象从新生代存储区晋升到老生代存储区时,再使用 GC 标记 - 整理算法 对内存空间进行优化;
当 GC 工作的时候,是会阻塞 JavaScript 执行的,使用增量标记算法将垃圾回收操作拆成多段与 mutator 交替执行,从而降低阻塞的时间。
四、更好的利用垃圾回收策略
JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似但不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
4.1 通过 const 和 let 提升性能
ES6 增加这两个关键字不仅有助于改善代码风格,而且有助于改进垃圾回收的过程。因为 const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。
4.2 解除引用
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。
例 1:一些常用的库会引用全局作用域中的对象,应该在不需要时手动进行释放:
import { useEffect } from "react"; import * as echarts from "echarts"; type Props = { option: echarts.EChartsOption; }; export default function Charts({ option }: Props) { const chartRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!chartRef.current) return; const chart = echarts.init(chartRef.current); chart.setOption(option); return () => chart.dispose(); }, [option]); return <div ref={chartRef} />; }
mobx 中 reaction、when 等方法也会返回 dispose 方法,应该在使用时统一收集,不需要用到时再手动释放掉。
例 2:闭包引用外部变量时,在函数调用后,把外部的引用关系置空。
function outer() { let name = 'Jake'; return function () { return name; }; }; let inner = outer() inner() inner = null
例 3:使用 WeakMap 和 WeakSet 。
const _counter = new WeakMap(); const _action = new WeakMap(); class Countdown { constructor(counter, action) { _counter.set(this, counter); _action.set(this, action); } dec() { let counter = _counter.get(this); if (counter < 1) return; counter--; _counter.set(this, counter); if (counter === 0) { _action.get(this)(); } } } const c = new Countdown(2, () => console.log('DONE')); c.dec() c.dec()
上面代码中,Countdown 类的两个内部属性 _counter 和 _action,是实例的弱引用,所以如果删除实例,它们也就随之消失,不会造成内存泄漏。
author:文杰