浅析内存三大问题(内存泄漏、内存膨胀、频繁GC)及几种常见的内存泄漏场景

一、什么是内存泄漏

  引擎中有垃圾回收机制,它主要针对一些程序中不再使用的对象,对其清理回收释放掉内存。那么垃圾回收机制会把不再使用的对象(垃圾)全都回收掉吗?

  其实引擎虽然针对垃圾回收做了各种优化从而尽可能的确保垃圾得以回收,但并不是说我们就可以完全不用关心这块了,我们代码中依然要主动避免一些不利于引擎做垃圾回收操作,因为不是所有无用对象内存都可以被回收的,那当不再用到的对象内存,没有及时被回收时,我们叫它内存泄漏(Memory leak)。

二、常见的内存泄漏

1、不正当的闭包

  闭包就是函数内部嵌套并 return 一个函数?这是大多数人认为的闭包,好吧,它确实也是,我们来看看几本 JS 高光书中的描述:

  • JavaScript高级程序设计:闭包是指有权访问另一个函数作用域中的变量的函数
  • JavaScript权威指南:从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链
  • 你不知道的JavaScript:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行

  按照上面三本书中的描述,那闭包所涉及的的范围就比较广了,我们这里暂时不去纠结闭包的定义,就以最简单、大家都认可的闭包例子来看闭包:

function fn1(){
  let test = new Array(1000).fill('isboyjc')
  return function(){
    console.log('hahaha')
  }
}
let fn1Child = fn1()
fn1Child()

  上例是闭包吗?它造成内存泄漏了吗?显然它是一个典型闭包,但是它并没有造成内存泄漏,因为返回的函数中并没有对 fn1 函数内部的引用,也就是说,函数 fn1 内部的 test 变量完全是可以被回收的,那我们再来看:

