JavaScript的内存泄漏详解
写在前面
当一个页应用随着使用的时间越用越卡,出现反应迟缓,高延迟甚至崩溃的时候,那就说明这个页面出了大问题,然后排除掉开发者和浏览器在应用中特意缓存的数据后还有这样的问题出现,那这应用就是出现了内存泄漏。
什么是内存泄漏
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
常见的引发内存泄漏的方式
1. 错误的闭包使用方式
function showA() {
let a = 0;
function print() {
console.log(a)
}
return print
}
let show = showA()
show() // 0
这就是我们说的闭包。理论上来说当变量show
定义完,showA
函数也运行完了,这个函数里面所有的变量就会被垃圾回收机制回收,但是由于show
是个函数,而且它的目的就是展示showA
函数里面a
的值,如果showA
的变量全部被回收了,那a
就不存在了,很明显这是不对的,那怎么办?只能让它不被回收了,这样在调用show
方法的时候才能正确显示a
变量的值。那这样问题就来了,如果一直不回收,那这个内存就会一直被占着,一个,两个,三个...,越来越多,最终系统崩溃是必然的。
function showA() {
let a = 0;
function print() {
console.log(a)
}
return print
}
let show = showA()
show() // 0
show = null
如果我们在运行完之后告诉系统我们用不到了(show = null
),这样在进行垃圾回收的时候这块代码的内 存回收掉,自然就不会出现内存泄漏的问题。所以当我们有这个意识,并且能正确使用闭包的话也是不会存在内存泄漏的问题的。
2. 隐式定义的全局变量
在javascript中,定义的全局变量是不会被回收的,所以我们在定义全句变量的时候要慎之又慎,因为如果是没有必要的变量作为了全局变量,那他就相当白白占用着系统内存,这也是一种内存泄漏。 但是光明白这点还不够,因为有时候在不经意之间就会创建出自己都不知道什么时候创建的全局变量:
function fun() {
a = '这是a'
this.b= '这是b'
}
fun()
上面的这个函数,其实我们已经在不经意间创建了2个全局变量,a
和b
现在都已经在window
上了,垃圾回收机制是不会回收它的,那这样又出现了内存泄漏。
function fun() {
const a = '这是a'
const b= '这是b'
}
fun()
如果我们这样定义变量,那a
和b
就被限制在了fun
函数中,函数运行完,它们自然也跟着被回收了。
3. 被遗忘的计时器
let a = 0
function countA(){
setInterval(() => {
a++
},1000)
}
countA()
上面的例子我们a
做了个一秒自增一次的装置,但是用着用着我们就会发现a
这个变量一直在增加,或许我们只是想让它加到5而已,但是现在他已经不受我们的控制了。我们把问题想的再严重一点,如果这个函数不是做a
的自增,而是创建dom的话,那不用说了,过不了多久这个系统就会随着内存使用过多导致崩溃。所以在计时器的使用中我们也要慎重,要注意在不使用的时候及时将其删除,避免导致内存泄漏。
let a = 0
let timer = null
function countA(){
timer = setInterval(() => {
a++
},1000)
}
countA()
clearInterval(timer) // 不使用的时候将其清除
4. dom泄漏
一般来说如果通过remove方法可以将dom移除,移除之后他就不会占用内存了,但是,如果在移除的时候它还在被使用,那他就不能被移除,并且会占用着内存(和闭包相似)。
<div id="root">
root
<ul id="ul">
<li id="li1">li1</li>
<li id="li2">li1</li>
</ul>
</div>
<script>
let root = document.querySelector('#root')
let ul = document.querySelector('#ul')
let li1 = document.querySelector('#li1')
let li2 = document.querySelector('#li2')
root.removeChild(ul) // 此时页面上已经没有ul和li的节点了
console.log(ul); // 节点对象
console.log(li1);// 节点对象
console.log(li2);// 节点对象
ul = null
console.log(ul);// null
console.log(li1);// 节点对象
console.log(li2);// 节点对象
li1 = null
console.log(ul);// null
console.log(li1);// null
console.log(li2);// 节点对象
li2 = null
console.log(ul);// null
console.log(li1);// null
console.log(li2);// null
</script>
从上面的例子只能够我们看到,虽然已经移除了root
的子节点页面上也没有显示,但是由于有变量接收了节点的数据,实际上在内存中还是有被移除的节点的信息的,如果不清除,那它一样会占用内存,造成内存泄漏。
5. 事件监听和观察者模式
在进行页面开发的过程中我们免不了需要监听对dom的一些操作,在用现在的前端框架时,有些人会选择使用EventBus
去进行状态的传递,这些操作都是要我们去手动开启监听的,我们以vue举例:
<template>
<div id="dom"></div>
</template>
<script>
export default {
created() {
document.getElementById('dom')
.addEventListener("click", ()=>{}) // 这种方式在vue里一般不会用
window.addEventListener("resize", ()=>{})
eventBus.on("event", ()=>{})
},
}
</script>
当我们进入组件,开启时间监听之后就可以根据这些事件的变化然后去进行一些相应的处理,但是当组件被销毁之后,它们还是存在的,这就不合理了,组件都不在了,它们还在监听,还在占用着内存,这不就造成了内存泄漏吗?
所以我们在不使用这些事件之后(例如组件销毁)应该及时将其关闭:
<template>
<div id="dom"></div>
</template>
<script>
export default {
created() {
document.getElementById('dom')
.addEventListener("click", ()=>{}) // 这种方式在vue里一般不会用
window.addEventListener("resize", ()=>{})
eventBus.on("event", ()=>{})
},
beforeDestroy(){
document.getElementById('dom')
.removeEventListener("click", ()=>{})
window.removeEventListener("resize", ()=>{})
eventBus.off("event", this.doSomething)
},
}
</script>
6. 意想不到的console.log
写前端代码时我们总免不了要去做一些调试和跟踪数据,这些时候一般都会用到console.log
输出变量的内容到控制台来帮我们调试,但是传递给console.log
的对象是不能被垃圾回收的,所以在开发环境最好不要使用console.log
不然又会出现内存泄露的情况
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id='runBtn'>运行输出内容</button>
<script>
runBtn.onclick = () => {
runBtn.remove();
addString();
}
const addString = (i=1)=> {
const logContent = { i, a: 'X'.repeat(2000000) }
console.log(logContent);
if(i<500) addString(++i) // 限制了500次
}
</script>
</body>
</html>
上面的例子就是很明显console.log
导致内存泄露使系统崩溃的例子,如果我们把那个限制去掉,执行次数到几千次的时候系统就撑不住了,但是如果我们去掉console.log
就不会出现这样的问题。
7. Map和Set
Map和Set是ES6新添加的数据结构,他们的key
可以是任意类型,既能是基本类型,也能是引用类型。那么问题就来了,如果以对象作为key
值的话如果这个对象用不到了,垃圾回收机制会回收它么?答案是不会的,因为虽然代码中没有再用这个对象做什么事情,但是由于Map
和Set
他们将这个对象当做key
了,所以还是不能回收的。这样在无形中就造成了内存泄露。
let map= new Map();
let value = { test: "map"};
map.set(value,'map结构');
value= null;
// value虽然为null,但是之前的引用还没被回收,此时已经造成内存泄露
for (let [key, value] of map) {
console.log(key + " = " + value); // [object Object] = map结构
}
如果想要解决Map
和Set
内存泄露的问题,那就应该在value置null之前先将他们移除
let map= new Map();
let value = { test: "map"};
map.set(value,'map结构');
map.delete(value )
value= null;
// map无值,此时无法执行for... of
for (let [key, value] of map) {
console.log(key + " = " + value);
}
基于这样的原因,所以与他们同时出现的还有WeakMap
和WeakSet
,由于他们只能以对象作为key而且他们的键都是弱引用,也就是说如果这个对象用不到了,垃圾回收机制就会被回收。
let map= new WeakMap();
let value = { test: "map"};
map.set(value,'map结构'); // 由于是弱引用,map的key是不可枚举的
value= null; // value之前的引用会被垃圾回收机制回收而不用手动移除
内存泄漏的排查
了解了内存泄露的常见方式想,现在我们就需要知道怎么通过去排查内存泄露的问题,看看是不是真的如上文所说。现在我们以console.log
为例开始进行排查:
- 浏览器F12打开检查窗口,并且切换到【Performance】标签
- 清空所有内容并且进行一次CG,防止其他干扰项影响
- 开启记录
- 并执行页面内容
- 停止记录,查看内存使用状态
从上面的内存使用状态中我们可以看到,使用了console.log
方法后内存一直在涨,直到执行完500次不再执行之后才停止,而且内存一直被占用着。
接下来我们把console.log(logContent)
注释掉再看看内存的占用情况:
可以很容易得出结论console.log
确实是会造成内存泄露的。
转载自:https://juejin.cn/post/7278496260478369829