深入了解 JavaScript 内存泄漏

深入,了解,javascript,内存,泄漏 · 浏览次数 : 287

小编点评

**函数 closure** closure 是一个函数,它指向一个变量。当 closure 被调用时,它会从变量中获取一个值并返回。 **内存泄漏** 内存泄漏是指一个变量被释放时仍然指向它。当变量被释放时,它会被从内存中移除,但它的指向变量仍然指向它。 **DOM 的引用** DOM 的引用每个页面上的 DOM 都是占用内存的。当我们使用 DOM 的引用来访问页面上的 DOM 时,我们实际上创建了一个新的 DOM 元素。这个新元素的指向原指向页面上的 DOM 元素。 **内存走势图** 内存走势图是一个用来判断内存泄漏的图表。内存走势图显示内存在某个时间点上的增长和下降。 **如何发现内存泄漏** 我们可以使用以下方法来发现内存泄漏: * 查看 DOM 的引用数量。 * 使用内存走势图分析内存增长和下降。 * 使用 developer 工具的内存记录功能。 **查找内存泄漏** 查找内存泄漏的位置上一步确认内存泄漏问题后,我们继续利用 developer 工具进行问题查找。访问上面的代码页面,打开开发者工具,切换至 Memory 选项。页面上点击运行按钮,然后点击开发者工具左上角的录制按钮,录制完成后继续点击录制,直到录制完成三个为止。然后点击页面上的停止按钮,在连续录制三次内存(不要清理之前的录制)。从这里也可以看出,点击运行按钮之后,内存在不断的递增。点击停止按钮之后,内存就平稳了。

正文

作者:京东零售 谢天

在任何语言开发的过程中,对于内存的管理都非常重要,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附近。

与深入了解 JavaScript 内存泄漏相似的内容:

深入了解 JavaScript 内存泄漏

在任何语言开发的过程中,对于内存的管理都非常重要,JavaScript 也不例外。但是如果我们对内存泄漏没有什么概念,就有可能因为内存泄漏,导致许多问题。了解内存泄漏,如何避免内存泄漏,都是不可缺少的。

【JavaScript】聊一聊js中的浅拷贝与深拷贝与手写实现

什么是深拷贝与浅拷贝?深拷贝与浅拷贝是js中处理对象或数据复制操作的两种方式。‌在聊深浅拷贝之前咱得了解一下js中的两种数据类型:

如何在低代码平台中引用 JavaScript ?

引言 在当今快速发展的数字化时代,企业对业务应用的需求日益复杂且多元。低代码开发平台作为一个创新的解决方案,以直观易用的设计理念,打破了传统的编程壁垒,让非技术人员也能轻松构建功能完备的Web应用程序,无需深入编码。这一特性极大地简化了应用开发流程,加速了业务需求转化为实际应用的速度,为企业带来了前

【技巧】JS代码这么写,前端小姐姐都会爱上你

这篇文章分享了JavaScript编程中的实用技巧,包括解构赋值的短路语法避免错误、深度解构及默认值设定,以及数组操作如条件添加元素、获取最后一个元素和使用includes优化条件判断。此外,还介绍了从URL解析参数、页面滚动功能和获取滚动距离的JS片段。作者提倡使用这些技巧提升代码质量和效率,并邀...

[转帖]深入了解浏览器存储

https://cloud.tencent.com/developer/article/1954085?areaSource=104001.46&traceId=7WZNP412yK3vh7ebw4th0 前言 随着移动网络的发展与演化,我们手机上现在除了有原生 App,还能跑“WebApp”——它

[转帖]深入了解epoll模型 -- 开卷有益

https://cloud.tencent.com/developer/article/1992927?areaSource=&traceId= 希望打开此篇对你有所帮助。 文章目录 什么是epoll?或者说,它和select有什么判别? 什么是select 为什么select最大只允许1024?

[转帖]深入了解 gRPC:协议

https://cn.pingcap.com/blog/grpc 经过很长一段时间的开发,TiDB 终于发了 RC3。RC3 版本对于 TiKV 来说最重要的功能就是支持了 gRPC,也就意味着后面大家可以非常方便的使用自己喜欢的语言对接 TiKV 了。 gRPC 是基于 HTTP/2 协议的,要深

深入了解 GPU 互联技术——NVLINK

随着人工智能和图形处理需求的不断增长,多 GPU 并行计算已成为一种趋势。对于多 GPU 系统而言,一个关键的挑战是如何实现 GPU 之间的高速数据传输和协同工作。然而,传统的 PCIe 总线由于带宽限制和延迟问题,已无法满足 GPU 之间通信的需求。为了解决这个问题,NVIDIA 于 2018 年

深入了解Elasticsearch搜索引擎篇:倒排索引、架构设计与优化策略

首先,我们介绍了Elasticsearch(ES)的倒排索引,这是一种用于快速检索的数据结构。其次,我们了解了ES集群的架构,包括主节点、数据节点和协调节点的功能和作用。然后,我们探讨了中文分词器的选择,其中包括IK、HanLP和Jieba等常用的分词工具。接着,我们解释了写入数据和查询数据的工作原理,包括请求的分配和预处理,数据的存储和查询结果的处理过程。最后,我们讨论了ES部署的优化方法,包括调整JVM内存、分片布局和数量、节点身份设计以及配置Ingest节点等方面的策略。

深入解析HTTP请求:了解请求特征与报文格式的关键秘密

这篇文章将带您深入了解HTTP请求的特征和报文格式。HTTP作为一种简单、灵活且易于扩展的协议,适用于各种操作系统和设备。我们还将探讨持久性连接如何提高请求的效率。了解HTTP报文的构成,包括起始行、头部字段和消息正文,将帮助您更好地理解HTTP的工作原理。无论您是初学者还是已经有一定了解的读者,本...