本文章除了引导部分,其他全部来自一篇文章
背景
某个函数在运行时创建了一些变量,并在内存中给这些变量分配了空间,当函数运行完之后,显然需要将这些变量占用的空间给释放掉,而这个操作就是GC(garbage collect),垃圾回收。
举个栗子
let name = 1
let obj = {name:2}
function func1(){
let arr1 = [1]
}
function func2(){
let arr2 = [2]
}
func1()
func2()
GC执行时,js线程会被阻塞
js代码是运行在V8引擎中的,全局js和函数在运行时分别会创建全局上下文和函数上下文,并都会压进一个执行栈中,非引用类的数据就放在上下文,引用类的数据都会在堆中分配内存空间。
比如js运行到了let arr2 = [2]这行代码,但是func2还没结束时,V8触发了第一次GC,那么此刻的状态就是:

GC过程:标记清除法
V8的GC过程分为了三步:
- 判断堆里哪些数据是被引用了的
- 遍历堆,将没被引用的数据清除掉
- 整理内存(看情况)
第一步:判断堆里哪些数据是被引用了的
V8引擎会将遍历全局对象(window)、dom树,运行栈中的上下文,然后又引用堆里的某个数据,那么就会将这个数据打上标记。比如上图的[2]和{name:2}就分别被func2的函数上下文和全局上下文给引用到了,所以会被打上标记;而[1]没有人引用,所以没有标记。
第二步:遍历堆,将没被引用的数据清除掉
遍历堆,将堆中没被标记的数据直接销毁,销毁掉[1]
第三步: 整理内存
销毁掉[1]之后

清除完之后,堆这个数组中的某几位就会空缺,我们需要整理一下,用后面的补上这个空位
通过这种方式,V8就能将堆中没有被引用的数据给清理掉
优化
回忆一下标记清除的三部曲:
- 判断堆里哪些数据是被引用了的
- 遍历堆,将没被引用的数据清除掉
- 整理内存
有没有一种可能,我们能优化GC过程,减少GC时间,这样就能减少阻塞Js线程的时间。
第一步和第二步是没办法了,看看第三步。
如果我们能将堆中的数据分成常驻和短驻的两类,那么每次GC时,常驻那部分就大概率不需要整理内存,这样就能节省时间。
而且对于动态语言,刚被分配内存的数据,往往马上就被清除了,比如最初的例子的func1中,arr1刚被分配内存,然后马上就被GC掉了,即js中函数的上下文(非闭包)大概率刚进入运行栈就执行完被弹出了。
根据这个特性,V8的GC机制将堆分为了两个区,新生代和旧生代,而新生代被一分为二,其中一半作为缓冲,这个缓冲后面解释。

回到最初的例子:js执行到func2中的let arr2 = [2],GC执行到第二步时

新生代的第一个堆中的{name:2}和[2]被标记次数都是1,所以在清除阶段,会被移动到缓冲堆,清除[1]。 清除完之后,完全清空新生代的第一个堆,然后V8会将新生代的第一个堆和第二堆的身份互换,如下图:

GC完毕,继续执行js线程,当执行完func2之后,V8触发了第二次GC的话,此刻运行栈和堆的状态如下:

GC:
- 打标记:因为第一个GC时{name:2}标记为1,而这次GC{name:2}标记次数则为2;[2]没有标记,因为没有被引用
- 清除:{name:2}的标记为2,所以直接越过缓冲堆,直接到旧生代,[2]没标记,被清除
- 整理内存:旧生代不需要,新生代也不需要。
为什么要用缓冲堆
- 如果新生堆中的数据只要在一次GC下存活就放到旧生代的话,那么旧生代里的数据,大概率会在下一次GC时被删除,产生内存碎片,这就需要内存整理了(只是概率大小的问题,多一层缓存,就能很大程度减少旧生代内存整理的频率)
- 新生代多了个缓冲堆后,发现没,每次GC,我们都是直接清空第一个堆,第二个堆不需要干什么,然后两个互换,这样我们就不需要对新生代的两个堆做内存整理了,而只需要做复制第一个堆的数据到第二个堆。
GC触发的时机
- 定时
- 新生代、新生代缓冲区、旧生代的比例达到阈值