垃圾回收策略
垃圾回收有手动回收和自动回收两种策略。
手动回收需要自己控制内存的分配和销毁,如果分配了内存在使用结束后没有进行销毁,会造成内存泄漏。
而 JavaScript 采用的是另外一种策略即通过垃圾回收器自动回收的机制。
因为 JavaScript 中的数据存储在栈(原始数据类型)和堆(引用数据类型)中,所以它的垃圾回收也包含栈中的垃圾回收和堆中的垃圾回收两种。
栈中的垃圾回收
通过下面的一段代码,我们可以先看下调用栈中的垃圾是如何回收的。
function fn() {
let num1 = 1;
let obj1 = {test: "haha"};
function fn2() {
let num2 = 2;
let obj2 = {test: "xixi"};
}
fn2();
}
fn();
执行到 fn2 时,此时调用栈和堆的状态如下图所示
从图中可以看出,原始类型的数据是分配在栈中的,对象这些引用类型的数据是分配在堆中的。
当 fn2 执行完的时候,fn2 所对应的栈中的数据就会被销毁掉,它是如何被销毁的呢?
在栈中有一个记录当前执行状态的指针 ESP,它指向当前执行函数的上下文,当 fn2 执行完的时候,这个指针就会下移到 fn 的执行上下文,这个下移的操作就是销毁 fn2 执行上下文的过程。
如果之后还有别的函数执行,那么该函数的执行上下文就会直接覆盖在原来 fn2 执行上下文的地方。
即当一个函数执行结束之后,JS 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。
堆中的垃圾回收
当栈中的数据被回收掉之后,接下来我们看看堆中的数据是如何进行回收的。
要回收堆中的垃圾数据,就需要用到 JavaScript 的垃圾回收器。
V8 引擎把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的数据,老生代中存放的是生存时间长的数据。
新生区出于效率的考虑,一般比较小,只有 1-8 M 的容量,而老生区容量则大很多。这两个区域使用的垃圾回收器是不同的。新生代的垃圾是通过副垃圾回收器回收的,而老生代的垃圾是通过主垃圾回收器进行回收的。
垃圾回收器的工作流程
垃圾回收器的工作流程其实都是差不多的。
第一步是先对空间中的对象进行标记,将空间中的对象分为活动对象和非活动对象,非活动对象就是可以进行垃圾回收的对象。
第二步是回收非活动对象所占据的内存。就是在所有的标记都完成之后,对内存中所有标记为非活动对象进行清理。
第三步是进行内存整理。垃圾回收后,内存中可能会出现不连续的空间,如果之后需要分配较大的连续内存,那么这些不连续的空间就可能导致分配不了,所以我们需要对内存进行整理。不过内存整理这一步不是一定需要的,因为有些垃圾回收器在进行回收的时候不会产生内存碎片,例如我们下面要说到的副垃圾回收器。
副垃圾回收器
副垃圾回收器负责新生区的垃圾回收。除了占用内存比较大的对象,一般的对象都是分配到新生区的,所以新生区的垃圾回收相对老生区来说会更频繁。
副垃圾回收器使用 Scavenge 算法进行处理。该算法将新生区的空间进行对半划分,一个作为存储对象的区域,另外一个作为空闲区域。
新加入的对象都会放到对象区域,当对象区域快要满了的时候,就会执行垃圾回收。
副垃圾回收器先对对象区域中的垃圾进行标记,标记完成后就将活动对象有序地复制到空闲区域中,这样在垃圾清理的过程中也相当于完成了内存的整理,复制后的空闲区域就变成对象区域,而原来的对象区域则变成了空闲区域。这样就完成了垃圾的回收。
每当对象区域快满了的时候就会进行垃圾回收,所以对象区域和空闲区域也是不断进行翻转,可以无限重复地使用下去。
那么会有一个问题,新生区的空间不大,如果被装满了怎么办?为了解决这个问题,V8 引擎使用了对象晋升策略,如果一个对象经过两次垃圾回收依然存活着,那么它就会被移动到老生区中。
主垃圾回收器
从上面对副垃圾回收器的介绍中,我们可以看出来,老生区中的对象主要是占用内存比较大的对象和存活时间久的对象,其中占用内存大的对象是直接分配到老生区的。
因为老生区的对象比较大,如果采用和副垃圾回收器一样的回收策略的话,复制的操作需要花费比较长的时间,并且有一半的空间会被浪费掉,所以主垃圾回收器采用的是另外一种算法,标记-整理算法来进行垃圾回收。
首先是从一组根元素开始,递归遍历这组根元素,能遍历到的对象标记为活动对象,没有遍历到的对象标记为需要进行垃圾回收的非活动数据。
标记完之后就将所有活动对象向同一边进行移动,移动完之后就将端边界外的内存都清理掉,这样就完成了内存整理和垃圾回收。
增量标记算法
因为 JavaScript 是单线程的,在执行垃圾回收的时候无法执行其他任务,所以如果垃圾回收时间过长的话那么就会造成明显的卡顿。
因为新生区的空间较小,所以造成的影响也比较小,我们可以忽略不计,而老生区因为其空间比较大,所以垃圾回收的时间可能过长。
为了降低老生区垃圾回收造成的卡顿,V8 将垃圾回收中的标记过程分为一个个子标记过程,同时让垃圾回收标记和 JavaScript 逻辑的交替执行,直到标记阶段完成,这个算法就是增量标记算法。
通过将一个大的任务划分为一个个小的任务,就可以让垃圾回收和 JavaScript 逻辑都正常地进行,同时不影响用户的体验。