Js中常见的内存泄漏场景

时间:2024-08-01 00:03:38

常见的内存泄漏场景

内存泄漏Memory Leak是指程序中已动态分配的堆内存由于疏忽或错误等原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。对于内存泄露的检测,Chrome提供了性能分析工具Performance,可以比较方便的查看内存的占用情况等。

内存回收机制

C语言这样的底层语言一般都有底层的内存管理接口,例如malloc()free()等,对于JavaScript而言在创建变量时其会自动进行分配内存,并且在不使用它们时自动释放。在Js七种基本类型中的引用类型Object的变量其占据内存空间大且大小不固定,在堆内存中实际存储对象,在栈内存中存储对象的指针,对于对象的访问是按引用访问的。在栈区中执行的变量等是通过值访问,当其作用域销毁后变量也就随之销毁,而使用引用访问的堆区变量,在一个作用域消失后还可能在外层作用域或者其他作用域仍然存在引用,不能直接销毁,此时就需要通过算法计算该堆区变量是否属于不再需要的变量,从而决定是否需要进行内存回收,在Js中主要有引用计数与标记清除两种垃圾回收算法。

引用计数算法

对于引用计数垃圾回收算法,把对象是否不再需要简化定义为该对象有没有其他变量或对象引用到它,如果没有引用指向该对象,该对象将被垃圾回收机制回收。在这里,对象的概念不仅特指JavaScript对象,还包括函数作用域或者全局词法作用域。引用计数垃圾回收算法使用比较少,主要是在IE6IE7等低版本IE浏览器中使用。

var obj = {
a : {
b: 11
}
}
// 此时两个对象被创建,一个作为另一个的a属性被引用称为对象1,另一个被obj变量引用称为对象2
// 此时两个对象都有被引用的变量,都不能回收内存 var obj2 = obj;
// 此时对于obj所引用的对象2,已经有obj与Obj2两个变量的引用 obj = null;
// 将obj对于对象2的引用解除,此时对象2还存在obj2一个引用 var a2 = obj2.a;
// 引用对象1,此时对象1有a与a2两个引用 obj2 = null;
// 解除对象2的一个引用,此时对象2的引用数量为0,可以被垃圾回收
// 对象2的a属性引用被解除,此时对象1只有a2一个引用 a2 = null;
// 解除a2对于对象1的引用,此时对象1可以被垃圾回收

但是对于引用计数垃圾回收算法有个限制,当对象循环引用时,就会造成内存泄漏,也就是引用计数垃圾回收算法无法处理循环引用的对象。

function funct() {
var obj = {}; // 命名为对象1,此时引用数量为1
var obj2 = {}; // 命名为对象2,此时引用数量为1
obj.a = obj2; // obj的a属性引用obj2,此时对象2的引用数量为2
obj2.a = obj; // obj2的a属性引用obj,此时对象1的引用数量为2
return 1;
// 此时执行栈的obj变量与obj2变量被销毁,对象1与对象2的引用数量减1
// 对象1的引用数量为1,对象2的引用数量为1,两个对象都不会被引用计数算法垃圾回收
} funct();
// 两个对象被创建,并互相引用,形成了一个循环,它们被调用之后会离开函数作用域,所以它们已经不再需要了,可以被回收了,然而引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

标记清除算法

对于引用计数垃圾回收算法,把对象是否不再需要简化定义为该对象是否可以获得,该算法设置一个叫做根root的对象,在Javascript里根是全局对象,垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象,以此不断向下查找。从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象,这样便解决了循环引用的问题。所有现代浏览器都使用了标记清除垃圾回收算法,所有对JavaScript垃圾回收算法的改进都是基于标记清除算法的改进。

  • 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记。
  • 然后,它会去掉运行环境中的变量以及被环境中变量所引用的变量的标记。
  • 此后,依然有标记的变量就被视为准备删除的变量,原因是在运行环境中已经无法访问到这些变量了。
  • 最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

常见内存泄漏场景

意外的全局变量

JavaScript中并未严格定义对未声明变量的处理方式,即使在局部函数作用域中依旧能够定义全局变量,这种意外的全局变量可能会存储大量数据,且由于其是能够通过全局对象例如window能够访问到的,所以进行内存回收时不认为其是需要回收的内存而一直存在,只有在窗口关闭或者刷新页面时才能够被释放,造成意外的内存泄漏,在JavaScript的严格模式下此种意外的全局变量定义方式会抛出异常,另外同样可以使用eslint进行此种状态的预检查。事实上定义全局变量并不是一个好习惯,如果必须使用全局变量存储大量数据时,确保用完以后把它设置为null或者重新定义,与全局变量相关的增加内存消耗的一个主因是缓存,缓存数据是为了重用,缓存必须有一个大小上限才有用,高内存消耗导致缓存突破上限,因为缓存内容无法被回收。

