前言
像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()用于分配内存和释放内存。
而对于JavaScript来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。
因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。
内存模型
JS内存空间分为栈(stack)和堆(heap),其中栈存放变量,堆存放复杂对象。
JS中五种基本的数据类型Undefined、Null、Boolean、Number、String,是按值访问,数据在栈内存中的存储。
JS的引用数据类型,比如数组Array,它们值的大小是不固定的,引用数据类型的真实对象是保存在堆内存中的。
变量声明的本质是变量名与栈内存地址进行绑定,不直接与堆内存进行绑定。
声明的基本数据类型会将值存储在栈内存中,声明的复杂数据类型会将值存储在堆内存中并将其在堆中的内存地址作为值存到栈内存中。
例子:
基本数据类型
let index = 23
为变量(index)创建一个唯一标识符。
为变量分配一个内存地址(运行时)。
在分配的地址中存储一个值(23)。
基础数据类型直接将值存储在栈内存中,变量绑定到值在栈中对应的地址。
let _index = index
声明另一个变量_index并赋值为index,其实是将_index和index变量绑定到index指向的内存地址。
index = 45
修改变量index的值为基本数据类型,其实是在栈内存中分配内存存储值然后将得到的内存地址绑定到变量index。
复杂数据类型
let students = []
为变量(students)创建一个唯一标识符。
在栈中给变量分配一个地址a(运行时)。
在堆中分配一个地址b,用来存储值 [](运行时)。
地址a所存储的值为地址b。
复杂数据类型在声明时是在堆内存上分配内存空间存储其值,将分配的堆内存空间地址作为值存储在栈内存上,变量直接绑定的是栈上内存地址。
通过引用来修改复杂数据:
let _students = students
_students.push({ name: '小明' })
_status = students 赋值语句只是将两个变量指向同一个栈内存地址,push()语句将在堆内存中分配新空间存储新的数组并将其在堆内存的地址存储到栈中
let obj = { name: '小明' }
let arr = []
arr = [ obj ]
obj = null
内存模型示意图如下:
[obj]属于复杂类型中引用复杂类型是通过指针引用处理,虽然通过obj=null来清除了obj对于对象{index:‘小明’}的绑定,但是arr对该对象任然存在引用。
内存生命周期
JS 环境中分配的内存有如下生命周期:
- 内存分配:当我们声明变量、函数、对象的时候,系统会自动为他们分配内存
- 内存使用:即读写内存,也就是使用变量、函数等
- 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存
为了便于理解,我们使用一个简单的例子来解释这个周期:
var a = 20; // 在内存中给数值变量分配空间
alert(a + 100); // 使用内存
var a = null; // 使用完毕之后,释放内存空间
JS 的内存回收
JS 有自动垃圾回收机制,回收机制的原理是:找出那些不再继续使用的值,然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。
然而大多数内存管理的问题都在这个阶段,在这里最艰难的任务是找到不再需要使用的变量:
- 在局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。
- 但是全局变量什么时候需要自动释放内存空间则很难判断,因此在我们的开发中,需要尽量避免使用全局变量,以确保性能问题。
全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。
js的垃圾回收机制
对垃圾回收算法来说,核心思想就是如何判断内存已经不再使用了。
- 引用计数垃圾收集
这是最简单的垃圾收集算法:此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用), 对象将被垃圾回收机制回收。示例:
// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
age: 12,
name: 'aaaa'
};
var p = person;
person = 1; //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收
p = null; //原person对象已经没有引用,很快会被回收
由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return "azerty";
}
f();
- 标记-清除
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
这个算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”。
工作流程:
1.垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
2.从根部出发将能触及到的对象的标记清除。
3.那些还存在标记的变量被视为准备删除的变量。
4.最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。
内存泄漏
什么是内存泄露:对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。
常见的内存泄露案例
1.全局变量
function foo(arg) {
bar = "some text";
}
在 JS 中处理未被声明的变量, 上述范例中的 bar时, 会把bar, 定义到全局对象中, 在浏览器中就是 window 上. 在页面中的全局变量, 只有当页面被关闭后才会被销毁. 所以这种写法就会造成内存泄露, 当然在这个例子中泄露的只是一个简单的字符串, 但是在实际的代码中, 往往情况会更加糟糕.
另外一种意外创建全局变量的情况.
function foo() {
this.var1 = "potential accidental global";
}
// Foo 被调用时, this 指向全局变量(window)
foo();
在这种情况下调用foo, this被指向了全局变量window, 意外的创建了全局变量.
我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null.
2 未销毁的定时器和回调函数
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 每 5 秒调用一次
如果后续 renderer 元素被移除, 整个定时器实际上没有任何作用. 但如果你没有回收定时器, 整个定时器依然有效, 不但定时器无法被内存回收, 定时器函数中的依赖也无法回收. 在这个案例中的 serverData 也无法被回收。
3. 闭包
const generateFn = () => {
const obj = { index: 1 }
return() => {
return obj
}
}
const obj2 = generateFn()() // 此时obj2就指向了上面定义的obj
4. DOM引用
很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。
var elements = {
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
document.body.removeChild(document.getElementById('image'));
// 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}
上述案例中, 即使我们对于 image 元素进行了移除, 但是仍然有对 image 元素的引用, 依然无法对其进行内存回收.
另外需要注意的一个点是, 对于一个 Dom 树的叶子节点的引用. 举个例子: 如果我们引用了一个表格中的td元素, 一旦在 Dom 中删除了整个表格, 我们直观的觉得内存回收应该回收除了被引用的 td外的其他元素. 但是事实上, 这个td 元素是整个表格的一个子元素, 并保留对于其父元素的引用. 这就会导致对于整个表格, 都无法进行内存回收. 所以我们要小心处理对于 Dom 元素的引用.
内存泄漏的识别方法
1.浏览器方法:
2.命令行方法:
process.memoryUsage() 方法返回 Node.js 进程的内存使用情况的对象,该对象每个属性值的单位为字节。
console.log(process.memoryUsage());
// {
// rss: 4935680,
// heapTotal: 1826816,
// heapUsed: 650472,
// external: 49879
// }
- rss(resident set size):所有内存占用,包括指令区和堆栈。
- heapTotal:"堆"占用的内存,包括用到的和没用到的。
- heapUsed:用到的堆的部分。
- external: V8 引擎内部的 C++ 对象占用的内存。
判断内存泄漏,以heapUsed字段为准
总结
即便在 JS 中, 我们很少去直接去做内存管理. 但是我们在写代码的时候, 也要有内存管理的意识, 谨慎的处理可能会造成内存泄露的场景。