都2022年了你不会还没搞懂js垃圾回收和内存泄露吧

1,448 阅读17分钟

简介

本文主要介绍js中垃圾回收策略以及谷歌v8引擎在垃圾回收上的优化和常见的一些会导致内存泄露操作,希望能对你们有所帮助。

image.png

什么是垃圾回收

GCGarbage Collection(垃圾回收) ,我们的程序在工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责自动回收这些垃圾,这就是我们常说的 垃圾回收机制

当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦。

垃圾是怎么产生的又如何判断垃圾

简单来说垃圾就是程序不用的内存或者是之前用过了,以后不会再用的内存空间。

如何判断垃圾就是看这个对象能否被访问,那如何知道对象能否被访问?有一个专业的词叫可达性。根据对象是否可达来判断。可达就不需要被回收,不可达就需要被回收。

我简单举个例子

let test = {name: 'randy'}

test = [1, 2, 3]

前面笔者介绍js数据类型的时候就说过,在js中数据分基本数据类型和引用数据类型,引用数据类型在栈中保存的是引用,实际是存储在堆中的。

在上面的例子中我们首先创建了一个test变量指向对象{name: 'randy'},然后又把test指向了新的数组[1, 2, 3],所以之前的{name: 'randy'}就不可能被访问到了(没有了可达性),就变成了垃圾。

为什么要垃圾回收

从上面的例子可以看出产生了垃圾就会导致浪费内存空间,一个两个还好,多了的话我们的程序可能会越来越卡顿,到最后崩溃。

所以就需要垃圾回收机制来帮我们自动清理没用的垃圾,释放出更多的内存来给当前程序使用,这样程序就会一直流畅的运行下去。

垃圾回收策略

垃圾回收策略里面最常用的两个策略就是标记清除法引用计数法

标记清除法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

就像它的名字一样,此算法分为 标记清除 两个阶段,标记阶段即为所有活动对象做上标记,后续会把可达的变量的标记清除掉。清除阶段则把还有标记(也就是非活动对象)销毁。

引擎在使用标记清除算法时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组  对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象文档DOM树 等。

标记清除过程

整个标记清除算法大致过程如下

  1. 从各个根对象开始遍历,把可达的变量打上标记。
  2. 清理所有没有标记垃圾,销毁并回收它们所占用的内存空间。

标记清除优点

标记清除算法实现比较简单,打标记也无非打与不打两种情况。而且执行效率高。

标记清除缺点

标记清除算法在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片(如下图),并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。

假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配(如下图)

那如何找到合适的块呢?我们可以采取下面三种分配策略

  • First-fit,找到大于等于 size 的块立即返回
  • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fitBest-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择

综上所述,标记清除算法或者说策略就有两个很明显的缺点

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块。
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢。

引用计数法

引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多。

引用计数法过程

引用计数就是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。

如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量改变了引用对象,则该值引用次数减1。

当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。

这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。

下面看个例子

let name1 = { name:'randy' }; //count==1 
let name2 = name1;            //count==2
b = null;                     //count==1
a = null;                     //count==0 被清除

引用计数法优点

引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾。而标记清除算法需要每隔一段时间进行一次,在应用程序运行过程中线程就必须要暂停去执行一次 GC

另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数值为0的时候清除就可以了。

引用计数法缺点

首先每个引用变量它都需要一个计数器,因此计数器需要占很大的位置。

还有就是无法解决循环引用无法回收的问题。

function cycle(){
  const obj1 = {};
  const obj2 = {};
  obj1.a = obj2;
  obj2.a = obj1;
}
cycle();

上面代码中cycle函数执行完后不需要了,所以o1o2的内存应该被释放,但是他们互相引用导致内存不会被回收,这就是循环引用。

v8引擎的垃圾回收

v8引擎对垃圾回收做了更近一步的优化。使用分代式垃圾回收机制,把对象分为新生代和老生代两种类型。对这两部分采用不同的垃圾回收策略。

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。

分代内存

默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

新生代垃圾回收

新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法

Cheney算法将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区

新加入的对象最开始都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。

当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。

为什么要把使用区和空闲区进行对调呢?就是为了让新加入的对象最开始都存放到使用区,空闲区始终保持空闲的状态。

因为新生代中对象的生命周期较短,并且Scavenge由于只复制存活的对象,所以它在时间效率上有优异的体现。 由于Scavenge将堆内存一分为二,所以永远最多使用一半的内存,所以内存利用率不高。

晋升

对象从新生代移动到老生代的过程叫作晋升。

对象晋升的条件主要有两个:

  1. 对象从使用区复制到空闲区时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,则会将该对象从使用区直接移动到老生代中,如果没有,则复制到空闲区。总结来说,如果一个对象是第二次经历从使用区复制到空闲区,那么这个对象会被直接移动到老生代中

  2. 当要从使用区复制一个对象到空闲区时,如果空闲区已经使用了超过25%,则这个对象会直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个空闲区会变为使用区,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

老生代垃圾回收

在老生代中,存活对象占较大比重,如果继续采用Scavenge算法进行管理,就会存在两个问题:

  1. 由于存活对象较多,复制存活对象的效率会很低。
  2. 采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。

所以,V8在老生代中主要采用了Mark-SweepMark-Compact相结合的方式进行垃圾回收。

