你不知道的秘密-js垃圾回收

30 阅读15分钟

垃圾回收

为何要进行垃圾回收

首先,我们摸着自己的良心来问问自己

你写代码的习惯好不好?
有没有实时关注性能?
有没有关注过定义过的变量后期的走向?
变量用完之后去哪了呢?

可能很多人代码写了很多,但是从来没有关心过上面的问题,变量是否用完了,是否需要清除。那么,这么多年,我们写的那么多的变量,好的坏的,我们都没有管,代码(屎山)还是在好好的跑着,难道真的是我们写的代码无懈可击吗?

nonono

没有什么岁月静好,只是有人在帮我们负重前行罢了,这个人究竟是谁呢?

当然js引擎了

实际上,我们在平时写代码的时候,因为变量的更新迭代,随着项目的运行,会有很多新的变量不断被创建,同时也会有很多变量变的没有意义,白白的占用这内存,这种现象要是不处理的话,内存会越占越大,后面轻则代码运行卡顿,重则浏览器直接崩溃。

那么处理运行中的垃圾(无用的变量)就成了非常重要的事,js引擎就一直在背后默默的做这这件事情。

这么说来,我们应该大致找到了js引进行垃圾回收的大致思路了

1、找到无用的变量
2、清除这些变量,释放空间

我们知道,各大浏览器厂商都有这自己特别的地方(特别是你IE),每家都有自己处理垃圾回收的方法,但是也有比较主流的方法,我们接下来继续分析一下常用的垃圾回收的方法。

垃圾回收的常用方法

垃圾回收用的最多的两种方法就是标记清除法引用计数法了。两种方法各有利弊,且听我一一道来。

引用计数法

我们都知道,在js中,除了number、string、boolean、symbol、null、undefined这六种原始值外,其他的变量都属于引用,引用类型的值都是存放在堆内存里面的,然后这个堆内存会有一个地址的引用,我们js中变量存储的便是这个引用。
我们多个变量可以同时存储同一个引用,比如:

const a = new Object()
const b = a

image.png

那么对于这个存储在堆内存中的对象来说,有ab两个变量引用了他,说明这个对象是一个活动对象,也就是说还是有用的。

那么什么样的对象是没用的呢,那当然是没有变量来引用了呗。

image.png

这里的old Object就是没有被变量引用,就属于一个需要回收的对象。

这样看来,引用计数法确实很简单,只要看有没有变量去引用就好了,皆大欢喜,直接推广使用。

but

事情远远没有那么简单

从上面的描述中我们可以看出来,引用计数法的优点有以下几点:

  1. 思想简单,可实现性强
  2. 可以在变量引用的时候就实时的判断出对象是否是活动对象

但是遗憾的是,现在主流的浏览器引擎都已经放弃了这个方法。

为什么呢?

因为引用计数法存在着一个非常致命的问题,就是相互引用的问题,举个例子:

const a = {}
const b = {}
a.b = b
b.a = a

这里可以看到,ab两个对象中分别有一个属性指向对象,我们按照引用计数法的规则来看,a和b都是有引用的,那说明两个对象都是活动对象,但是,如果程序中其他地方没有用到这两个对象的话,我们自己其实很清楚的知道,这两个对象其实都是属于垃圾对象。

所以

这个致命的问题是无法得到解决的,就会存在很大的内存泄露的风险。

当然,除此之外,引用计数法还存在其他的问题,大型项目中是会有很多的变量,需要有一个很大的内存计数器来统计所有变量的引用次数,并且一个对象可以被无数个变量引用,是无法得知这个上限的。

标记清除法

标记清除法,顾名思义就是先标记,然后再清除。那我们就一步步来讲:

标记

标记就是给变量做一个标记,来判断这个变量是不是一个活动对象

标记采用的是遍历的方式,根据一个根节点(这个根节点可以是window对象,document、 函数内部变量等)开始,依次向下访问,如果可以访问的到,则标记。一轮遍历下来,经过标记的对象则是活动对象,反之,则是垃圾对象,需要进行清理。

具体策略:

1、给所有的对象都标记为白色。 2、通过根节点进行遍历 3、如果可以访问的到,则标记为黑色 4、遍历完成

image.png

image.png

清除

清除就非常简单了,就是将上面标记过的非活动对象进行回收。

优缺点

优点: 标记清除法的优点也非常的明显,就是实现比较简单,只需要一个0/1的标记位就可以完成。

