引言
JavaScript是一种拥有垃圾回收(Garbage Collection,简称GC)机制的语言,使开发者不必浪费太多精力在内存管理工作中,提升开发效率和代码容错性。每种引擎的GC算法都或多或少存在一定差异,但在开发过程中也会因为疏忽导致内存泄漏。
内存生命周期
- 分配内存- c语言通过malloc()方法申请内存,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存。
- 使用内存- 在得到的内存中进行读、写的操作。
- 释放内存- c语言通过free()方法释放内存,而JavaScript的GC机制则会跟踪内存分配和使用情况,以便找到何时不再需要分配的内存,在这种情况下,它会自动释放它。
let n = 123; // 给数值变量分配内存
let s = "azerty"; // 给字符串分配内存
function f(a){
return a + 2;
} // 给函数(可调用的对象)分配内存
let d = new Date(); // 分配一个 Date 对象
let e = document.createElement('div'); // 分配一个 DOM 元素
常见的两种GC算法
关键术语:
-
对象(Object)- “一个供应用程序使用的数据所对应的内存集合”。由头(head)和域(field)组成,header与计算机网络的报文头相似,用于辅助GC算法的实施,(我理解为对象的引用地址)。fields用于存储数据(我理解为值),是一个统称,一个fields包含多个filed。
-
根(Root)- GC领域所有的对象组成一个树形结构,Root便是树的根节点,可以理解为浏览器的
window对象或是Node.js的Global对象。
1. 标记清除算法
- b和f直接被根节点引用,可理解为全局变量。
- b引用了c,f引用了e,e引用了f,a引用了d,但未被根引用,g没有被引用,也没有引用其他对象。
① 标记阶段,作用是以根节点作为起点,使用深度优先搜索,向下遍历所有对象,在搜索到的对象头部进行标记。根据图中引用关系,直接被根节点引用的b和e以及被两者引用的c和f,被添加了标记信息。而b、d和g未被标记。
② 清除阶段,在标记结果的基础上删除所有未标记的对象,并且清楚已标记对象头部中的标记信息以便下一次GC流程顺利进行。
③ 合并, 未标记的对象被清除后,对应的内存空间成为空闲状态,造成对象被分块存储在内存中,这种状态被称为碎片化。碎片化的危害有两点:1、分块的内存查询效率远低于连续内存;2、造成存储空间的浪费。比如三个2M的分块内存无法满足需要4M内存的应用程序,所以往往在清除完成后会对空闲内存进行合并。
var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
// 函数执行完成后,a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
a++
var c = a + b
return c
}
2. 引用计数算法
IE6和IE7引擎所在用的GC算法,建立在微软的COM技术的基础之上,于是沿用的相同的GC策略。引用计数的工作原理是:每个对象的头部中有一个计数器用于记录用用他的对象个数,每当产生新的引用时,计数器+1,反之-1。当计数器的值为0时则无引用,可以被安全的回收销毁。
引用计数有个缺点:无法处理循环引用。在上面图形中,a和d相互引用,但没有被其他任何对象引用,即使没有用了也不会被回收。
function f() {
let o1 = {};
let o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1. 这里会形成一个循环引用
}
f();
内存泄漏
JavaScript是有自己的内存回收机制,可以确定那些变量不再需要,并将其清除。但是当代码存在逻辑缺陷的时候,以为已经不需要,但是程序中还存在着引用,导致程序运行完后并没有合适的回收所占用的空间,导致内存不断的占用,运行的时间越长占用的就越多,随之出现的是,性能不佳,高延迟,频繁崩溃。
常见的内存泄漏及避免方式
1. 错误的引入了全局变量
在非严格模式下当引用未声明的变量时,会在全局对象中创建一个新变量。在浏览器中,全局对象将是window
function foo() {
bar1 ="some text"; // bar将泄漏到全局.
// 或者
this.bar2 = new Array(100) // arr同样被window引用
}
foo()
全局变量是根据定义无法被垃圾回收机制收集,且存在命名冲突、破坏封装性等危害,需要特别注意用于临时存储和处理大量信息的全局变量。
解决办法: 函数内部的变量需要先申明在使用;严格模式(未定义变量直接赋值抛出ReferenceError);如果必须使用全局变量来存储数据,确保将其指定为null或在完成后重新分配它
2. 及时销毁定时器和回调函数
const someResource = getData();
setInterval(function() {
let node = document.getElementById('Node');
if(node) {
node.innerHTML = JSON.stringify(someResource));
// 定时器也没有清除
}
// node、someResource 存储了大量数据 无法回收
}, 1000);
原因:与节点或数据关联的计时器不再需要,node 对象可以删除,整个回调函数也不需要了。定时器没有清楚,一直在执行。同时,someResource 如果存储了大量的数据,也是无法被回收的。
解决方法: 在定时器完成工作的时候,手动清除定时器
3. 谨慎处理闭包
function fn1() {
var a = 4
return function fn2() {
console.log(++a)
}
}
var f = fn1()
f() // 5
f() // 6 变量a一直在内存中
使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染,缺点是由于闭包会使得函数中的变量都被保存在内存中,会增大内存使用量,使用不当很容易造成内存泄露。
解决办法:f = null;使用闭包时将数据进行合理的作用域划分,分清哪些适用于放在哪外层作用域,哪些适合放在返回的函数中。
4. 注意DOM以外的引用和及时移除对DOM添加的事件
var dom = document.getElementById('domID');
document.body.removeChild(dom); // dom删除了
console.log(dom); // 但是还存在引用
// 能console出整个dom元素没有被回收
原因:有变量引用了 dom ,即便从 dom 树中移除,内存中仍有变量引用。
解决办法:dom = null
var button = document.getElementById('button');
function onClick(event) {
button.innerHTML = 'text';
}
button.addEventListener('click', onClick);
原因: 给元素button添加了一个事件处理器onClick, 而处理器里面使用了button的引用。而老版本的 IE 是无法检测 DOM 节点与 JavaScript 代码之间的循环引用,因此会导致内存泄漏。
解决办法: button.removeEventListener('click');
如今,现代的浏览器(包括 IE 和 Microsoft Edge)的垃圾回收算法,已经可以正确检测和处理循环引用了。换言之,回收节点内存时,不必非要调用 removeEventListener 了。(这样是不是不太好?)
React中常见的内存泄漏
两种常见的情况:
1.使用了 setTimeout(),并且在回调函数里执行 setState()。如果在回调函数被执行之前就离开页面导致目标组件被卸载了的话,那么当回调函数被执行之后就会发生内存溢出的错误。
解决办法: 在组件卸载的时候清除定时器
// class component
componentDidMount() {
// 定时器执行setState造成的内存泄漏
this.timeout = setTimeout(function () {
setState(data);
}, 1000);
}
componentWillUnmount() {
// 组件即将卸载时先清除 setTimeout()
clearTimeout(this.timeout);
}
// function component
useEffect(() => {
const timeout = setTimeout(function () {
setState(data);
}, 1000);
// 返回 clean up 函数。在组件即将被卸载时,React 会执行该函数
return () => {
clearTimeout(timeout);
};
}, []);
2.在异步请求的回调函数中执行了 setState(),在回调函数执行完成之前目标组件就被卸载了。
解决办法:
- 方法一:定义变量,在组件销毁后不进行setState操作,不取消http请求。
- 方法二:在组件销毁之后取消http请求。
此处仅展示了函数组件的方法
方法一:
useEffect(() => {
let isUnmounted = false; // 定义变量
(async () => {
const res = await fetch(SOME_API);
const data = await res.json();
if (!isUnmounted) { // 为true时不进行setState操作
setValue(data.value);
setLoading(false);
}
})();
return () => {
isUnmounted = true;
}
}, []);
方法二:
AbortController 是一个浏览器的实验接口,它可以返回一个信号量(singal),从而中止发送的请求。
useEffect(() => {
const abortController = new AbortController(); // 创建
(async () => {
const res = await fetch(SOME_API, {
signal: abortController.signal, // 当做信号量传入
});
const data = await res.json();
setValue(data.value);
setLoading(false);
})();
return () => {
abortController.abort(); // 在组件卸载时中断
}
}, []);
let xhr = new XMLHttpRequest();
xhr.method = 'GET';
xhr.url = 'https://slowmo.glitch.me/5000';
xhr.open(method, url, true);
xhr.send();
// Abort the request at a later stage
abortButton.addEventListener('click', function() {
xhr.abort();
});
内存泄漏的检测
具体可参考: chrome内存泄露(一)、内存泄漏分析工具
1.浏览器方法
- 使用浏览器的开发者工具,低版本Timeline,高版本Performance 选择 Memory。
- 点击左上角灰色圆形的录制按钮。
- 在页面上进行各种操作,模拟用户的使用情况。
- 一段时间后,点击 stop 按钮,面板上就会显示这段时间的内存占用情况。
如下图,曲线基本平稳,且数据在一定范围内波动则正常。如果曲线及相关数据居高不下,则可能存在内存泄漏
(一)(二)
2.服务器环境
使用 Node 提供的process.memoryUsage方法。参考 ES6 WeakMap
console.log(process.memoryUsage());
// 输出
{
rss: 27709440, // resident set size,所有内存占用,包括指令区和堆栈
heapTotal: 5685248, // "堆"占用的内存,包括用到的和没用到的
heapUsed: 3449392, // 用到的堆的部分
external: 8772 // V8 引擎内部的 C++ 对象占用的内存
}
// 判断内存泄漏,以heapUsed字段为准。
如何优化 GC
- 合理设计页面,按需创建对象/渲染页面/加载图片等, 避免一次性请求全部数据。
- 不再使用的对象,手动赋值为 null 。减少虚拟机扫描内存时扫描次数等。
- 使用
WeakMap和WeakSet。 - 添加的侦听器需要移除。
- 生产环境勿用 console.log 大对象,包括 DOM 、大数组、ImageData、ArrayBuffer 等。因为 console.log 的对象不会被垃圾回收。
- 预加载图片时(使用动态创建 img 设置 src 方式),要将 img 对象赋为 null ,否则会导致图片内存无法释放。当实际渲染图片时,浏览器会从缓存中再次读取。
- ...