垃圾回收机制(GC)
回收原因以及垃圾如何产生
回收原因是为了避免内存泄漏,那么什么会导致内存泄漏呢?严格来说,只要某部分内存不再使用,但它也没有被释放,就会导致内存泄漏
这里垃圾指的也是我们不再使用但仍然占据内存空间的那部分数据
let data = {
prop: 12345
};
data = [1,2,3,4,5]
上面这部分代码就是很典型的制造垃圾的方式,原本data所指向的保存在堆内存中的数据,在data被重新赋值后,便成为了“垃圾”。这种类似的对象堆积起来不清理,会造成内存的浪费甚至导致系统崩溃,所以就需要被清理(回收)。
回收策略
可达性:在JavaScript内存管理的方案中,有它自己判断数据是否为垃圾的依据,即“可达性”。简单地说,“可达性” 值就是那些以某种方式可访问或可用的值,它们被保证存储在内存中。主要由两部分组成
根
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
- 全局变量
- JavaScript内置对象
根的引用
如果引用或引用链可以从根访问任何其他值,则认为该值是可达的。
所以如果有一块内容既不是根,也不是根的相关引用,那么他就会被视为“垃圾”,被视为“垃圾”的对象会在之后被JavaScript的回收引擎清除掉。
具体策略
- 标记清除法(Mark-Sweep)
- 引用计数法(Reference Counting)
标记清除法
在标记阶段,从根对象开始进行遍历,对从根对象可以访问到的对象都打上一个标识,将其记录为可达对象。
而在清除阶段,堆内存(heap memory)从头到尾进行线性的遍历,如果发现某个对象没有标记为可达对象,则就将其回收。
优点:标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单。
缺点:标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了内存碎片,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配
那如何找到合适的块呢?我们可以采取下面三种分配策略
First-fit,找到大于等于size的块立即返回Best-fit,遍历整个空闲列表,返回大于等于size的最小分块Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分size大小,并将该部分返回
这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fit 和 Best-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择
综上所述,标记清除算法或者说策略就有两个很明显的缺点
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
- 分配速度慢,因为即便是使用
First-fit策略,其操作仍是一个O(n)的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢
引用计数法
引用计数法把对象是否不再需要简化定义为对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多。
它的策略是跟踪记录每个变量值被使用的次数
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
- 如果同一个值又被赋给另一个变量,那么引用数加 1
- 如果该变量的值被其他的值覆盖了,则引用次数减 1
一旦引用数变为 0, 该对象空间就会被认为是垃圾,然后被回收。
这种垃圾回收方式也很简单,但有一个致命的问题,即循环引用
function demo(){
let A = new Object()
let B = new Object()
A.prop = B
B.prop = A
}
如上所示,对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 demo 执行完成之后,对象 A 和 B 应该被清理掉,但由于引用计数的机制则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放。而使用标记清楚法,当函数执行完毕后,两个对象都不在作用域中,所以会被视为“不可达”,最后会被清理掉,这也是后来放弃引用计数,使用标记清除的原因之一。
在 IE8 以及更早版本的 IE 中,BOM 和 DOM 对象并非是原生 JavaScript 对象,它是由 C++ 实现的 组件对象模型对象(COM,Component Object Model),而 COM 对象使用 引用计数算法来实现垃圾回收,所以即使浏览器使用的是标记清除算法,只要涉及到 COM 对象的循环引用,就还是无法被回收掉,就比如两个互相引用的 DOM 对象等等,而想要解决循环引用,需要将引用地址置为 null 来切断变量与之前引用值的关系,如下:
// COM对象
let ele = document.getElementById("xxx")
let obj = new Object()
// 造成循环引用
obj.ele = ele
ele.obj = obj
// 切断引用关系
obj.ele = null
ele.obj = null
//不过在 IE9 及以后的 BOM 与 DOM 对象都改成了 JavaScript 对象,也就避免了上面的问题
优点:引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾
而标记清除算法需要每隔一段时间进行一次,那在应用程序(JavaScript脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了
缺点:需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题。