缺点: 优点只有一个,但是缺点却非常的多,首先,标记需要遍历,遍历需要时间,清除是在标记之后统一进行清除,也是需要时间,众所周知,js是一个单线程语言,一旦js回收线程启动标记或者回收的话,主线程是需要让出来的,这样就会导致程序中断。 除此之外,还有一个比较严重的问题就是,标记清除法,在标记完成之后,垃圾变量会分布在各个位置,js引擎启动回收后,将这些变量从各个位置进行回收,释放内存,会导致空闲内存不连续,东一个西一个。

image.png

这时候,就非常不利于后面的内存分配了。

虽然说

标记清除法也有不少的缺点,但是目前主流的浏览器都是在使用这个方法,那他们是怎么解决上面的这些问题的呢,接下来我们看看V8引擎是怎么做的吧。

V8引擎针对垃圾回收做的优化

分代式垃圾回收

何为分代式垃圾回收,就是将变量分成新生代和老生代分别进行存储,然后针对不同的生代有不同的算法进行标记回收。

分类

新生代 V8引擎将那些小、短、新的变量定义为新生代,新生代的特点就是周期短,内存存储小、变动频繁

老生代 内存占用大,经历过多次新生代的迭代仍然存活下来的

算法

新生代算法

新生代用的是一种叫做Scavenge的算法,在具体的算法实现过程中,又用了Cheney算法,那么这个算法究竟是怎么用的呢? 首先,将新生代的内存空间一分为二,一部分叫做使用区,一部分叫做空闲区

image.png

首先,有新的变量产生,则将变量放到使用区,当GC开始回收时,会将使用区的变量进行标记,清除掉非活动对象,并且将活动对象复制到空闲区。

image.png

这是其中一轮的算法,在新生代阶段会一直循环反复的进行,那么,一个变量的整个生存周期就一直在新生代么?

当然不是的,变量还有一个过程叫做晋升

晋升,就是变量从新生代过渡到老生代的过程,我们知道了新生代存储的是周期短,内存小的变量,一般这种变量在新生代的一次次循环中就会被回收掉,但是,还是存在一些经历了多次新生代而依然存在的变量,这种变量已经不符合新生代周期短的特点了,这个时候就要将它转移到老生代中。

那么,究竟什么时候做这个晋升呢?

有两个条件:

1. 经历过一个完整的Scavenge算法
2. 空闲区内存是否超过25%

第一个条件,通过内存地址来判断,该变量是否经过了一次回收,第二个条件,是在将活动对象从使用区复制到空闲区之前,进行判断,如果空闲区的内存大于25%,则直接将该活动对象转移到老生代。

新生代的算法是典型的用空间来换取时间的算法(需要牺牲一半的空间),但是由于新生代的特点,这种算法可以做到比较高效的回收。

老生代算法

老生代算法就比较简单了,就是用的上面说的标记清除法。但是我们上面说了,标记清除法是有比较大的缺点的,就是会产生比较多不连续的内存块,非常不利于后期的内存分配,针对这个现象,引擎有了标记整理的方案

标记整理 每次js引擎进行垃圾回收之后,会将活动对象全部移动到最右侧,把左侧的空间全部连续起来,成一个比较大的连续空间,这样以后分配内存就不会受影响,具体的操作如下:

image.png

优化

即使V8针对不同的对象有不同的算法进行回收,并且做了一些优化,但是我们都知道js是一门单线程的语言,程序一直都需要执行,那么我们的GC是什么时候介入回收的呢。

因为js回收是要在程序运行的过程中,程序执行完了再没有那肯定是没有意义的,既然要在程序执行过程中,js又是单线程语言,那么说明GC执行的时候程序是停止的,这个停顿的过程就叫做全停顿

全停顿持续的时间因程序的复杂程度而定,但是这种操作就是会影响到程序的正常运行的,所以针对这样的现象,引擎做了很多的优化,其中,效率比较高的还得是并行

在回收的过程中,启动多个辅助线程同时进行回收,这样就会成倍的提升回收效率,也就很大程度的减小了全停顿的时间。

image.png

增量标记

即使使用了并行回收,但是在老生代中,都是一些比较大的对象,使用并行回收还是会存在比较大的全停顿,针对这种现象,引擎使用了增量标记的方式进行优化。

何为增量标记,首先,这个过程是发生在标记阶段中,增量的意思就是将原本总长度的1标记时间,分成了n个程度为m的小标记时间,这样的话,就缩短了每次进行标记的时间,对于程序执行的影响就会大大的降低。

image.png

这样猛的一听感觉还挺好的,但是仔细一想想就会发现一个比较大的问题,我标记到一半,js程序又继续执行了,要是改了我之前标记好的活动对象,那我下次怎么搞?

想想也是,那我们V8引擎是怎么解决这个问题的呢?

使用的是三色标记法写屏障

