乐趣区

关于内存:深入了解-JavaScript-内存泄漏

作者:京东批发 谢天

在任何语言开发的过程中,对于内存的治理都十分重要,JavaScript 也不例外。

然而在前端浏览器中,用户个别不会在一个页面停留很久,即便有一点内存透露,从新加载页面内存也会跟着开释。而且浏览器也有本人的主动回收内存的机制,所以前端并没有特地关注内存透露的问题。

然而如果咱们对内存透露没有什么概念,有时候还是有可能因为内存透露,导致页面卡顿。理解内存透露,如何防止内存透露,都是不可短少的。

什么是内存

在硬件级别上,计算机内存由大量触发器组成。每个触发器蕴含几个晶体管,可能存储一个位。单个触发器能够通过惟一标识符寻址,因而咱们能够读取和笼罩它们。因而,从概念上讲,咱们能够把咱们的整个计算机内存看作是一个微小的位数组,咱们能够读和写。

这是内存的底层概念,JavaScript 作为一个高级语言,不须要通过二进制进行内存的读写,而是相干的 JavaScript 引擎做了这部分的工作。

内存的生命周期

内存也会有生命周期,不论什么程序语言,个别能够依照程序分为三个周期:

  • 调配期:调配所须要的内存
  • 使用期:应用调配的内存进行读写
  • 开释期:不须要时将其开释和偿还

内存调配 -> 内存应用 -\> 内存开释

什么是内存透露

在计算机科学中,内存透露 指因为忽略或谬误造成程序未能开释曾经不再应用的内存。内存透露并非指内存在物理上的隐没,而是应用程序调配某段内存后,因为设计谬误,导致在开释该段内存之前就失去了对该段内存的管制,从而造成了内存的节约。

如果内存不须要时,没有通过生命周期的的 开释期 ,那么就存在 内存透露

内存透露的简略了解:无用的内存还在占用,得不到开释和偿还。比较严重时,无用的内存会继续递增,从而导致整个零碎的卡顿,甚至解体。

JavaScript 内存管理机制

像 C 语言这样的底层语言个别都有底层的内存治理接口,然而 JavaScript 是在创立变量时主动进行了内存调配,并且在不应用时主动开释,开释的过程称为“垃圾回收”。然而就是因为主动回收的机制,让咱们谬误的感觉开发者不用关怀内存的治理。

JavaScript 内存管理机制和内存的生命周期是统一的,首先须要分配内存,而后应用内存,最初开释内存。绝大多数状况下不须要手动开释内存,只须要关注对内存的应用(变量、函数、对象等)。

内存调配

JavaScript 定义变量就会主动分配内存,咱们只须要理解 JavaScript 的内存是主动调配的就能够了。

let num = 1;
const str = "名字";
const obj = {
  a: 1,
  b: 2
}
const arr = [1, 2, 3];
function func (arg) {...}

内存应用

应用值的过程实际上是对调配的内存进行读写的操作,读取和写入的操作可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

// 持续上局部
// 写入内存
num = 2;
// 读取内存,写入内存
func(num);

内存回收

垃圾回收被称为GC(Garbage Collection)

内存透露个别都是产生在这一步,JavaScript 的内存回收机制尽管能够回收绝大部分的垃圾内存,然而还是存在回收不了的状况,如果存在这些状况,须要咱们本人手动清理内存。

以前一些老版本的浏览器的 JavaScript 回收机制没有那么欠缺,经常出现一些 bug 的内存透露,不过当初的浏览器个别都没有这个问题了。

这里理解下当初 JavaScript 的垃圾内存的两种回收形式,相熟一下这两种算法能够帮忙咱们了解一些内存透露的场景。

援用计数

这是最高级的垃圾收集算法。此算法把“对象是否不再须要”简化定义为“对象有没有其余对象援用到它”。如果没有援用指向该对象(零援用),对象将被垃圾回收机制回收。

//“对象”调配给 obj1
var obj1 = {
  a: 1,
  b: 2
}
// obj2 援用“对象”var obj2 = obj1;
//“对象”的原始援用 obj1 被 obj2 替换
obj1 = 1;

以后执行环境中,“对象”内存还没有被回收,须要手动开释“对象”的内存(在没有来到以后执行环境的前提下)

obj2 = null;
// 或者 obj2 = 1;
// 只有替换“对象”就能够了

这样援用的“对象”内存就被回收了。

ES6 中把援用分为 强援用 弱援用,这个目前只有在 Set 和 Map 中才存在。