function fn2(){
  let test = new Array(1000).fill('isboyjc')
  return function(){
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()

  上例是闭包吗?它造成内存泄漏了吗?显然它也是闭包,并且因为 return 的函数中存在函数 fn2 中的 test 变量引用,所以 test 并不会被回收,也就造成了内存泄漏。

  那么怎样解决呢?其实在函数调用后,把外部的引用关系置空就好了,如下:fn2Child = null

  “减少使用闭包,闭包会造成内存泄漏...”这句话是过去式了,它的描述不准确,应该说不正当的使用闭包可能会造成内存泄漏。

2、隐式全局变量

  我们知道JS的垃圾回收是自动执行的,垃圾回收器每隔一段时间就会找出那些不再使用的数据,并释放其所占用的内存空间。

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

function fn(){
  // 没有声明从而制造了隐式全局变量test1
  test1 = new Array(1000).fill('isboyjc1')
  // 函数内部this指向window,制造了隐式全局变量test2
  this.test2 = new Array(1000).fill('isboyjc2')
}
fn()

  调用函数 fn ,因为 没有声明 和 函数中this 的问题造成了两个额外的隐式全局变量,这两个变量不会被回收,这种情况我们要尽可能的避免,在开发中我们可以使用严格模式或者通过 lint 检查来避免这些情况的发生,从而降低内存成本。

  除此之外,我们在程序中也会不可避免的使用全局变量,这些全局变量除非被取消或者重新分配之外也是无法回收的,这也就需要我们额外的关注,也就是说当我们在使用全局变量存储数据时,要确保使用后将其置空或者重新分配,当然也很简单,在使用完将其置为 null 即可,特别是在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理,不然的话数据量越来越大,内存压力也会随之增高。

3、游离 dom 引用

  考虑到性能或代码简洁方面,我们代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放

<div id="root">
  <ul id="ul">
    <li></li>
    <li></li>
    <li id="li3"></li>
    <li></li>
  </ul>
</div>
<script>
  let root = document.querySelector('#root')
  let ul = document.querySelector('#ul')
  let li3 = document.querySelector('#li3')
  // 由于ul变量存在,整个ul及其子元素都不能GC
  root.removeChild(ul)
  // 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
  ul = null
  // 已无变量引用,此时可以GC
  li3 = null
</script>

  如上所示,当我们使用变量缓存 DOM 节点引用后删除了节点,如果不将缓存引用的变量置空,依然进行不了 GC,也就会出现内存泄漏。

  假如我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父 DOM 节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将引用子节点的变量也置空。

4、遗忘的定时器

  程序中我们经常会用到计时器,也就是 setTimeout 和 setInterval,先来看一个例子:

// 获取数据
let someResource = getData()
setInterval(() => {
  const node = document.getElementById('Node')
  if(node) {
    node.innerHTML = JSON.stringify(someResource))
  }
}, 1000)

  上面是一个小例子,每隔一秒就将得到的数据放入到 Node 节点中去,但是在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。

  什么才叫结束呢?也就是调用了 clearInterval。如果没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。所以在上例中,someResource 就没法被回收。

  同样,setTiemout 也会有同样的问题,所以,当不需要interval 或者 timeout 时,最好调用 clearInterval 或者clearTimeout来清除,另外,浏览器中的 requestAnimationFrame 也存在这个问题,我们需要在不需要的时候用 cancelAnimationFrame API 来取消使用。

5、遗忘的事件监听器

  当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。我们就拿 Vue 组件来举例子,React 里也是一样的:

<template>
  <div></div>
</template>
<script>
export default {
  created() {
    window.addEventListener("resize", this.doSomething)
  },
  beforeDestroy(){  // 需在组件销毁前清除事件绑定
    window.removeEventListener("resize", this.doSomething)
  },
  methods: {
    doSomething() {
      // do something
    }
  }
}
</script>

6、遗忘的监听者模式:我们只需在 beforeDestroy 组件销毁生命周期里将其清除即可

  监听者模式想必我们都知道,不管是 Vue 、 React 亦或是其他,对于目前的前端开发框架来说,监听者模式实现一些消息通信都是非常常见的,比如 EventBus. . .

  当我们实现了监听者模式并在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样也会造成意外的内存泄漏。还是用 Vue 组件举例子,因为比较简单:

created() {
  eventBus.on("test", this.doSomething)
},
beforeDestroy(){// 需在组件销毁前清除事件监听
  eventBus.off("test", this.doSomething)
},

7、遗忘的 Map、Set 对象

  当使用 Map 或 Set 存储对象时,同 Object 一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。

  如果使用 Map ,对于键为对象的情况,可以采用 WeakMapWeakMap 对象同样用来保存键值对,对于键是弱引用(注:WeakMap 只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会干扰 Js 的垃圾回收。

8、未清理的 console 输出

  写代码的过程中,肯定避免不了一些输出,在一些小团队中可能项目上线也不清理这些 console,殊不知这些 console 也是隐患,同时也是容易被忽略的,我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。

  所以,开发环境下我们可以使用控制台输出来便于我们调试,但是在生产环境下,一定要及时清理掉输出。

三、内存三大件

  其实前端关于内存方面主要有三个问题,我把它们亲切的称作内存三大件:

1、内存泄漏

  我们说很久了,对象已经不再使用但没有被回收,内存没有被释放,即内存泄漏,那想要避免就避免让无用数据还存在引用关系,也就是多注意我们上面说的常见的几种内存泄漏的情况。

2、内存膨胀

  即在短时间内内存占用极速上升到达一个峰值,想要避免需要使用技术手段减少对内存的占用。

3、频繁GC

  同这个名字,就是 GC 执行的特别频繁,一般出现在频繁使用大的临时变量导致新生代空间被装满的速度极快,而每次新生代装满时就会触发 GC,频繁 GC 同样会导致页面卡顿,想要避免的话就不要搞太多的临时变量,因为临时变量不用了就会被回收,这和我们内存泄漏中说避免使用全局变量冲突,其实,只要把握好其中的度,不太过分就 OK。

四、内存泄漏排查、定位与修复

  可以看这篇文章:https://mp.weixin.qq.com/s/fS-PKDCM1apt5MhwlhoTjw

posted @ 2019-03-20 10:45  古兰精  阅读(2363)  评论(0编辑  收藏  举报