垃圾回收机制
认识
GC
即垃圾回收机制,引擎会帮助我们将程序工作过程中的垃圾清理掉,防止内存泄漏等问题
而这里的垃圾,也就是程序不用的内存或者已经用过的内存,以后都不会再用的内存空间
并不是所有的语言都有GC,一般高级语言中会自带 GC,像JAVA、JS等,而向C语言这种,需要我们手动管理内存
垃圾产生
例子:
let test = {
name: 'zsw'
}
test = [1, 2, 3]
在上述这段代码中,test是一个引用数据类型,而JS的应用数据类型保存至堆内存中,然后在栈内存中保存一个对堆内存的实际对象的引用,也就是说,栈内存中保存了一个对象的内存地址
然后,我们将一个数组赋值给test,这会导致之前的对象引用断开,所有断开的这个对象,也就是一个闲置的无用对象,这时候就要对这个对象进行清理
官方描述垃圾回收:程序运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存,对于持续运行的服务进程,必须要及时释放内存,否则内存占用越来越高,轻则影响系统性能,重则导致进程崩溃
垃圾回收策略
怎么找到垃圾
通过上面了解了垃圾的产生,可是JS怎么找到垃圾的呢,怎么判断一个对象是否已经闲置没用了?
JS内存管理中有一个概念叫做可达性,就是那些以某种方式可以访问或者可用的值,他们被存储在内存中,所以这类数据不需要被回收;反之,那种不可访问的值则需要回收
JS的垃圾回收机制也就是定期找出不再用到的内存(变量),然后释放其内存,在这里不用实时的方式是因为开销太大
所以这个流程涉及到一些算法:
- 标记清除算法
- 引用计数算法
标记清除算法
这个算法是JS引擎中最常用的
顾名思义,该算法主要分为标记和清除两个阶段,标记阶段即为所有活动对象做上标记,清除阶段就是把没有标记的对象销毁
清除算法过程
- 垃圾收集器在运行时会给内存中的所有变量加上一个标记,假设现在内存中的所有对象都是垃圾,全部标记为
0 - 然后从各个根对象开始遍历,并把不是垃圾的节点改为
1 - 清理所有标记为
0的垃圾,销毁并回收他们所占用的内存空间 - 最后把所有内存中的对象标记为
0,等待下一轮垃圾回收
优点
清除算法过程的优点只有一个,就是实现比较简单,做标记也就只有0和1两种,可以只用一个二进制位进行标记
缺点
标记清除算法最大的缺点:会产生很多内存碎片,类似于操作系统的空闲分区分块产生内部碎片
所以现在如果我们要新建一个对象分配大小为size的空间,我们就要按照内存分配算法进行分配:
- 首次适应算法:找到第一个大小合适的分块,综合性能最好
- 最佳适应算法:找到能满足
size的最小的分块,还需要排序比较出最小的分块- 最坏适应算法:找到最大的能满足
siae的分块,并进行切块,采用这种方法看似合理,但是会产生更多难以利用的碎片
总结两个缺点:
- 内存碎片化
- 分配速度慢:操作时间复杂度为
O(n)
优化
在每一次标记结束之后,都将不需要清理的对象移向内存的一端,最后再清理掉边界的内存,类似于紧凑技术
引用计数算法
这个算法的把对象是否不在需要定义为对象有没有其他对象引用到他,如果没有对象引用他,则被垃圾回收机制回收
实现策略是跟踪记录每个变量值被使用的次数
实现算法过程
- 当声明一个变量并且将一个引用型赋值给该变量的时候这个值的引用次数就变成
1 - 如果同一个值又赋给了其他变量,则引用数加
1 - 如果这个变量的值被其他值覆盖了,则引用数减
1 - 当这个值的引用次数变成
0的时候,就说明没有变量在使用,则可以回收这个变量,垃圾回收器会在运行的时候清理掉引用次数为0的变量
存在问题
如果存在循环引用的话,就会造成内存释放不了
function test(){
let A = new Object()
let B = new Object()
A.b = B
B.a = A
}
现在两个对象都存在两个引用,并且互相引用着,在函数test执行完毕之后,对象A和B应该被回收,但是由于引用计数,他们不会被清理,因为他们的引用数量不会变成0,所以现在如果这个函数在程序中被多次调用,那么就会造成大量的内存不会被释放
但是如果是标记清除,函数执行完后,两个对象都不在作用域中,所以A和B会被当作非活动对象清除掉,相比之下引用技术不会被释放,这样就会造成大量无用内存占用着
优点
可以立即回收垃圾,不用向标记清除算法一样每隔一段时间执行一次,引用计数可以在数据变成垃圾的那一刻立马回收
标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数就只需要在引用时计数就可以了
缺点
需要一个计数器,这个计数器会占用很大的位置,因为我们不知道引用数量的上限
无法解决循环引用的问题
V8对GC的优化
分代式垃圾回收
V8引擎中,使用的是标记清除算法对垃圾进行清理
所以在每次垃圾回收的时候都要检查内存中所有的对象,这样对于一些大、老、存活时间长的对象来说和新、小、存活时间短的对象的检查频率不好,因为前者需要时间长并且不需要频繁清理,而后者反之
所以这时就要使用分代式了
新老生代
基于分代式垃圾回收机制,V8将堆内存分为新生代和老生代两区域,不同的垃圾回收器使用不同的策略管理垃圾回收
新生代:存活时间较短的对象的存放位置,通常只支持1-8M
老生代:存活时间比较长的对象或者常驻内存的对象的存放位置,也就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大
新生代垃圾回收
通过scavenge算法进行垃圾回收,再其具体实现中,使用了一种复制式的方法即cheney算法
cheney算法:将堆中的内存一分为二,一个是使用区,一个是空闲区
垃圾回收过程:
新加入的对象会存放到使用区,使用区快写满的时候就要执行一次垃圾清理操作
开始垃圾回收时,新生代垃圾回收器会对使用区的活动对象标记,标记完成后将使用区的活动对象复制到空闲区并进行排序(防止碎片) ,随后进入垃圾清理阶段,将非活动对象占用的空间清理掉(觉得是所有对象) ,最后进行角色互换,把原来的使用区变成空闲区,原来的空闲区变成使用区
转变成老生代的对象:
- 一个对象多次复制后依然存活,那么他就会被认为是生命周期较长的对象,随后就又会被移动到老生代中,采用老生代的垃圾回收机制进行管理
- 复制一个对象到空闲区时,空闲区空间占用超过了
25%,那么就会直接晋升到老生代空间中
老生代垃圾回收
老生代垃圾回收流程采用的就是标记清除算法
从一组根元素开始,遍历这组根元素,遍历过程中能到达的元素称为活动对象,不能到达的元素则为非活动对象,再将非活动对象直接清理掉
通过我们对标记清除算法的解析,知道了他的缺点就是尝试大量不连续的碎片,所以V8对其进行了优化,采用了标记整理算法来解决这个问题
使用分代式的好处
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理
一些大、老、存活时间长的对象作为老生代,使其很少接受检查
新老生代的回收机制及频率不同,可以说此机制的出现很大程度提高了垃圾回收机制的效率
并行回收
全停顿:JS是一门单线程的语言,运行在主线程上,在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,这种行为叫做全停顿
并行回收机制:指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作
新生代采用并行策略:在执行垃圾回收的过程中,会启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针,此即并行回收
增量标记与懒性清理
增量标记
并行策略可以增加垃圾回收的效率,但是本质上还是一种全停顿式的垃圾回收方式,对于老生代来说,内部存放的都是一些比较大的对象,这么大的对象使用并行策略可能会消耗大量时间
V8在2011年开始,对老生代的标记进行了优化,从全停顿标记切换成增量标记
增量
增量就是将GC标记的过程分成很多小步,执行完一小步就让应用逻辑执行一会儿,交替多次执行后完成一轮GC标记
存在问题:
- 将一次完整的
GC标记分次执行,那在每一小次GC标记执行完之后如何暂停下来去执行任务程序,而后又怎么恢复呢? - 那假如我们在一次完整的
GC标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了又怎么办呢?
三色标记法
这个方法是用来解决上述第一个问题的
标记清理算法中,只需要使用黑色和白色标记数据即可,其标记流程即在执行一次完整的 GC 标记前,垃圾回收器会将所有的数据置为白色,然后垃圾回收器在会从一组跟对象出发,将所有能访问到的数据标记为黑色,遍历结束之后,标记为黑色的数据对象就是活动对象,剩余的白色数据对象也就是待清理的垃圾对象
如果采用非黑即白的标记策略,那在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段 JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了
三色标记法的解释:
- 白色:未被标记的对象
- 灰色:自身被标记,成员变量(该对象的引用对象)未被标记
- 黑色:自身和成员变量皆被标记
三色标记法过程:
如上图所示,我们用最简单的表达方式来解释这一过程,最初所有的对象都是白色,意味着回收器没有标记它们,从一组根对象开始,先将这组根对象标记为灰色并推入到标记工作表中,当回收器从标记工作表中弹出对象并访问它的引用对象时,将其自身由灰色转变成黑色,并将自身的下一个引用对象转为灰色,直到没有可标记的灰色对象时,也就是没有可达对象时,剩下的白色对象都是无法到达的,即等待回收
现在的恢复执行过程:
可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以
优点:
三色标记法的 mark 操作可以渐进执行的而不需每次都扫描整个内存空间,可以很好的配合增量回收进行暂停恢复的一些操作,从而减少 全停顿 的时间
写屏障
解决上面第二个问题:一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用
假如我们有 A、B、C 三个对象依次引用,在第一次增量分段中全部标记为黑色(活动对象),而后暂停开始执行应用程序也就是 JavaScript 脚本,在脚本中我们将对象 B 的指向由对象 C 改为了对象 D ,接着恢复执行下一次增量分段
这时其实对象 C 已经无引用关系了,但是目前它是黑色(代表活动对象)此一整轮 GC 是不会清理 C 的,不过我们可以不考虑这个,因为就算此轮不清理等下一轮 GC 也会清理,这对我们程序运行并没有太大影响
我们再看新的对象 D 是初始的白色,按照我们上面所说,已经没有灰色对象了,也就是全部标记完毕接下来要进行清理了,新修改的白色对象 D 将在次轮 GC 的清理阶段被回收,还有引用关系就被回收,后面我们程序里可能还会用到对象 D 呢,这肯定是不对的
为了解决这个问题,V8 增量回收使用写屏障机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性
那在我们上图的例子中,将对象 B 的指向由对象 C 改为对象 D 后,白色对象 D 会被强制改为灰色
懒性清理
增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理
增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记
增量标记与懒性清理的优缺点
优点:使得主线程的停顿时间大大减少,让用户与浏览器交互过程变得更加流畅
缺点:由于每个小的增量之间执行了JS代码,堆中的对象指针可能发生了变化,需要使用写屏障计数来记录引用关系的变化,所以主要的缺点有:没有减少主线程的总暂停时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量
并发回收
它指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起
回顾
V8到底怎么对垃圾回收机制进行优化呢,上述讲了许多种策略
其实在新生代中,v8使用并行回收去提高回收效率
而在老生代中,V8则是采用各个策略混合的方式:
- 主要使用并发标记,主线程在开始执行JS时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成)
- 标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作)
- 同时,清理的任务会采用增量的方式分批在各个 JS 任务之间执行
内存泄漏:不再用到的内存,没有及时回收