强援用才会有援用计数叠加,只有援用计数为 0 的对象的内存才会被回收,所以个别须要手动回收内存(手动回收的前提在于标记革除法还没执行,还处于以后的执行环境)。

而弱援用没有触发援用计数叠加,只有援用计数为 0,弱援用就会主动隐没,无需手动回收内存。

标记革除

当变量进入执行时标记为“进入环境”,当变量来到执行环境时则标记为“来到环境”,被标记为“进入环境”的变量是不能被回收的,因为它们正在被应用,而标记为“来到环境”的变量则能够被回收。

环境能够了解为咱们的执行上下文,全局作用域的变量只会在页面敞开时才会被销毁。

// 假如这里是全局上下文
var b = 1; // b 标记进入环境
function func() {
  var a = 1;
  return a + b; // 函数执行时,a 被标记进入环境
}
func();
// 函数执行完结,a 被标记来到环境,被回收
// 然而 b 没有标记来到环境

JavaScript 内存透露的一些场景

JavaScript 的内存回收机制尽管能回收绝大部分的垃圾内存,然而还是存在回收不了的状况。程序员要让浏览器内存透露,浏览器也是管不了的。

上面有些例子是在执行环境中,没来到以后执行环境,还没触发标记革除法。所以你须要读懂下面 JavaScript 的内存回收机制,能力更好的了解上面的场景。

意外的全局变量

// 在全局作用域下定义
function count(num) {
  a = 1; // a 相当于 window.a = 1;
  return a + num;
}

不过在 eslint 帮忙下,这种场景当初根本没人会犯了,eslint 会间接报错,理解下就好。

忘记的计时器

无用的计时器遗记清理,是最容易犯的谬误之一。

拿一个 vue 组件举个例子。

<script>
export default {mounted() {setInterval(() => {this.fetchData();
    }, 2000);
  },
  methods: {fetchData() {...}
  }
}
</script>

下面的组件销毁的时候,setInterval还是在运行的,外面波及到的内存都是没法回收的(浏览器会认为这是必须的内存,不是垃圾内存),须要在组件销毁的时候革除计时器。

<script>
export default {mounted() {this.timer = setInterval(() => {...}, 2000);
  },
  beforeDestroy() {clearInterval(this.timer);
  }
}
</script>

忘记的事件监听

无用的事件监听器遗记清理也是最容易犯的谬误之一。

还是应用 vue 组件举个例子。

<script>
export default {mounted() {window.addEventListener('resize', () => {...});
  }
}
</script>

下面的组件销毁的时候,resize 事件还是在监听中,外面波及到的内存都是没法回收的,须要在组件销毁的时候移除相干的事件。

<script>
export default {mounted() {this.resizeEvent = () => {...};
    window.addEventListener('resize', this.resizeEvent);
  },
  beforeDestroy() {window.removeEventListener('resize', this.resizeEvent);
  }
}
</script>

忘记的 Set 构造

Set 是 ES6 中新增的数据结构,如果对 Set 不熟,能够看这里。

如下是有内存透露的(成员是援用类型,即对象):

let testSet = new Set();
let value = {a: 1};
testSet.add(value);
value = null;

须要改成这样,才会没有内存透露:

let testSet = new Set();
let value = {a: 1};
testSet.add(value);

testSet.delete(value);
value = null;

有个更便捷的形式,应用 WeakSet,WeakSet 的成员是弱援用,内存回收不会思考这个援用是否存在。

let testSet = new WeakSet();
let value = {a: 1};
testSet.add(value);
value = null;

忘记的 Map 构造

Map 是 ES6 中新增的数据结构,如果对 Map 不熟,能够看这里。

如下是有内存透露的(成员是援用类型,即对象):

let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);
key = null;

须要改成这样,才会没有内存透露:

let map = new Map();
let key = [1, 2, 3];
map.set(key, 1);

map.delete(key);
key = null;

有个更便捷的形式,应用 WeakMap,WeakMap 的键名是弱援用,内存回收不会思考到这个援用是否存在。

let map = new WeakMap();
let key = [1, 2, 3];
map.set(key, 1);
key = null

忘记的订阅公布

和下面事件监听器的情理是一样的。

建设订阅公布事件有三个办法,emitonoff三个办法。

还是持续应用 vue 组件举例子:

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {mounted() {EventEmitter.on('test', () => {...});
  },
  methods: {onClick() {EventEmitter.emit('test');
    }
  }
}
</script>

