0.何为垃圾回收
看下面例子:
let test = { name: "codereasy" };
test = [1,2,3,4,5]
在JavaScript中,引用类型保存在堆内存中,然后在栈内存中保存一个对堆内存中实际对象的引用。在上述例子中,我们声明了一个变量 test
,它引用了对象 { name: "codereasy" }
接着我们给这个变量重新赋值了 一个数组,于是 test 指向了数组 [1,2,3,4,5],这就导致 test 不再指向之前的对象了。 没有了引用关系, { name: "codereasy" } 也就变成了无用的对象,所以就需要被清理(回收)。
1.引用计数法
跟踪记录每个对象的引用次数,当一个对象的引用次数为0时,说明这个对象没有被其他对象引用,就可以被安全地回收了。
let object1 = {name: 'Object 1'};
let object2 = {name: 'Object 2'};
let object3 = {name: 'Object 3'};
object1.otherObject = object2;
object2.otherObject = object3;
在这个例子中,我们创建了三个对象:
{name: 'Object 1'}
{name: 'Object 2'}
{name: 'Object 3'}
在引用计数的角度来看,{name: 'Object 1'} 的引用次数为1(只有 let object1 引用了它),{name: 'Object 2'} 的引用次数为2(首先,它被 let object2 引用,其次,它被object1.otherObject引用 )
现在,如果我们删除 object1.otherObject 对 {name: 'Object 2'} 引用
object1.otherObject = null;
那么 {name: 'Object 2'} 的引用次数变为 1,此时它还不会被销毁。如果继续将 let object2 对它的引用删除:
let object2 = null
{name: 'Object 2'} 就会成为一个无法访问的对象,可以被垃圾回收器回收。
这种方法看起来没问题,但是遇到循环引用的时候就会出错:
let objectA = {name: 'Object A'};
let objectB = {name: 'Object B'};
objectA.otherObject = objectB;
objectB.otherObject = objectA;
// 删除对objectA和objectB的引用
objectA = null;
objectB = null;
即使我们将 objectA 和 objectB 的引用全部删除,它们的引用计数仍然为1(因为它们互相引用),所以垃圾回收器不会将它们回收,导致内存泄漏。
想要进行垃圾回收,必须断开循环引用:
objectA.otherObject = null;
objectB.otherObject = null;
所以无法清楚循环依赖是它的缺点,那么它的优点是什么呢?
引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。
而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC
,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了
2.标记-清除法
此算法分为两个阶段:标记阶段和清除阶段。
在标记阶段,垃圾回收器会遍历所有的对象,对所有存活的对象进行标记。所谓的"存活",就是说在当前的执行上下文中,这个对象仍然有被引用。这种引用可以是直接的,也可以是间接的。
标记阶段的具体做法:从根(root)出发(根是全局对象),遍历所有的可达对象。所谓可达对象,就是说我们通过一些方式能到达这个对象,比如说一个对象作为函数的参数,在函数体内部可以访问到,或者一个对象作为另一个对象的属性,我们可以通过引用链找到它。遍历的过程中,GC会给所有找到的、从根出发可以访问到的对象标记上活动对象(reachable/alive)的标记。
在清除阶段,垃圾回收器会再次遍历所有的对象,对于没有被标记为"存活"的对象,进行回收。
以下是标记的过程:
// 全局变量可以被视为根,因此它们是可达的
let globalVariable = {
name: "I'm a global variable",
};
// 函数的参数和内部变量也是可达的
function someFunction(someArgument) {
let functionVariable = {
name: "I'm a variable inside a function",
};
console.log(someArgument, functionVariable);
}
someFunction(globalVariable);
// 对象的属性也是可达的,因为它们可以通过对象引用链从根访问
let objectA = {
objectProperty: {
name: "I'm a property of an object",
},
};
console.log(objectA.objectProperty);
然后回到引用计数法的出现问题的代码:
let objectA = {name: 'Object A'};
let objectB = {name: 'Object B'};
objectA.otherObject = objectB;
objectB.otherObject = objectA;
在引用计数法中,objectA和objectB都不会被回收。但如果我们使用标记清除法,当我们从根对象开始进行标记时,如果 objectA
和 objectB
无法从根对象访问到(即它们被完全隔离,没有任何从根对象开始的引用链可以到达它们),那么即使 objectA
和 objectB
互相引用,标记清除垃圾回收器也不会给它们打上活动标记。
在清除阶段,因为 objectA
和 objectB
都没有被标记为活动对象,垃圾回收器会认为它们是垃圾,然后回收它们所占用的内存,从而解决了循环引用的问题。
标记清除法的缺点
清除步骤结束之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片
(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。
假设我们现在需要分配一块大小为 size 的内存空间,应该如何寻找它呢?
一般情况下,我们有三种策略:
-
First-fit
,找到大于等于size
的块立即返回 -
Best-fit
,遍历整个空闲列表,返回大于等于size
的最小分块 -
Worst-fit
,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分size
大小,并将该部分返回
不管是哪种算法,都面临以下缺点:
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢,因为即便是使用
First-fit
策略,其操作仍是一个O(n)
的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