三色标记法

前面我们讲过,常规的标记法就只有黑白两种颜色进行区分,白色代表没有非活动对象,黑色代表活动对象。那么三色标记法就需要引入三个颜色来进行区分。

  1. 初始化,将所有的对象都标记成白色
  2. 在遍历的过程中,将遍历到的元素标记,其中分成两种情况
  3. 第一种,该元素没有子元素,则直接标记为黑色
  4. 第二种,该元素有子元素,则将其标记为灰色,代表仅仅遍历了这个元素,还没有遍历完这个元素的子节点
  5. 然后遍历该元素的子节点,方法同上,待这个元素的所有子节点都标记完成之后,将这个阶段标记为黑色。

image.png

这样的话,这次标记完成之后,经过了一轮js程序的执行,再到下次标记的时候,直接从灰色的对象继续标记就好了。

三色标记解决的是两次标记中衔接的问题,可以让下次的标记从上次标记的地方继续标记,但是并不能解决我们上面提到的,在两次标记之间,js程序执行修改了对象的问题,这种问题V8引擎采用的写屏障的方式。

写屏障

写屏障也是在三色标记法的基础上进行的,具体的操作是:在第一轮的标记的基础上,js程序执行时,如果一旦有活动对象发生了变化,则将这个活动对象以及其子对象都标记为灰色,那么在下次标记时,就会将这个对重新遍历一次。

惰性清理

在js程序和垃圾回收之间,其实是有一个优先级的,当垃圾比较多,可能影响性能的时候,GC回收的优先级就会比较高,反之,就可以延迟GC回收,让js程序多跑一会,这种现象就叫做惰性清理

以上就是V8引擎针对GC回收做的一些优化,这些优化,可以让我们在平时写代码的时候,不需要刻意的关注GC回收过程,但是我们平时还是要多多注意自己的编码习惯。

开发中要注意的点

关于垃圾回收要注意的点实际上就是要避免内存泄露

那何为内存泄露,就是有无用的对象,但是GC识别还是活动对象,无法对其进行回收。

内存泄露有以下几种:

全局变量

我们知道在js中,window是属于根节点,GC标记的时候从根节点开始,挂载在根节点上面的对象都是可以访问到的,GC都无法对其进行回收。

使用var来进行变量声明

在es6之前,都是使用var来进行变量声明,但是有一个问题就是,var声明的变量都会被改在到window上。

var a = 123

//相当于

window.a = 123

在函数内部,没有声明直接使用变量

在函数内不声明直接使用变量赋值,也是相当于直接挂载在window对象上

function a () {
    b = 1
}

//相当于

function a() {
    window.b = 1
}

那么针对上面两种情况,我们最好的就是不要这么操作,使用变量都要进行声明,最好是用letconst进项声明,如果创建了window全局对象,在使用结束后,手动置成null,以便GC回收。

手动清除定时器

如果我们在定时器的回调函数中使用了定时器作用域之外的变量的话,在定时器的不断触发中,这个变量就会一直存在,无法进行回收,那么我们在使用完成之后,就需要手动的调用clearInterval进行定时器清除。

还有一点就是,我们不要在定时器中一直对一个对象进行累加操作,这样会随着定时器的不断重复,这个对象占用的内存就会越来越大,内存泄露就会越来越严重,会引起程序崩溃。

闭包

我们都知道闭包是js的专属特性,可以访问到不是本作用域的变量,同样因为这个闭包的引用,会导致这个对象一直无法进行回收。

所以在非必要的情况下少使用闭包,关于闭包,后面专门开一篇文章讲述。

减少对Dom的引用

在平时的开发中,可能有同学喜欢先把Dom原型给存储起来,方便后续的使用,后面这个元素不要了,就会使用removeChild方法将该Dom元素在Dom树上进行清除,但是由于之前已经存储了这个Dom对象,这个Dom虽然不在Dom树上挂载了,但是依然存在内存中没有进行释放,白白的浪费了内存。

const a = {
    list: doenment.getElementById('list')
}

listPar.removeChild(doenment.getElementById('list'))

总结

js的垃圾回收是GC程序在我们背后默默做的一些事情,虽然我们在平时敲代码的过程中不需要关注,但是了解其原理能够让我们更好的了解一下js底层的东西。

除了常规的垃圾回收的方法外,我们还介绍了V8引擎针对垃圾回收专门做的一些优化,这些优化也是我们使用V8引擎能够比较顺畅的原因。

除了GC会自己帮我们清除垃圾外,我们自己也要注意,不能过分的创造垃圾,也就是内存泄露。

好了,这篇文章就到这里了,我们下次见。