function funct(){
name = "name";
}
funct();
console.log(window.name); // name
delete window.name; // 不手动删除则在不关闭或刷新窗口的情况下一直存在

被遗忘的计时器

计时器setInterval必须及时清理,否则可能由于其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。

<template>
<div></div>
</template> <script>
export default {
creates: function() {
this.refreshInterval = setInterval(() => this.refresh(), 2000);
},
beforeDestroy: function() {
clearInterval(this.refreshInterval);
},
methods: {
refresh: function() {
// do something
},
},
}
</script>

脱离DOM的引用

有时保存DOM节点内部数据结构很有用,例如需要快速更新表格的几行内容,把每一行DOM存成字典或者数组很有意义。此时同样的DOM元素存在两个引用:一个在DOM树中,另一个在字典中。将来如果决定删除这些行时,需要把两个引用都清除。此外还要考虑DOM树内部或子节点的引用问题,假如你的JavaScript代码中保存了表格某一个<td>的引用,将来决定删除整个表格的时候,直觉认为GC会回收除了已保存的<td>以外的其它节点,实际情况并非如此,此<td>是表格的子节点,子元素与父元素是引用关系,由于代码保留了<td>的引用,导致整个表格仍待在内存中,所以在保存DOM元素引用的时候,要小心谨慎。

var elements = {
button: document.getElementById("button"),
image: document.getElementById("image"),
text: document.getElementById("text")
};
function doStuff() {
elements.image.src = "https://www.example.com/1.jpg";
elements.button.click();
console.log(elements.text.innerHTML);
// 更多逻辑
}
function removeButton() {
// 按钮是 body 的后代元素
document.body.removeChild(elements.button);
elements.button = null; // 清除对于这个对象的引用
}

闭包

闭包是JavaScript开发的一个关键方面,闭包可以让你从内部函数访问外部函数作用域,简单来说可以认为是可以从一个函数作用域访问另一个函数作用域而非必要在函数作用域中实现作用域链结构。由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存,过度使用闭包可能会导致内存占用过多,在不再需要的闭包使用结束后需要手动将其清除。

function debounce(wait, funct, ...args){ // 防抖函数
var timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(() => funct(...args), wait);
}
} window.onscroll = debounce(300, (a) => console.log(a), 1);

被遗忘的监听者模式

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

<template>
<div ></div>
</template> <script>
export default {
created: function() {
global.eventBus.on("test", this.doSomething);
},
beforeDestroy: function(){
global.eventBus.off("test", this.doSomething);
},
methods: {
doSomething: function() {
// do something
},
},
}
</script>

被遗忘的事件监听器

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

<template>
<div></div>
</template> <script>
export default {
created: function() {
window.addEventListener("resize", this.doSomething);
},
beforeDestroy: function(){
window.removeEventListener("resize", this.doSomething);
},
methods: {
doSomething: function() {
// do something
},
},
}
</script>

被遗忘的Map

当使用Map存储对象时,类似于脱离DOM的引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收,对于键为对象的情况,可以采用WeakMapWeakMap对象同样用来保存键值对,对于键是弱引用的而且必须为一个对象,而值可以是任意的对象或者原始值,且由于是对于对象的弱引用,其不会干扰Js的垃圾回收。

var elements = new Map();
elements.set("button", document.getElementById("button"));
function doStuff() {
elements.get("button").click();
// 更多逻辑
}
function removeButton() {
// 按钮是 body 的后代元素
document.body.removeChild(elements.get("button"));
elements.delete("button"); // 清除对于这个对象的引用
}

被遗忘的Set

当使用Set存储对象时,类似于脱离DOM的引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收,如果需要使用Set引用对象,可以采用WeakSetWeakSet对象允许存储对象弱引用的唯一值,WeakSet对象中的值同样不会重复,且只能保存对象的弱引用,且由于是对于对象的弱引用,其不会干扰Js的垃圾回收。

var elements = new Set();
var btn = document.getElementById("button");
elements.add(btn);
function doStuff() {
btn.click();
// 更多逻辑
}
function removeButton() {
document.body.removeChild(btn); // 按钮是 body 的后代元素
elements.delete(btn); // 清除Set中对于这个对象的引用
btn = null; // 清除引用
}

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://zhuanlan.zhihu.com/p/60538328
https://juejin.im/post/6844903928060985358
https://jinlong.github.io/2016/05/01/4-Types-of-Memory-Leaks-in-JavaScript-and-How-to-Get-Rid-Of-Them/