JS的垃圾回收机制
垃圾的产生和回收
写代码时,创建的任何基本类型、对象、函数等等都是需要占用内存的,这些都是引擎去分配的,不需要我们显示手动的去分配内存
javascript
的引用数据类型是保存在堆内存中的,然后在栈内存中保存的一个对堆内存中实际对象的引用,所以javascript
中对引用数据类型的操作,都是操作对象的引用而不是实际的对象,可以理解为,栈内存中保存了一个地址,这个地址和堆内存中的实际值是相关联的
let test = {
a:"one"
};
test = ['two']
垃圾回收策略
标记清除算法
就像它的名字一样,此算法分为 标记
和 清除
两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁
引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 根
对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象
、文档DOM树
等
整个标记清除算法大致过程就像下面这样
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1
- 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
- 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收
优点
标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单
缺点
标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片
,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题
假设我们新建对象分配内存时需要大小为 size
,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size
的块才能为其分配
归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了
而 标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)
引用记数法
引用计数,是早先的一种垃圾回收算法,它把 对象是否不在需要
简化定义为 对象有没有其他的对象引用到它
如果没有,对象将被垃圾回收机制回收,目前很少使用此算法
它的策略是跟踪每个变量值被使用的次数
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
- 如果同一个值又被赋给另一个变量,那么引用数加 1
- 如果该变量的值被其他的值覆盖了,则引用次数减 1
- 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存
let a = new Object() // 此对象的引用计数为 1(a引用)
let b = a // 此对象的引用计数是 2(a,b引用)
a = null // 此对象的引用计数为 1(b引用)
b = null // 此对象的引用计数为 0(无引用)
... // GC 回收此对象
但此算法会出现一个问题:循环引用
function test(){
let A = new Object()
let B = new Object()
A.b = B
B.a = A
}
如上所示,对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 test
执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放
再用标记清除的角度看一下,当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一
在 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
对象,也就避免了上面的问题此处参考 JavaScript高级程序设计 第四版 4.3.2 小节
V8对垃圾回收的优化
现在大多数浏览器都是基于标记清除算法,V8 亦是,当然 V8 肯定也对其进行了一些优化加工处理
新老生代
V8的垃圾回收策略主要基于分代式垃圾回收机制,V8中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器就是不同的策略管理垃圾回收
新生代
https://juejin.cn/post/6981588276356317214#heading-9
新生代对象是通过一个名为
Scavenge
的算法进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法
新生代采用的算法中,将堆内存的新生代一分为二,一个是使用区
,一个是空闲区
新加入的对象会存放到使用区,当使用区快被写满时,就执行一次垃圾清理
当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成后,将活动区的对象复制到空闲区,并进行排序,虽后进入垃圾清理阶段(即将非活对象占用的空间清理掉),然后将使用区
和空闲区
进行角色互换
当一个对象经过多次复制后依然存活,它将被认为是生命周期较长的对象,会被移动到老生代中,采用老生代的策略进行管理
另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge
回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配
老生代
相比于新生代,老生代的垃圾回收就比较容易理解了,上面说过,对于大多数占用空间大、存活时间长的对象会被分配到老生代里,因为老生代中的对象通常比较大,如果再如新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的标记清除算法了
首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉
前面提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了我们上文中说的标记整理算法来解决这一问题来优化空间
为什么需要分代式
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率