下面组件销毁的时候,自定义 test 事件还是在监听中,外面波及到的内存都是没方法回收的,须要在组件销毁的时候移除相干的事件。

<template>
  <div @click="onClick"></div>
</template>
<script>
import EventEmitter from 'event';

export default {mounted() {EventEmitter.on('test', () => {...});
  },
  methods: {onClick() {EventEmitter.emit('test');
    }
  },
  beforeDestroy() {EventEmitter.off('test');
  }
}
</script>

忘记的闭包

闭包是常常应用的,闭包能提供很多的便当,

首先看下上面的代码:

function closure() {
  const name = '名字';
  return () => {return name.split('').reverse().join('');
  }
}
const reverseName = closure();
reverseName(); // 这里调用了 reverseName

下面有没有内存透露?是没有的,因为 name 变量是要用到的(非垃圾),这也是从侧面反映了闭包的毛病,内存占用绝对高,数量多了会影响性能。

然而如果 reverseName 没有被调用,在以后执行环境未完结的状况下,严格来说,这样是有内存透露的,name变量是被 closure 返回的函数调用了,然而返回的函数没被应用,在这个场景下 name 就属于垃圾内存。name不是必须的,然而还是占用了内存,也不可被回收。

当然这种也是极其状况,很少人会犯这种低级谬误。这个例子能够让咱们更分明的意识内存透露。

DOM 的援用

每个页面上的 DOM 都是占用内存的,建设有一个页面 A 元素,咱们获取到了 A 元素 DOM 对象,而后赋值到了一个变量(内存指向是一样的),而后移除了页面上的 A 元素,如果这个变量因为其余起因没有被回收,那么就存在内存透露,如上面的例子:

class Test {constructor() {
    this.elements = {button: document.querySelector('#button'),
      div: document.querySelector('#div')
    }
  }
  removeButton() {document.body.removeChild(this.elements.button);
    // this.elements.button = null
  }
}

const test = new Test();
test.removeButton();

下面的例子 button 元素尽管在页面上移除了,然而内存指向换成了this.elements.button,内存占用还是存在的。所以下面的代码还须要这么写:this.elements.button = null,手动开释内存。

如何发现内存透露

内存透露时,内存个别都是周期性的增长,咱们能够借助谷歌浏览器的开发者工具进行判断。

这里针对上面的例子进行一步步的的排查和找到问题点:

<html>
  <body>
    <div id="app">
      <button id="run"> 运行 </button>
      <button id="stop"> 进行 </button>
    </div>
    <script>
      const arr = []
      for (let i = 0; i < 200000; i++) {arr.push(i)
      }
      let newArr = []

      function run() {newArr = newArr.concat(arr)
      }

      let clearRun

      document.querySelector('#run').onclick = function() {clearRun = setInterval(() => {run()
        }, 1000)
      }

      document.querySelector('#stop').onclick = function() {clearInterval(clearRun)
      }
    </script>
  </body>
</html>

的确是否是内存透露问题

拜访下面的代码页面,关上开发者工具,切换至 Performance 选项,勾选 Memory 选项。

在页面上点击运行按钮,而后在开发者工具下面点击左上角的录制按钮,10 秒后在页面上点击进行按钮,5 秒进行内存录制。失去内存走势如下:

由上图可知,10 秒之前内存周期性增长,10 秒后点击了进行按钮,内存安稳,不再递增。咱们能够应用内存走势图判断是否存在内存透露。

查找内存透露的地位

上一步确认内存透露问题后,咱们持续利用开发者工具进行问题查找。

拜访下面的代码页面,关上开发者工具,切换至 Memory 选项。页面上点击运行按钮,而后点击开发者工具左上角的录制按钮,录制实现后持续点击录制,直到录制实现三个为止。而后点击页面上的进行按钮,在间断录制三次内存(不要清理之前的录制)。

从这里也能够看出,点击运行按钮之后,内存在一直的递增。点击进行按钮之后,内存就安稳了。尽管咱们也能够用这种形式来判断是否存在内存透露,然而没有第一步的办法便捷,走势图也更加直观。

而后第二步的次要目标是为了记录 JavaScript 堆内存,咱们能够看到哪个堆占用的内存更高。

从内存记录中,发现 array 对象占用最大,开展后发现,第一个 object elements 占用最大,抉择这个 object elements 后能够在上面看到 newArr 变量,而后点击前面的高亮链接,就能够跳转到 newArr 左近。

退出移动版