前言
垃圾回收是所有语言避免不了的问题,但在JS的日常开发中却很少被提及,关键在于垃圾回收分为两大阵营,手动回收和自动回收,前者代表有 C/C++ ,通过mallco和free实现控制何时分配内存、何时销毁内存;而Java、Python 等语言,产生的垃圾数据是由垃圾回收器来释放的;我们今天的主角--JavaScript属于后者;
那有了自动回收机制,就不再需要考虑内存使用的问题了吗?答案显然是否定的,关键在于另一个我们常见的概念 -- 内存泄漏;轻度的如闭包内存泄漏拉低性能,重度的递归调用栈溢出内存泄漏导致页面卡死,相信大家都见过如下报错
这些都告诉我们一件事:西门子全自动洗衣机是干不过老妈手洗的;但我们没有老妈那么勤快,回到手动回收时代要一直考虑垃圾回收太麻烦,对开发者本身素质要求很高,这就体现了解垃圾回收机制的重要性了,起码做到避免垃圾代码、出问题有思路排查。
思路
要垃圾回收,其实就是对程序运行占用空间中某部分存储空间的清零,这个操作关键在于两步
- 判定哪片空间是需清理的”垃圾“:程序运行空间分布在堆和栈中,如果存储在这上面的数据还有可能会被使用,结果垃圾回收器把它清理掉了,这就很离谱;
- 如何清理能达到最高效:清理导致内存碎片、代际假说等问题带来了我们对垃圾回收算法的要求
**代际假说:**类似二八定律,大部分对象很小而且在内存中存在的时间很短,而剩余不死的对象会存活很久。
实现:栈的垃圾回收机制
程序运行空间分布在堆和栈中,堆上垃圾回收经常听到,而栈的垃圾回收却很少有人听过,因为在这方面目前的自动垃圾回收机制很少会出现问题,不过在此也科普一下。
栈中主要存放的是执行上下文,包含全局上下文、函数调用上下文和eval上下文;其中存在一个ESP指针,用于记录当前执行状态,随着执行上下文的执行完毕,ESP执行就会下移从而销毁上一个结束的执行上下文,从而达到栈中的垃圾回收
实现:堆的垃圾回收机制
堆的垃圾回收就复杂多了,关键就是根据代际假说,我们就不能简单的将两类对象一起处理了,这就引出了我们最核心的算法--分代收集
分代收集
在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。
- 副垃圾回收器,主要负责新生代的垃圾回收。
- 主垃圾回收器,主要负责老生代的垃圾回收。
垃圾回收器的工作流程
垃圾回收器的工作流程是相同的,只是在具体处理时会根据特点进行特定处理,我们先来宏观看下整体的流程
- 对所有对象进行标记,标记分为活动对象和非活动对象(垃圾回收的目标)
- 执行清理算法,清理非活动对象的占据内存
- 内存整理(可选,如果清理算法会导致内存碎片就需要)
第一阶段:标记阶段
最开始使用的是引用计数法,即每个对象都存在一个引用计数器,默认为0,被引用时数字+1;垃圾回收时如果计数器值不为0说明有被引用,标记为活动对象。
但此算法存在计数器空间损耗等问题,最关键是会出现循环引用问题,即两对象互相引用,所以采用了可达性算法,即从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素说明可达,称为活动对象,没有到达的元素就可以判断为垃圾数据。
此外,引用计数法并非毫无作用,这可以通过强、弱引用计数结合方式解决引用计数的循环引用问题,具体为【若 A 强引用了 B,那 B 引用 A 时就需使用弱引用,当判断是否为无用对象时仅考虑强引用计数是否为 0,不关心弱引用计数的数量】,实际上 Android 的**「智能指针」**就是这么实现的。
第三部分也好理解,那么在v8垃圾回收机制中的分代收集关键点就在于不同代间清理算法的不同,我们依次看下
第二阶段清理算法:老生代的清理算法
在老生代执行清理算法的是主垃圾回收器,老生代的存储对象来源主要是两个:新生代晋升对象和占用空间大对象,特点在于对象存活时间长、占用空间大。
主垃圾回收器的清理算法最开始是标记 - 清除(Mark-Sweep)算法,而后面向内存碎片问题优化成了标记 - 整理(Mark-Compact)算法,先了解下最开始的算法;
其实很简单,就是对第一部分标记阶段标注的非活动对象所占据空间进行清空,然后在进行第三部分的内存整理动作,将产生的不连续内存碎片整理成连续的片段从而释放空间就可以了。
而标记 - 整理(Mark-Compact)算法则针对内存碎片需要整理这一问题进行了优化,在第二步清理算法中不再对不活动对象进行处理,而是改为让存活对象向内存区的一端进行移动,完成后直接清理掉端边界外的内存,这样就一步实现了清理和整理的动作
第二阶段清理算法:新生代的清理算法
在新生代执行清理算法的是副垃圾回收器,新生代存储的多数对象小而且存在时间短,特点在于存在对象晋升机制,存活时间长的对象需要移动至老生代
如果直接标记 - 整理(Mark-Compact)算法,活动对象移动至一端会出现难以确定晋升对象的问题,基于此,v8提出了新的Scavenge 算法进行处理,本质就是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域
对象开始存储在”对象区域“,然后清理时,做如下动作
- 标记 - 整理(Mark-Compact)算法中移动到端的动作改为移动至空闲区域
- 完成后清理端外垃圾数据的动作改为直接反转空闲区域和对象区域
这样就完成了一轮清理,这种算法较之之前有两点好吃
- 新生区的两块空间可以无限使用
- 新生区对象是否晋升可以通过标记存活反转次数进行判断,一般为两次就晋升
但老生区不行,因为老生区存储对象所占空间较大,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间
全停顿和增量标记
由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,这就是全停顿。
面对这个问题,v8将垃圾回收的第一个阶段--标记阶段抽离出来分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,再进行清理和整理的动作,这就是增量标记(Incremental Marking)算法
总结:前端八股文
至此我们就完成了对V8中垃圾回收机制的学习,进行一下总结吧!
垃圾回收机制是指对程序运行所占空间的整理规则,不同语言有不同的实现,主要分为两类,手动回收,如c/c++里通过free和mallco控制;自动回收如java、Python以及我们的JS。
以JS为例,程序运行空间分布在堆和栈中。
关于堆上的垃圾回收,我们得先了解一个概念,代际假说;类似二八定律,大部分对象很小而且在内存中存在的时间很短,而剩余不死的对象会存活很久。
基于此我们将堆分为两个空间--新生代和老生代,前者存放小和存活时间不长的对象;后者存放大或者存活很久的对象;他们分别有着自己的垃圾处理器,前者是副垃圾处理器,后者是主垃圾处理器,我们说的垃圾回收关键就是看这些处理器做了什么;
垃圾处理器的工作流程是一致的,只是在清理阶段采用的算法不同,分为三步
- 标记阶段:
- 最开始使用的是引用计数法,即每个对象都存在一个引用计数器,默认为0,被引用时数字+1;垃圾回收时如果计数器值不为0说明有被引用,标记为活动对象,但此算法存在计数器空间损耗等问题,最关键是会出现循环引用问题,即两对象互相引用
- 所以采用了可达性算法,即从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素说明可达,称为活动对象,没有到达的元素就可以判断为垃圾数据。
- 此外,引用计数法并非毫无作用,这可以通过强、弱引用计数结合方式解决引用计数的循环引用问题,具体为【若 A 强引用了 B,那 B 引用 A 时就需使用弱引用,当判断是否为无用对象时仅考虑强引用计数是否为 0,不关心弱引用计数的数量】,实际上 Android 的**「智能指针」**就是这么实现的。
- 清理阶段:
- 这个在老生代中最开始采用的是标记清除法,即直接清理掉第一阶段标记出来的不活动对象;而后面对内存碎片问题优化为标记整理算法,即不对不活动对象处理,而是对活动对象进行移动至端的操作,然后对端外对象进行清理;
- 新生代中采用的则是
Scavenge算法,即将新生代空间对半分为对象区和空闲区,标注后的活动对象移至空闲区域,然后进行区域反转,清空空闲区域实现清除。
- 整理阶段:本质就是清理内存碎片,这是可选的,在副垃圾处理器中是没有这个阶段的,因为
Scavenge算法的反转操作不会导致内存碎片
栈上的垃圾回收就比较简单了,面向的是执行上下文的回收,包含全局上下文、函数调用上下文和eval上下文;其中存在一个ESP指针,用于记录当前执行状态,随着执行上下文的执行完毕,ESP执行就会下移从而销毁上一个结束的执行上下文,从而达到栈中的垃圾回收
此外,由于 JavaScript 是运行在主线程之上的,垃圾回收可能会导致全停顿现象,即一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,造成用户感官上的卡顿,所以v8对标记算法进行了优化,采用增量标记(Incremental Marking)算法实现标记,即将标记阶段抽离出来分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成。