这篇文章围绕JavaScript垃圾回收进行展开,介绍垃圾回收的必要性和标记清除、引用计数两种垃圾回收机制。
GC:Garbage Collecation 垃圾回收
全文主要回答以下几个问题
- 为什么要区分全局变量和局部变量?
- 标记清除的原理是什么?
- 引用计数的原理是什么?
- 什么时候执行垃圾回收?
全局变量和局部变量
为什么会有全局变量和局部变量之分?
简单来说,通过将变量区分为全局变量和局部变量,可以定时清理局部变量占据的空间,为后续程序运行腾出内存空间,避免内存不足。
下面举个栗子来解释下。
休息室里有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 | let test = {name:'tom'}; |
以上面两行代码为例进行分析:
- 初始化
test
变量时,开辟了一块存有键值对name:'tom'
的内存空间,该空间标记为0,将test指向该空间; - 接下来又开辟了一块存有数组
[1,2,3,4]
的内存空间,该空间创建时也标记为0,并将test的指向更改到该空间; - 等到JS执行垃圾回收时,从test遍历,发现能够指向数组空间,于是将该空间标记为1,存有键值对的空间因为没有对象指向它,因此无法被遍历到,标记始终为0;
- 进行清理时,标记为0的键值对空间就被清理掉了,而数组空间也重新标记为0,等待下一轮垃圾回收。
1 | function test(){ |
对以上代码进行分析:
- 当执行到
test()
时,程序进入函数内部; let a = {name:'tom'}
在内存中创建了空间并用变量a指向该空间,此时该空间被标记为0;console.log(a.name)
读取了a指向的空间的值;- 函数执行完毕,当进行垃圾回收时,因为无法从全局作用域访问到该空间,因此该空间标记仍为0,空间被回收。
引用计数
垃圾回收的另一种方法是引用计数,该策略下,跟踪记录每个值(空间)被引用的次数,当被引用次数为0时,该值(空间)可以被回收。
值(空间)的计数规则如下:
- 当值(空间)被变量引用时**(建立指向关系时)**,计数+1
- 当引用该值(空间)的变量指向其他值(空间)时**(指向关系断开时)**,计数-1
1 | let a = {name:'tom'}; |
以上面四行代码为例进行分析:
- 第一行代码创建了一个键值对对象
{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 | function test(){ |
对上面代码进行分析:
- 当a和b分别指向不同值空间时,两个值空间的计数均为1;
- 接下来因为a变量的一个属性指向b所指向的空间,即指向了
Bob空间
,因此Bob空间
新增一个指向关系,计数为2; - 又因为b变量的一个属性指向
Tom空间
,因此Tom空间
的计数也变成了2; - 这样执行下来,虽然
Bob空间
、Tom空间
是函数内部使用的空间,但永远无法被清除掉,将长期占据内存且不再被使用。
正是由于该缺陷,一般不推荐用引用计数法。
什么时候触发垃圾回收
JavaScript具有自动垃圾回收机制,执行环境会负责管理代码执行过程中使用的内存。因为其开销比较大,因此这个过程不是实时进行的。
-
在程序运行过程中,垃圾收集器会定期(按照固定的时间间隔,周期性的执行)找出那些不再继续使用的变量,然后释放其内存;
-
当本次回收内存小于已用内存15%,意味着回收空间较少,回收过于频繁,回收间隔时间*2
-
当回收内存大于已用内存85%,意味着回收了大量空间,回收间隔过长了,将会重置回收时间
如何释放闭包
JavaScript中通过闭包产生的空间会与全局变量类似不被清除,想要释放该空间,可以通过X = null
的变量操作实现。其原理如下:
- 当程序产生闭包时,会将在函数内部创建的值空间与全局作用域的变量建立联系,因此垃圾回收器会通过遍历将该空间标记为1,避免了被清除掉;
- 若要释放该闭包所使用的的空间,只需要通过
x = null
将全局变量的指向与该空间断开,则下次垃圾回收执行时无法通过全局变量遍历到该空间,就会销毁该空间。