JS的垃圾回收机制与内存泄漏
JS GC —— garbage collection
GC策略: JS引擎周期性寻找不具有可达性的内存空间并释放该空间;如果是实时性寻找开销过大,影响性能。
可达性: 能够通过某种方式访问到地址的,不会被GC。
问:为什么要垃圾回收?
答: 程序的运行需要内存,只要程序提出要求,操作系统就必须提供内存,那么对于尺寸运行的服务进程就必须要及时释放内存,否则一直占用内存,导致堆积,轻则影响系统性能,重则就会导致进程崩溃。
垃圾回收方式
1. 标记清除(主流使用)—— mark sweep
标记: 针对所有的Active Object(简称:AO)进行标记
清除: 清除无标记的非AO对象
大致流程
- 打标记,初始默认值为垃圾标识 -> 0
- 从根节点遍历,AO对象标记 -> 1
- 清理所有为0的垃圾,销毁垃圾的内存空间
- 标记为1的 -> 重置为0
优缺点
优点: 方式简单,使用打标记的方式
缺点:
1. **内存空间碎片化**:因为内存空间都是连续的,当清理部分内存空间后,导致内存空间就不连续的了,空闲的内存空间呈现出碎片化。
2. **分配速度慢**:由于内存碎片化,导致需要遍历寻找适合的内存空间分配给新的变量对象。
优化
标记清除算法缺点就在于内存空间碎片化,只要解决这个问题就接连解决两个问题。
解决这一问题出现了标记整理算法(Mark-compact):在结束标记后,会将AO向内存一端移动,最后清理掉边界的内存,让空闲的内存变成连续的。
2. 引用计数(淘汰)—— reference counting
描述: 如果没有引用指向该对象,则该对象将被垃圾回收机制回收
大致流程
设置一个计数器 count = 0
- 声明了一个变量,且将一个引用类型赋值给变量 count + 1
- 如果同一个值被赋值给别的变量 count + 1
- 变量值被其他值覆盖 count - 1
- 直到 count = 0,开始GC
let a = new Object() // 此对象的引用计数为 1(a引用)
let b = a // 此对象的引用计数是 2(a,b引用)
a = null // 此对象的引用计数为 1(b引用)
b = null // 此对象的引用计数为 0(无引用)
// GC 回收此对象
优缺点
缺点:虽然方式简单,但是容易出现问题
- 需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限
- 需要手动释放,无法解决循环引用无法回收的问题
function fn() {
let A = new Object() // acount = 1
let B = new Object() // bcount = 1
A.b = B // bcount + 1
B.a = A // acount + 1
}
当函数结束后,两个对象都不在作用域中,A 和 B 都会被当作非活动对象来清除掉,相比之下,引用计数则不会释放,也就会造成大量无用内存占用,这也是后来放弃引用计数,使用标记清除的原因之一。
优点:
- 当引用值变为0的这一刻,即为垃圾,会被立即回收
- 只需要在引用的时候计数即可,无需遍历
JS V8引擎对GC的优化
1. 分代式回收
标记清除策略每次垃圾回收都需要遍历检查所有的对象,所需时间长;而分代式回收将内存对象进行划分区域,不需要频繁清理,大程度提高了垃圾回收机制的效率。
AO内存对象划分:
- 大的老的内存,存在时间长 —— 老生代
- 小的新的内存,存在时间短 —— 新生代(1-8M)
1.1 新生代垃圾回收
将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区
大致流程:
- 新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。
- 当开始进行垃圾回收时,新生代垃圾回收器中使用区的AO进行标记,之后将使用区的AO复制到空闲区并排序,随之进入GC阶段,最后进行互换,将原来的使用区变成空闲区,空闲区变为使用区。
- 当反复操作后,仍然存在的AO会被移动到老生代进行管理
- **特例:**当一个对象复制到空闲区时发现其占用25%内存空间时,该对象会直接转移到老生代区域
1.2 老生代垃圾回收
老生代使用标记清除的GC策略,并且V8使用了标记整理的算法进行了内存空间的优化,避免了碎片化。
2. 并行回收
JavaScript是一门单线程语言,他是运行在主线程上的,那么在进行垃圾回收的时候是会堵塞JavaScript的执行,等GC完成之后在恢复运行,这一过程被称为 全停顿(Stop-The-World)。
每一次GC的时间会造成JS的堵塞,时间过长就会造成页面卡顿,因此V8引擎引入了并行回收机制,引入多个辅助线程同时处理,加速GC的执行速度。
3. 增量标记与惰性清理
并行回收增加了GC的效率,对于新生代的GC有很好的优化,但是仍然是一种 全停顿的GC方式。对于比较大的对象,全停顿的GC策略还是可能会存在消耗大量时间的。
因此就引入了增量标记和惰性清理的方式
- 增量标记:将一次GC标记的过程,分为很多个小步骤,小步骤和JavaScript交替执行,多次交替就会完成一次GC的标记过程。
- **三色标记法:**用不同的标识去标记不同的标记状态,用于GC的暂停和恢复
- 惰性清理:当标记完成后,开始惰性清理;当目前内存可以让JS快速执行,则优先执行JS代码,将清理过程延后,同时也不是一次性将所有的非AO对象清理,而是逐一清理,直到清理完毕。
4. 并发回收
并行回收会堵塞主线程,增量标记同样也是和主线程交替执行,并没有和JS线程分开,实质性问题仍然没有解决。
并发回收: 主线程在执行JS代码时,辅助线程可以在后台完成GC 的操作,辅助线程在执行垃圾回收的时候主线程可以自行执行不会被挂起。
5. 总结
在新生代GC中可以使用并行回收,可以很好的增加垃圾回收的效率;而在老生代中,由于内存较大,因此是这几种策略融合使用的。
内存泄漏
什么是内存泄漏?
GC主要是针对一些不具有可达性的对象进行清理和释放内存,在代码逻辑中,我们认为不在用到的对象,可能因为我们的操作导致该对象仍然被引用导致没有被及时收回的现象被称为内存泄漏(Memory Leak)
常见的内存泄漏
1. 不正当的闭包
闭包是指有权访问到另一个函数作用域中变量的函数
-
正确的闭包:返回的函数没有对fn函数中变量存在引用
function fn() { let a = 1 return function () { console.log('a = 1') } } -
不正确的闭包:因为返回的函数存在着对fn函数中a变量的引用,所有a不会被回收,造成了内存泄漏
function fn() { let a = 1 return function () { console.log(`a = ${ a }`) } }解决方案: 在函数调用后,将引用函数的引用关系值为空
function fn() { let a = 1 return function () { console.log(`a = ${ a }`) } } let leakFn = fn() leakFn() leakFn = null
2. 隐式的全局变量
全局作用域是一直存在的,因此垃圾回收器很难判断这些全局变量什么时候不在被需要;而函数局部作用域在函数执行完毕之后,就表明局部作用域中的变量不在需要了,可以被清除。
因此不小心将变量挂载到全局作用域中,会导致内存的压力会变大。
function fn() {
let a = 1 // let 产生块级作用域 a 在fn的局部作用域中
this.b = 3 // this === window 因此 b 挂载到了window上面
}
fn() // fn()的执行 => window.fn()
fn的执行,因为 没有声明 和 函数中this 的问题造成了两个额外的隐式全局变量,这两个变量不会被回收,这种情况我们要尽可能的避免。
在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理,不然的话数据量越来越大,内存压力也会随之增高。
3. 定时器
定时器:setTimeout 和 setInterval
let msg = 'hello world'
setInterval(() => {
const node = document.getElementById('#root')
if(node) {
node.innerHTML = msg + Math.random()
}
}, 1000)
在setInterval结束之前,回调函数中始终引用了外部变量msg,因此该变量没发被回收。
因此当不需要interval的时候,使用clearInterval清除他,同样的setTimeout也是如此,还有requestAnimationFrame和cancelAnimationFrame搭配使用。
4. 事件监听器
当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收。
拿 Vue 组件来举例子
<template>
<div></div>
</template>
<script>
export default {
created() {
window.addEventListener("keydown", this.doSomething)
},
beforeDestroy(){
window.removeEventListener("keydown", this.doSomething)
},
methods: {
doSomething() {
// do something
}
}
}
</script>
5. Map、Set对象
当使用Map、Set存储对象时,使用的是强引用,如果不是主动清除引用,内存不会自动回收
let obj = {id: 1}
let set = new Set([obj])
let map = new Map()
map.set(obj, 'aaa')
// 重写obj
obj = null
// 先执行上面的,再输出
console.log(set) // Set(1) {{…}}
console.log(map) // Map(1) {{…} => 'aaa'}
解决方案: 使用weakMap, weakSet弱引用,对象销毁,引用的内存也销毁。(注:WeakMap 只对于键是弱引用, 且必须为一个对象)
let obj1 = {id: 1}
let set1 = new WeakSet([obj1])
let map1 = new WeakMap()
map1.set(obj1, 'aaa')
// 重写obj
obj1 = null
// 先执行上面的,再输出
console.log(set1) // WeakSet {}
console.log(map1) // WeakMap {}
6. console
我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。
所以,开发环境下我们可以使用控制台输出来便于我们调试,但是在生产环境下,一定要及时清理掉输出。
7. 游离的DOM引用
代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放
<div id="root">
<ul id="ul">
<li id="li3"></li>
<li></li>
</ul>
</div>
<script>
let root = document.querySelector('#root')
let ul = document.querySelector('#ul')
let li3 = document.querySelector('#li3')
// 由于ul变量存在,整个ul及其子元素都不能GC
root.removeChild(ul)
// 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
ul = null
// 已无变量引用,此时可以GC
li3 = null
</script>
我们将父节点置空,但是被删除的父节点其子节点引用也缓存在变量里,那么就会导致整个父 DOM 节点树下整个游离节点树均无法清理,还是会出现内存泄漏,解决办法就是将引用子节点的变量也置空。
前端常见内存问题
- 上述常见问题
- 内存膨胀:短时间内内存占用急速上升到峰值,需要减少对内存的占用
- 频繁的GC:GC执行频繁会页面卡顿,需要避免过多的临时变量
内存泄漏排查与定位
示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>test</title>
</head>
<body>
<button id="click">click</button>
<h1 id="content"></h1>
<script>
let click = document.querySelector('#click');
let content = document.querySelector('#content');
let arr = [];
function closures() {
let test = new Array(10000).fill('哈哈哈');
return function () {
return test;
};
}
click.addEventListener('click', function () {
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
arr.push(closures());
content.innerHTML = arr.length;
});
</script>
</body>
</html>
1. 排查问题
- 使用Chrome的devTool开发者工具来排查问题
- 点击录制之后,开始操作,结束录制之后;都能看到每次起伏当前的页面操作,就可以排查哪一步操作导致的内存泄漏。
2. 定位问题
- 使用另一个功能内存
- 开始录制初始状态:先别操作,录制初始状态
-
根据排查到的问题,操作一次录制一次,查看操作前后的对比
- 第一次操作
-
第二次操作
-
对比
-
通过对比,很明显发现了前后closure函数,很明显是closure函数给arr数组push之后,没有清除掉自身的闭包引用的变量。
- 20行的闭包引用的test数组导致的内存泄漏
- 全局变量arr数组元素不断变多造成的内存泄漏