JavaScript垃圾回收机制

这篇文章围绕JavaScript垃圾回收进行展开,介绍垃圾回收的必要性和标记清除引用计数两种垃圾回收机制。

GC:Garbage Collecation 垃圾回收

全文主要回答以下几个问题

  1. 为什么要区分全局变量和局部变量?
  2. 标记清除的原理是什么?
  3. 引用计数的原理是什么?
  4. 什么时候执行垃圾回收?

全局变量和局部变量

为什么会有全局变量和局部变量之分?

简单来说,通过将变量区分为全局变量和局部变量,可以定时清理局部变量占据的空间,为后续程序运行腾出内存空间,避免内存不足。

下面举个栗子来解释下。

休息室里有10个座位,来了20个人,有5个人行动不便需要座位,剩下15个人仅需要休息一会就离开。

现在有两种分配座位的方式:

1)一个人一旦坐在某个座位上,这个位置就永远是TA的,即使TA离开了别人也不能坐TA的座位

2)行动不便的人一旦来到,就可以指定某个位置是TA的,不论TA是否使用,别人都无法占用。其余的人坐在某个座位上时,别人不能把TA挤下去,一旦TA离开座位,这个位置就可以被人占据。

现在这20个人在一段时间内依次来到休息室,如果使用方式1,那么就会导致后来到的10个人没有位置可坐;如果使用方式2,椅子的使用情况将会动态变化,就能够满足这20个人都休息的需要。

通过垃圾回收,将那些临时使用的变量所占据的内存空间重新释放出来,就可以减小后续程序运行中内存不足的风险。

垃圾回收的原理大致如下:

  • 找出不再使用的变量;
  • 释放其占用的内存;
  • 固定时间间隔运行。

JavaScript如何知道那些空间不再使用,哪些空间又是需要保留的?主要通过标记清除引用计数两种方法实现。

标记清除

垃圾回收的第一种方式是标记清除(mark and sweep),是目前使用较多的方式。其原理在于:

  • 标记清除法开始执行垃圾回收时,会先将内存中所有的变量认为是垃圾,并打上标记(如标记为0)(该标记其实在变量进入环境时就被打上了);
  • 之后从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 对象,而所谓的根对象,在浏览器环境中包括又不止于 全局Window对象文档DOM树 等(类似于从根节点遍历一个树形结构),在遍历过程中将能够遍历到的对象进行标记反转(如标记为1);
  • 完成遍历后,将内存空间中记为0的对象销毁,将剩余的对象重新标记为0,完成一轮垃圾清理。
1
2
let test = {name:'tom'};
test = [1,2,3,4]

以上面两行代码为例进行分析:

  • 初始化test变量时,开辟了一块存有键值对name:'tom'的内存空间,该空间标记为0,将test指向该空间;
  • 接下来又开辟了一块存有数组[1,2,3,4]的内存空间,该空间创建时也标记为0,并将test的指向更改到该空间;
  • 等到JS执行垃圾回收时,从test遍历,发现能够指向数组空间,于是将该空间标记为1,存有键值对的空间因为没有对象指向它,因此无法被遍历到,标记始终为0;
  • 进行清理时,标记为0的键值对空间就被清理掉了,而数组空间也重新标记为0,等待下一轮垃圾回收。
1
2
3
4
5
function test(){
let a = {name:'tom'};
console.log(a.name);
}
test()

对以上代码进行分析:

  • 当执行到test()时,程序进入函数内部;
  • let a = {name:'tom'}在内存中创建了空间并用变量a指向该空间,此时该空间被标记为0;
  • console.log(a.name)读取了a指向的空间的值;
  • 函数执行完毕,当进行垃圾回收时,因为无法从全局作用域访问到该空间,因此该空间标记仍为0,空间被回收。

引用计数

垃圾回收的另一种方法是引用计数,该策略下,跟踪记录每个值(空间)被引用的次数,当被引用次数为0时,该值(空间)可以被回收。

值(空间)的计数规则如下:

  • 当值(空间)被变量引用时**(建立指向关系时)**,计数+1
  • 当引用该值(空间)的变量指向其他值(空间)时**(指向关系断开时)**,计数-1
1
2
3
4
let a = {name:'tom'};
let b = a;
let a = [1,2,3,4]
let b = [1,1,1]

以上面四行代码为例进行分析:

  • 第一行代码创建了一个键值对对象{name:'tom'},并将变量a指向该对象,因此该键值对对象计数+1,计数为1;
  • 第二行将变量b指向变量a,本质上与a指向同一个键值对对象,{name:'tom'}键值对增加了一个指向关系,计数+1,计数为2;
  • 第三行代码创建了一个数组对象[1,2,3,4],并改变变量a的指向,因此该数组对象建立了指向关系,计数1,{name:'tom'}键值对对象断开了与变量a的直线关系,计数-1,计数为2;
  • 第四行代码创建了新的数组对象[1,1,1],并将变量b指向它,因此该数组对象计数为1,因为b断开了与键值对对象{name:'tom'}的关系,因此键值对对象计数-1,计数为0;
  • 代码结束后,当执行垃圾回收时,因为{name:'tom'}键值对空间计数为0 ,其空间将被销毁,而两个数组对象因计数不为0,空间保留,等待下一轮垃圾回收。

⚠️引用计数的缺陷

使用引用计数的方法,若变量产生了循环引用,则值空间永远无法清除

1
2
3
4
5
6
function test(){
let a = {name:'Tom'} // 且将该 值空间 称为Tom
let b = {name:'Bob'} // 且将该 值空间 称为Bob
a.prop = b
b.prop = a
}

对上面代码进行分析:

  • 当a和b分别指向不同值空间时,两个值空间的计数均为1;
  • 接下来因为a变量的一个属性指向b所指向的空间,即指向了Bob空间,因此Bob空间新增一个指向关系,计数为2;
  • 又因为b变量的一个属性指向Tom空间,因此Tom空间的计数也变成了2;
  • 这样执行下来,虽然Bob空间Tom空间是函数内部使用的空间,但永远无法被清除掉,将长期占据内存且不再被使用。

正是由于该缺陷,一般不推荐用引用计数法。

什么时候触发垃圾回收

JavaScript具有自动垃圾回收机制,执行环境会负责管理代码执行过程中使用的内存。因为其开销比较大,因此这个过程不是实时进行的。

  • 在程序运行过程中,垃圾收集器会定期(按照固定的时间间隔,周期性的执行)找出那些不再继续使用的变量,然后释放其内存;

  • 当本次回收内存小于已用内存15%,意味着回收空间较少,回收过于频繁,回收间隔时间*2

  • 当回收内存大于已用内存85%,意味着回收了大量空间,回收间隔过长了,将会重置回收时间

如何释放闭包

JavaScript中通过闭包产生的空间会与全局变量类似不被清除,想要释放该空间,可以通过X = null的变量操作实现。其原理如下:

  • 当程序产生闭包时,会将在函数内部创建的值空间全局作用域的变量建立联系,因此垃圾回收器会通过遍历将该空间标记为1,避免了被清除掉;
  • 若要释放该闭包所使用的的空间,只需要通过x = null将全局变量的指向与该空间断开,则下次垃圾回收执行时无法通过全局变量遍历到该空间,就会销毁该空间。

参考内容

19【JS深度指南】垃圾回收、变量声明周期、标记清除、引用计数_哔哩哔哩_bilibili

「硬核JS」你真的了解垃圾回收机制吗 - 掘金 (juejin.cn)