JavaScript 内存管理学习笔记
本文旨在回到如下几个问题:
- JavaScript 通俗的内存管理机制
- 内存泄漏与闭包
- 会引起内存泄漏的编码习惯有哪些以及如何避免
4. 如何利用 chrome 浏览器 devtool 工具来查看脚本运行时的内存泄漏情况
JavaScript 内存管理机制
对于计算机来说,内存有一个生命周期,分为:分配期,使用期,释放期;编程语言利用内存也是基于这个生命周期进行。对于 JavaScript 来说,内存分配是语言机制(引擎)自动实现的,当我们定义变量时,JavaScript 便为我们分配了所需要的内存。如:
const val1 = 'string';
let arr = [1,2,3];
var obj = {};
const func = function(arr){console.log(arr)};
复制代码
上述代码直接进入使用期了,因为在定义的时候就已经被自动分配了内存了。
那内存是什么时候被释放的呢?当我们不需要的时候就会释放了,那如何判断不需要这块内存了呢?很难。但幸运的是,这一般也不需要我们手动去释放,这也是 JavaScript 引擎去做的事。
基于这两个现状,前端人员很容易以为不需要进行内存管理,因为我们很少需要手动的去申请或释放内存,我们只有使用场景。但是,为什么有时候页面还是会卡顿甚至崩溃呢?这是因为语言机制自动释放内存存在局限性,所以为了程序运行良好,内存管理还是得做一下的。
那么,接下来我们来了解一下 JS 引擎(V8)的GC(Garbage Collection,垃圾内存回收)机制吧。
引用计数垃圾收集
最简单的垃圾收集机制,近似理解为,当一块内存被人引用时,就将引用数量+1,随着程序的执行,内存没有被引用了,引用数量为0,那么 JS 引擎就将这块内存进行重新分配。that’s all.
但是这个机制存在一个缺陷:无法解决循环引用问题,如
function temp() {
const va = {...};
const vb = {...};
va.b = vb;
vb.a = va;
}
temp()
复制代码
temp 函数执行完成之后,本来该释放内存了,但是 va
和 vb
都互相引用了对方一次,导致无法被收回内存。
标记清除垃圾收集
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
这个算法假定设置一个叫做根(root)的对象(在Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”。
它的限制是:那些无法从根对象查询到的对象都将被清除 — MDN
内存泄漏与闭包
内存泄露是指你「用不到」(访问不到)的变量,依然占居着内存空间,不能被再次利用起来。闭包里面的变量就是我们需要的变量,不能说是内存泄露。
一个更短的数学说明:闭包是内存泄漏的子集(突然想到《JavaScript 语言精粹》)
简单理解闭包
闭包是跨作用域访问变量。闭包无处不在。详情戳文章的 reference 资料。
内存泄漏和内存溢出
内存泄漏
- 不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。内存泄漏是指程序执行时,一些变量没有及时释放,一直占用着内存,而这种占用内存的行为就叫做内存泄漏。
- 作为一般的用户,根本感觉不到内存泄漏的存在。
- 真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积。也即内存泄漏如果一直堆积,最终会导致内存溢出问题
内存溢出
- 内存溢出一般是指执行程序时,程序会向系统申请一定大小的内存,当系统现在的实际内存少于需要的内存时,就会造成内存溢出
- 内存溢出造成的结果是先前保存的数据会被覆盖或者后来的数据会没地方存
JavaScript 中的现象往往是页面卡顿,崩溃等。(特指浏览器)
场景和修复
JavaScript 的内存回收机制虽然能回收绝大部分的垃圾内存,但是还是存在回收不了的情况。程序员要让浏览器内存泄漏,浏览器也是管不了的。
下面有些例子是在执行环境中,没离开当前执行环境,还没触发标记清除法。
意外的全局变量
// 浏览器 console 中
function log() {
a = 1 + 1;
console.log(a);
}
// 这里的 a 没有用声明关键字声明,导致被存在全局对象中,这就是意外,不过这种状况真的很少了
// 主要还是说的是配置太多的全局属性
复制代码
遗忘的定时器引用
// bad
setTimeout(() => {
if(domLoaded) {
findNode(){};
}
}, 300}
// good
const timer = setTimeout(() => {
if(domLoaded) {
findNode(){};
clearTimeout(timer);
}
}, 300}
// 如果是在 Vue 中,记得在组件销毁前注销定时器引用,或使用 $once('hook:beforeDestroy',function(){}) 钩子
复制代码
遗忘的事件监听器引用和脱离 DOM 的引用
const node = document.getElementById(nodeId);
node.addEventListener('click', clickFunc,false) {};
// 到目前位置一切还好,但是如果一直不移除事件监听器的话,会有两个问题,
// 第一个问题是这个 dom 节点一直占据着内存,但是无法保证这个 dom 节点还在页面上,
// 也就是说,这个 dom 节点的有效性无法保证
// 第二个问题是事件监听器依然会占据着内存(dom 节点的事件侦听器列表增加了)
// 但是,如果我们及时消除(离开页面,完成操作后),那一切都是完美的
node.removeEventListener('click', clickFunc,false) {};
复制代码
写的太过冗余的闭包
// 虽然这种情况是有可能发生的,但是其实这样的写法是很奇怪的
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(originalThing);
}
};
};
setInterval(replaceThing, 1000);
复制代码
每次调用 replaceThing ,theThing 会创建一个大数组和一个新闭包(someMethod)的新对象。同时,变量 unused 是一个引用 originalThing(theThing) 的闭包,闭包的作用域一旦创建,它们有同样的父级作用域,作用域是共享的。
即 someMethod 可以通过 theThing 使用,someMethod 与 unused 分享闭包作用域,尽管 unused 从未使用,它引用的 originalThing 迫使它保留在内存中(防止被回收)。
因此,当这段代码反复运行,就会看到内存占用不断上升,垃圾回收器(GC)并无法降低内存占用。
Map 和 Set
这两个 ES6 新对象特别好用,但是会有一点点的内存管理问题(吹毛求疵了属于是,但切实存在)
如下是有内存泄漏的(键值是引用类型的,即对象)
let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
key = null;
复制代码
需要手动清理或者使用 weakMap
let map = new Map();
let key = new Array(5 * 1024 * 1024);
map.set(key, 1);
// 手动清理
key = null;
map.delete(key);
// 使用 weakMap
let wmap = new weakMap();
let wkey = new Array(5 * 1024 * 1024);
wmap.set(wkey,10);
wkey = null; // that's all!
复制代码
同理,Set 和 weakSet 同样如此,不再赘述。
为什么是这样的呢,因为 Map 和 Set 为了可枚举,使用的是键强引用,含义是,即使作为键的变量被置空了,键依然保留着原来传递的内存地址,也即是复制了一份内存地址,而 weakMap 和 weakSet 则是存的是变量的地址,被叫做键的弱引用,这样一来,当键被置空,相当于它的键也就是空,就不会有内存占用了。