您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
深入了解 JavaScript 内存泄漏
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
深入了解 JavaScript 内存泄漏
京东云开发者
2023-04-03
IP归属:北京
334400浏览
前端
推荐
#### 作者:京东零售 谢天 在任何语言开发的过程中,对于内存的管理都非常重要,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 不熟,可以看[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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 不熟,可以看[这里](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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 ``` ## 遗忘的订阅发布 和上面事件监听器的道理是一样的。 建设订阅发布事件有三个方法,`emit`、`on`、`off`三个方法。 还是继续使用 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 秒停止内存录制。得到内存走势如下: ![](https://oscimg.oschina.net/oscnet/up-c6f1c47e02821b6b5d15317606b18618d98.png) 由上图可知,10 秒之前内存周期性增长,10 秒后点击了停止按钮,内存平稳,不再递增。我们可以使用内存走势图判断是否存在内存泄漏。 ## 查找内存泄漏的位置 上一步确认内存泄漏问题后,我们继续利用开发者工具进行问题查找。 访问上面的代码页面,打开开发者工具,切换至 Memory 选项。页面上点击运行按钮,然后点击开发者工具左上角的录制按钮,录制完成后继续点击录制,直到录制完成三个为止。然后点击页面上的停止按钮,在连续录制三次内存(不要清理之前的录制)。 ![](https://oscimg.oschina.net/oscnet/up-0da23c5001a2f227514172312d39c144f15.png) 从这里也可以看出,点击运行按钮之后,内存在不断的递增。点击停止按钮之后,内存就平稳了。虽然我们也可以用这种方式来判断是否存在内存泄漏,但是没有第一步的方法便捷,走势图也更加直观。 然后第二步的主要目的是为了记录 JavaScript 堆内存,我们可以看到哪个堆占用的内存更高。 ![](https://oscimg.oschina.net/oscnet/up-6bd7287e8f251b6d2eba4d48c4d7561e113.png) 从内存记录中,发现 array 对象占用最大,展开后发现,第一个`object elements`占用最大,选择这个 object elements 后可以在下面看到`newArr`变量,然后点击后面的高亮链接,就可以跳转到`newArr`附近。
原创文章,需联系作者,授权转载
上一篇:交易系统之数据库弱依赖解决方案
下一篇:JSF预热功能在企业前台研发部的实践与探索
相关文章
前端十年回顾 | 漫画前端的前世今生
Taro小程序跨端开发入门实战
【技术干货】企业级扫描平台EOS关于JS扫描落地与实践!
京东云开发者
文章数
90
阅读量
207668
作者其他文章
01
安全测试之探索windows游戏扫雷
扫雷游戏相信很多人都从小玩过,在那个电脑游戏并不多的时代,扫雷成为玩的热度蛮高的一款游戏之一,然而就在有一次,接触到了一次不寻常的扫雷过程,使得后来我也有了这个冲动,也来做一次。通过动态调试,逆向和C来写一个扫雷辅助工具从而提高逆向与编码技能。
01
https 的本质、证书验证过程以及数据加密
01
《京东金融APP的鸿蒙之旅系列专题》新特性篇:意图框架接入
01
《京东金融APP的鸿蒙之旅系列专题》鸿蒙工程化:Hvigor构建技术
京东云开发者
文章数
90
阅读量
207668
作者其他文章
01
安全测试之探索windows游戏扫雷
01
https 的本质、证书验证过程以及数据加密
01
《京东金融APP的鸿蒙之旅系列专题》新特性篇:意图框架接入
01
《京东金融APP的鸿蒙之旅系列专题》鸿蒙工程化:Hvigor构建技术
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号