Mark-Sweep是标记清除的意思,就是我们前面介绍的标记清除垃圾回收。前面我们说到标记清除会有内存碎片化分配速度慢问题。所以就有了下面的Mark-Compact

Mark-Compact就是标记整理的意思。标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,这样存活的对象在内存空间中是连续的,就不会再有内存碎片这种问题了。

总结

v8引擎中的分代式机制把一些新、小、存活时间短的对象作为新生代,采用Scavenge 算法进行快速清理,而一些大、老、存活时间长的对象作为老生代,采用了Mark-SweepMark-Compact相结合的方式进行垃圾回收,可以说此机制大大提高了垃圾回收机制的效率。

内存泄露

虽然引擎有优化,但并不是说我们就可以完全不用关心垃圾回收这块了,我们的代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被垃圾回收机制回收的,那当不再用到的内存,没有及时回收时,我们叫它 内存泄漏

下面我们来说说常见的内存泄露情况。

不正当的闭包

闭包在不同的文献中有不同的定义,笔者理解的闭包是在一个函数中返回了另外一个新函数,这个新函数使用了外部函数的局部变量。

举个例子

function say(){
  const name = 'randy'
  return function(){
    return name
  }
}
let newSay = say()
newSay()

上面的say方法返回了一个新的函数,这个函数使用了外部函数的局部变量name,所以就产生了闭包。这里的name变量内存就不会被释放,就会导致内存泄露。

那怎么解决呢?

我们只需要在调用完毕该方法后把变量置为null就可以啦。

newSay = null;

这里再扩展一下闭包

既然闭包内存不能被自动释放,为什么还要使用闭包呢?

  1. 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,我们可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。这也是早期实现模块化的方案,对模块化不理解的可以看看笔者写的彻底弄懂前端模块化 CJS、AMD、CMD、ESM、UMD一文。

  2. 闭包的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。在某些情况下就能起到很好的作用。

比如,循环,不使用闭包会输出十个10,而使用闭包就会输出0到9

for (var i = 0; i < 10; i++) {
  (function (i) {
    setTimeout(() => {
      console.log(i);
    });
  })(i);
}

意外的全局变量

函数中的局部变量在函数执行结束后这些变量已经不再被需要,所以垃圾回收器会识别并释放它们。但是对于全局变量,垃圾回收器很难判断这些变量什么时候才不被需要,所以全局变量通常不会被回收,我们使用全局变量是没问题的,但同时我们要避免一些额外的全局变量产生。

我们再来看看下面的例子

function fn(){
  // 没有声明从而制造了隐式全局变量test1
  test1 = {name: 'randy'}
  
  // 函数内部this指向window,制造了隐式全局变量test2
  this.test2 = {name: 'randy2'}
}

fn()

游离DOM引用

我们在平时的开发中进行 DOM 操作时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放。

let root = document.querySelector('#root')
let div1 = document.querySelector('#div1');

root.removeChild(div1)

一定要将DOM变量置为null,这样才会被垃圾回收机制回收。

div1 = null

未清理的定时器

在我们平时的开发中可能会使用到setTimeout 和 setInterval,但是在每次使用完毕后你们有没有将定时器清除呢?如果没有清除的话也会造成内存泄漏。

let timer = setTimeout(() => {
  console.log('randy')
}, 1000)

let inter = setInterval(() => {
  console.log('randy')
}, 1000)

在使用完毕后我们一定要将其清除

timer = null;
inter = null;

说到这里对于浏览器中的 requestAnimationFrame 也存在这个问题,我们需要在不需要的时候用 cancelAnimationFrame 来取消使用。

未清理的事件监听器

在我们平时的开发中可能会使用到addEventListener来进行事件的监听,但是监听完毕后有没有使用removeEventListener进行清除呢。

const say = () => {console.log('randy')}
window.addEventListener("resize", say)

一定要记得在使用完毕后使用removeEventListener进行清除。

window.removeEventListener("resize", say)

vue中还有我们常使用的eventBus进行事件传播。

eventBus.on("say", say)

我们一定要记得off方法进行清除。

eventBus.off("say", say)

未清理的console

什么console也会造成内存泄露?

我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。

所以在上到生产环境的时候我们一般都会使用插件console进行清除。

未清理的Map Set

由于ES普及,我们可能会用得到ES6MapSetMapSetObject一样都是强引用,也就是如果对象保存在MapSet中是不会被垃圾回收机制回收的。

所以就有了WeakSetWeakMap,保存这两个对象里对象是弱引用,一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,因此可能在任何时刻被回收,不会干扰垃圾回收机制的进行。

简单来说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSetWeakMap 之中。

系列文章

都2022年了你不会还没搞懂JS数据类型吧

都2022年了你不会还没搞懂JS原型和继承吧

都2022年了你不会还没搞懂JS赋值拷贝、浅拷贝、深拷贝吧

都2022年了你不会还没搞懂对象数组的遍历吧

都2022年了你不会还没搞懂this吧

都2022年了你不会还没搞懂JS Object API吧

都2022年了你不会还没搞懂js垃圾回收和内存泄露吧

都2022年你不会还没搞懂js执行上下文和事件循环机制吧

都2022年了你不会还没搞懂js中的事件吧

都2020年了你不会还没搞懂js异步编程吧

参考文章

你真的了解垃圾回收机制吗

聊聊V8引擎的垃圾回收

你的程序中可能存在内存泄漏

后记

本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力。