1.什么是垃圾回收机制
我们去餐馆吃饭,先是服务员给我们安排位置,然后我们吃完后就起身离去,服务员开始收拾我们之前的位置,然后把它留给下一位消费者。这样这家餐馆就可以让更多的人就餐了。 在JS中代码的执行也是这样每次运行的时候内存就会给代码开辟一片空间,让代码执行,执行完之后就把代码清楚,把这块空间去进行回收,这样就可以给接下来要运行的代码留下可以执行的空间。这也就是我们说的垃圾回收机制GC(Garbage Collection)。
2.JS 内存管理
想要了解垃圾回收机制,我们先要明白JS代码在内存中的使用情况,这个在我们的JS数据类型的初步了解和作用域、作用域链、执行上下文、执行上下文栈、变量对象、活动对象全了解这两部分的内容中都有说到一些。
- 首先是我们执行代码时会系统会分配一块空间,也就是我们全局的执行环境,里面存储的就是一些全局变量,例如:window。
- 然后代码开始执行,当调用一个方法的时候,就进入了函数的执行环境中,变量的定义,变量的引用等在该环境执行完成,然后弹栈,该环境的代码就被清空,内存被回收了。理论上是这么个流程,但具体的情况还是有一些出入的。因为只有当GC开始执行,这些变量才会被清除,内存才会被回收,还有一些特殊的情况我们下面再细说。
3.有哪些垃圾回收机制
知道了基本原理,现在就可以看看我们有哪些不同的算法去执行垃圾回收机制。
1.引用计数(Reference-counting)算法(低版本ie下使用)
引用计数主要依赖的就是引用这一概念,比如对象之间的赋值就是对象的引用。
JavaScript
function fun() {
let arr = [1,2,3]
const arr1 = arr
arr = null
console.log(arr) // null
console.log(arr1) // [1,2,3]
}
fun()
[1,2,3]
后面统一称为数组 如图,当fun
函数执行时,先给arr
赋值,arr
存的是数组的地址,此时数组被引用的次数为1 ,接着arr
赋值给arr1
,arr1
存的不是arr
的值,而是数组的引用,此时数组的被引用次数为2 ,然后arr
再赋值null
。此时虽然arr
的值被设置为null
了,但是数组还是存在的,只是它的引用次数减为了1 ,所以arr1
此时的值还是[1,2,3]
。也正是如此,由于arr1
还在使用这个数组,此时,所以它就不能被回收。
要让它被回收只有两种方式:
fun()
函数执行完,由于数组是创建在该函数下面的,函数执行完成弹栈之后,arr1
被清除,那数组占用的内存自然也被回收了。arr1
也跟着设置为null
这样一来数组的引用就变成了0,此时虽然当fun
函数还在,但是数组也会被回收。
优点:
- 由于会对于每个引用计数,所以当对象的引用次数变为0时可以第一时间就回收内存。
- 由于是对于对象的引用的计数,所以不必管它是活动对象还是非活动对象。
缺点:
- 既然是要对引用计数,那么就会有一个计数器用来计数,而计数器自然要占一部分内存用来记录这些东西的,如果引用的对象很多的话,那么计数器占的内存也会相应变多。
- 无法处理循环引用。上述的例子中数组是直接赋值给了变量,所以当函数执行完成,数组占用的内存就直接被清空了。但是有一种特殊情况:
JavaScript
function fun() {
let obj1= {a:null}
let obj2= {a:null}
obj2.a= obj1
obj1.a= obj2
}
fun()
现在obj1
和obj2
是你中有我,我中有你了,对于这种情况引用计数法是没办法处理的,所以就算函数执行完成,它也会由于这两个对象的相互引用判断他们还是有用的,不能被回收,这样一来这块的内存就会一直被占用了。
2.标记-清除(Mark-Sweep)算法
当函数进入执行上下文,里面的变量对象会变成活动对象,此时会被加上使用中的标记,当函数执行完成,活动对象变成非活动对象,此时标记变则取消标记,当GC开始执行时,不被标记变量就会被清除掉,占用的内存也会释放。
优点:
- 策略简单,只是需要为每个变量打上标记就行。
- 不浪费内存,不需要像引用变量算法一样开辟一个空间用来记录引用数量。
- 解决了循环引用问题。
缺点:
-
它不会像引用变量算法那样第一时间回收内存,所以内存回收是每个一段时间执行一次的,这样会造成部分性能的损耗。
-
而且在执行内存回收的时候需要停止整个应用程序几十ms,对部分用户来说体验会比较差。
-
GC执行完之后是把变量占用的内存回收回来了,但是它并没有整合那段内存,所以会产生很多内存碎片。这样对这些内存进行再分配的时候会有一些问题。
上图可以看出,内存5在回收之后就保持那么大了,如果再定义一个新的变量占用的内存比它大的话是不能使用使用它的,这样为了寻找合适的内存就会一直在里面找,直到找到合适的大小再给变量分配,会影响到一些性能,而且这种内存碎片一点多起来可能会造成一种看起来内存多实际上不够用的情况了。
3.复制(copying)算法
复制算法是将一份内存分成均等的两块,在A内存占用完成之后把还在使用的部分移到内存B中,然后把A给清空出来,等待B占满再移动到A。
优点:
- 效率高。当内存占满,只需要移动仍在使用的内存的指针,然后清理掉之前的那部分的内存。
- 不会产生内存碎片。
缺点:
- 对内存的需求较大,由于是要将内存分成两份的,所以它对内存的需求是标记清除算法的2倍
4. v8引擎的垃圾回收算法(分代回收机制)
v8引擎吸取了上面几种策略算法的经验,将他们垃圾回收的思路结合起来,将要处理的内存垃圾分成新生代和老生代,他们使用了各自不同的策略,使得效率和内存的消耗达到一个相对平衡。
- 新生代(Scavenge)算法 由于在js中我们大部分变量是定义在函数中的,而这些函数在使用之后就直接弹栈了,由于他们来去匆匆,占用的内存也不算太多,所以它们需要高效率的内存回收算法而且还要保证内存的连续性,让新的变量有充足的内存放置。 从这里我们第一时间想到的就是 copying算法,是的Scavenge算法用的也正是这个思想使得这部分的垃圾回收变得效率且规整。
- 老生代(Mark-Sweep 和 Mark-Compact)标记清除和标记整理算法 在内存清理中总会存在某些"钉子户",它们可能是全局变量,可能是生命周期较长的变量,总之每次清理内存都有它们,但是它们又一直被使用,无法回收,这样在进行回收内存时也会造成不必要的损耗。 V8引擎在新生代进行内存回收时会标记它们,如果发现它们两次出现,并且没有被回收,或者它在新生代中占了回收前内存的25%,那么V8引擎就会将它们晋升到老生代。 由于老生代中的变量不会频繁创建和清除,因此使用标记清除算法相对来说是更加经济实惠的,但是由于标记清除算法会造成内存碎片过多从而出现内存的浪费,因此又用上了标记整理,这样在老生代内存被清除之后会把内存碎片给整理好,从而避免了浪费。 增量标记(Incremental Marking) 按理说V8引擎的回收算法到这里就结束了,但是我们知道,老生代中的变量要么是比较老的,要么是比较大的,而且还可能会随着时间推移越来越多,这样由于在执行GC会停止整个应用程序,这样一旦清理的内存比较多,那清理的时间会相对应延长,这样的结果就是用户体验特差。 所以避免这种情况出现,V8引擎运用了增量标记的方式,将要这一次性回收内存分成了很多份,然后分次回收,这样卡顿不会那么严重,用户体验自然就会大大提升。