介绍背景
我们知道垃圾回收机制是引擎来做的,JS引擎有很多种(各个浏览器都不同),其垃圾回收机制在一些细节及优化上略有不同,==本文我们以一些通用的回收算法作为切入,再由 V8 引擎发展至今对该机制的优化为例==,一步一步深入来助我们了解垃圾回收机制.
主要从以下几个问题进行分析理解:
什么是垃圾回收机制?
垃圾回收机制简称 GC(garbage collection), 我们的程序在工作中会产生一些垃圾,这些垃圾是程序中不用的或者是使用过的,在程序完成后,或者进行中,GC会负责进行回收垃圾,这个过程就是垃圾回收机制。
当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要程序猿手动管理内存了,相对比较麻烦。
说白了也就是定期找出一些不再用到的内存(变量),然后释放其内存。
垃圾是怎样产生的?
在我们开发中会进行变量声明,这些变量都会在占用内存,这些分配过程不需要我们手动显示的给赋值,这个是V8引擎帮助我们分配的。但是很多时候我们只是进行了声明,未对其进行销毁,内存就没有得到释放,虽然V8引擎他自己有自己的一套回收机制,但是也存在他不能回收的,这部分内容就永远的存在了我们的内容中。这部分内容即是垃圾。
for example
let test = {
name: "六哥霸王色霸气"
};
test = [1,2,3,4,5,6]
引用类型是保存在堆内存中,然后在栈内存中保存一个对堆内存中的实际对象的引用,因此js中对引用数据类型的操作都是对对象的引用的操作,而不是对对象本身进行操作;基本类型是保存在栈中,因此对基本类型的操作就是直接对其操作
我们首先声明了一个变量test,它引用了对象{name:"六哥霸王色霸气"},接着我们把这个变量重新赋值了一个数组对象,也就是该变量引用了一个数组,==那么之前的对象引用关系就没有。也就说之前的值变成了无用的对象,他就需要被处理==。
为什么要进行垃圾回收?
上一节我们介绍了垃圾产生的一种情况,随着无用变量的不断增加,==越来越多的内存被无价值的占用==,那么我们就需要对其进行清理!
程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,那么对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则就会导致进程崩溃。
垃圾回收是怎样进行的?
可达性
在 JavaScript 内存管理中有一个概念叫做 可达性,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。 至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题。
标记清除法(Mark-Sweep)
标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的==大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。==
就像它的名字一样,此算法分为 标记 和 清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
大致过程
优点
标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单
缺点
标记清除算法有一个很大的缺点,==就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的==,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)
新对象的内存分配采用以下三种策略:
First-fit找到大于等于size的块立即返回Best-fit遍历整个空闲列表,返回大于等于size的最小分块Worst-fit遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分size大小,并将该部分返回
第三种看起来挺牛的,不过再考虑分配的速度和效率上 First-fit 才是最明智的选择。因为第三种在实际使用上会造成更多的小块,行程更多更难处理的内存碎片。
综上所诉,内存碎片化和分配速度慢是标记清除算法两个比较明显的缺点。
由标记清除法的缺点进行优化,提出标记整理法(Mark-Compact)
标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,==标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动==,最后清理掉边界的内存。
引用计数法(Reference Counting)
引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 ==对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收==。所以我们只对它的策略进行大概了解即可。
function test(){
let A = new Object()
let B = new Object()
A.b = B
B.a = A
}
对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 test 执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放。
优点
引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。相比于标记清除算法,更加可以体现 即时性。
缺点
它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。
V8引擎对垃圾回收进行了哪些优化
内存对象分析
- 新、小、存活时间短
- 老、大、存活时间长
如果从这点出发,对两种类型的对象进行 ==不同方式不同频率== 的处理,是不是更好呢?所以V8提出了 分代式垃圾回收
将 新、小、存活时间短 称为 新生代(新产生的对象)
将 老、大、存活时间长 称为 老生代(存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象)
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。
新生代垃圾回收
在新生代中又细分为 使用区 和 空闲区
-
新加入的对象都会存放到
使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。 -
然后开始对使用区做标记,标记后复制一份活动对象到
空闲区(这里做了整理的操作,也就是排序,避免内存碎片) -
再然后清除
使用区数据对象,把原来的使用区改称空闲区,把原来的空闲区改成使用区,这样的话新使用区就是空的,继续存数据,当快存满了开始下一轮GC -
再看第二轮GC,还是重复上面的步骤,先标记,再把活动对象从
使用区复制到空闲区,这个时候假如发现了上次就存在的对象这次还是活动对象,那这个对象就会被晋级,扔到老生代里去。 -
接着说复制之后,
使用区又被清空了,并且再次和空闲区转换,那每一轮GC过后,使用区就会变成空的
需要注意的是:
-
当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。
-
另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了
25%,那么这个对象会被直接晋升到老生代空间中。
老生代垃圾回收
老生代不会像新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的==标记清除算法==了。
首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。
前面我们也提过,==标记清除算法==在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的==标记整理算法==来解决这一问题来优化空间。
垃圾回收策略
在介绍并行之前,我们先要了解一个概念 全停顿(Stop-The-World),我们都知道 JavaScript ==是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行==,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿
所以每次进行GC,都会是我们的应用逻辑暂停,加入一次GC的时间过长,那对用户来讲就是可能造成页面卡顿的问题!
切换到程序这边,那我们可不可以引入多个辅助线程来同时处理,
这样是不是就会加速垃圾回收的执行速度呢?因此 V8 团队引入了 并行回收机制
所谓并行,也就是同时的意思,它指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作
并行操作,也就是说 同一个活多个人同时干 这样会大大减小GC耗时。
新生代对象空间就采用并行策略,在执行垃圾回收的过程中,==会启动了多个线程来负责新生代中的垃圾清理操作==,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针。
对于新生代垃圾回收器能够有很好的优化,但是其实它还是一种全停顿式的垃圾回收方式, 对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间!
所以为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到 增量标记
增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记
增量标记中采用了 三色标记法 及 写屏障 的方法来实现。
三色标记法
作用:==可以很好的配合增量回收进行暂停恢复的一些操作==
即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑
- 白色指的是未被标记的对象
- 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
- 黑色指自身和成员变量皆被标记
写屏障
作用:保证增量标记中暂停后由于逻辑代码修改导致对象指向问题。从而保证下一次增量 GC 标记阶段可以正确标记
懒性清理
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)
增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。
并发回收
它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起
V8中GC优化总结
V8 的垃圾回收策略主要基于分代式垃圾回收机制
这我们说过,关于新生代垃圾回收器,我们说使用并行回收可以很好的增加垃圾回收的效率,
那老生代垃圾回收器用的哪个策略呢?我上面说了并行回收、增量标记与惰性清理、并发回收这几种回收方式来提高效率、优化体验,看着一个比一个好,那老生代垃圾回收器到底用的哪个策略?
其实,这三种方式各有优缺点,所以在老生代垃圾回收器中这几种策略都是融合使用的。
老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成) 标记完成之后,再执行并行清理操作。(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行。
作者:E2coJason 邮箱:keepfightingzzz@163.com