JavaScript 内存管理

453 阅读9分钟

引言

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 。减少虚拟机扫描内存时扫描次数等。
  • 使用 WeakMapWeakSet
  • 添加的侦听器需要移除。
  • 生产环境勿用 console.log 大对象,包括 DOM 、大数组、ImageData、ArrayBuffer 等。因为 console.log 的对象不会被垃圾回收。
  • 预加载图片时(使用动态创建 img 设置 src 方式),要将 img 对象赋为 null ,否则会导致图片内存无法释放。当实际渲染图片时,浏览器会从缓存中再次读取。
  • ...

参考链接

V8内存管理机制
深度理解浏览器内存管理