1、JavaScript的内存管理
1.1、 栈内存与堆内存
JavaScript中的变量分为基本类型和引用类型。基本类型就是保存在栈内存中的简单数据段,而引用类型指的是那些保存在堆内存中的对象。
1.1.1、基本类型
基本类型有Undefined、Null、Boolean、Number ,String和BigInt。这些类型在内存中分别占有固定大小的空间,他们的值保存在栈空间,我们通过按值来访问的。
1.1.2、引用类型
引用类型,值大小不固定,栈内存中存放地址指向堆内存中的对象。是按引用访问的。如下图所示:栈内存中存放的只是该对象的访问地址,在堆内存中为这个值分配空间。由于这种值的大小不固定,因此不能把它们保存到栈内存中。但内存地址大小是固定的,因此可以将内存地址保存在栈内存中。这样,当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做按引用访问。
![](./images/stc.png)
那么为什么会有栈内存和堆内存之分?
这通常与垃圾回收机制有关。为了使程序运行时占用的内存最小。当一个方法执行时,每个方法都会建立自己的内存栈,在这个方法内定义的变量将会逐个放入这块栈内存里,随着方法的执行结束,这个方法的内存栈也将自然销毁了。因此,所有在方法中定义的变量都是放在栈内存中的(如果是对象,那么保存在堆中,然后栈中中保存的是一个引用);当我们在程序中创建一个对象时,这个对象将被保存到运行时数据区中,以便反复利用(因为对象的创建成本通常较大),这个运行时数据区就是堆内存。堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用(方法的参数传递时很常见),则这个对象依然不会被销毁,只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在合适的时候回收它。
同时,堆栈的区分能够让代码执行更加安全,同时也更加快(不需要动态栈帧的垃圾回收,而只是创建新的栈帧)。比如下面的例子:
function foo() { var a = 1; }
function bar() { var b = 2; foo(); }
bar();
我们的方法是递归调用,那么每一个栈帧也会有自己的一份独立的本地变量。当函数执行完毕以后,该栈帧就会从栈中被移除,释放本地变量的内存分配。这也是为什么类似于C++,C的语言不需要去考虑释放本地变量的原因。
1.2 内存的回收策略
引用计数的循环引用
引用计数的机制为:
(1)当我们创建了一个对象,然后将它存储到一个变量中,那么该对象的引用数量就是1,而当它的引用又被用于另外一个变量或者函数中的时候,它的引用数量就是2。
(2)如果我们将使用变量的引用值的变量设置为一个新的值,那么原来的变量和使用引用变量的引用数就是1。
(3)如果最后对象被置空了,那么引用数量就是0。
下面是示例代码:
var a = {obj:{name:"my_name"}};
var b = a;
a = 1;
//Here we created an object, which is used by variable a and b
// we have 1 to a so one of objects reference is reduced to 1
// still we have one reference which is b
//注意:虽然a此时已经被重置了,但是在此处打印b依然会引用原来的变量
b = null;
// now object has not any reference left, garbage collector will take this object.
而引用计数算法可能出现循环引用,最终两者都无法经过垃圾回收器进行回收。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 references o2
o2.p = o1; // o2 references o1. This creates a cycle.
}
f();
标记清除
2、 内存泄露的方式
2.1、意外的全局变量
即使我们讨论了不可预测的全局变量,但是仍有一些明确的全局变量产生的垃圾。这些是根据定义不可回收的(除非被取消或重新分配)。特别地,用于临时存储和处理大量信息的全局变量是令人关注的。 如果必须使用全局变量来存储大量数据,请确保将其置空或在完成后重新分配它。与全局变量有关的增加的内存消耗的一个常见原因是高速缓存)。缓存存储重复使用的数据。 为了有效率,高速缓存必须具有其大小的上限。 无限增长的缓存可能会导致高内存消耗,因为缓存内容无法被回收。
2.2、被遗忘的计时器或回调函数
比如下面的例子:
var someResource = getData();
setInterval(function() {
var node = document.getElementById("Node");
if(node) {
// Do stuff with node and someResource.
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
此示例说明了挂起计时器可能发生的情况:引用不再需要的节点或数据的计时器。 由节点表示的对象可以在将来被移除,使得区间处理器内部的整个块不需要了。但是,处理程序(因为时间间隔仍处于活动状态)无法回收(需要停止定时器才能发生)。 如果无法回收定时器,则也无法回收其依赖项。这意味着someResource,它可能存储大小的数据,也不能被回收。解决方法就是在DOM移除的时候清除定时器。
对于观察者的情况,重要的是进行显式调用,以便在不再需要它们时删除它们(或者相关对象即将无法访问)。 在过去,以前特别重要,因为某些浏览器(Internet Explorer 6)不能管理循环引用。 现在,一旦观察到的对象变得不可达,即使没有明确删除监听器,大多数浏览器也可以回收观察者处理程序。 然而,在对象被处理之前显式地删除这些观察者仍然是良好的做法。 例如:
var element = document.getElementById("button");
function onClick(event) {
element.innerHtml = "text";
}
element.addEventListener("click", onClick);
// Do stuff
element.removeEventListener("click", onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don"t
// handle cycles well.
2.3、脱离DOM的引用要置空
有时,将DOM节点存储在数据结构中可能很有用。 假设要快速更新表中多行的内容。 在数组中存储对每个DOM行的引用可能是有意义的。当发生这种情况时,会保留对同一个DOM元素的两个引用:一个在DOM树中,另一个在数组中。 如果在将来的某个时候,您决定删除这些行,则需要使这两个引用不可访问。
var elements = {
button: document.getElementById("button"),
image: document.getElementById("image"),
text: document.getElementById("text")
};
function doStuff() {
image.src = "http://some.url/image";
button.click();
console.log(text.innerHTML);
// Much more logic
}
function removeButton() {
// The button is a direct child of body.
document.body.removeChild(document.getElementById("button"));
// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.
}
对此的另外考虑与对DOM树内的内部或叶节点的引用有关。假设您在JavaScript代码中保留对表的特定单元格(标记)的引用。 在将来的某个时候,您决定从DOM中删除表,但保留对该单元格的引用。直观地,可以假设GC将回收除了该单元之外的所有东西。在实践中,这不会发生:单元格是该表的子节点,并且子级保持对其父级的引用。 换句话说,从JavaScript代码对表单元格的引用导致整个表保留在内存中。 在保持对DOM元素的引用时仔细考虑这一点。下面再给出一个类似的内存泄露的例子:
现在,问题来了,如果我现在在dom中移除div#refA会怎么样呢?答案是dom内存依然存在,因为它被js引用。那么我把refA变量置为null呢?答案是内存依然存在了。因为refB对refA存在引用,所以除非再把refB释放,否则dom节点内存会一直存在浏览器中无法被回收掉。
在上图,红色的虽然已经不再DOM树中了,但是其依然占据着内存,所以称为detached Dom Nodes。
2.4、闭包导致的内存泄漏
function a() {
var obj = [1,2,3,4,5,6];
return function Test() {
//js作用域的原因,在此闭包运行的上下文中可以访问到obj这个对象
console.log(obj);
}
}
//正常情况下,a函数执行完毕obj占用的内存会被回收,但是此处a函数返回了一个函数表达式,其中obj因为js的作用域的特殊性一直存在,所以我们可以说b引用了obj。
var b = a();
//每次执行b函数的时候都可以访问到obj,说明内存未被回收 所以对于obj来说直接占用内存[1,2,....n], 而b依赖obj,所obj是b的最大内存。
b()
在chrome调试工具中可以查看所有自定义的函数等,也可以通过这个视图查找我们写的闭包,如下面的函数的context属性里面有一个closure:
而且我们知道:在每一次snapshot(chrome中的操作)的时候都是会提前GC的,所以如果某个元素已经被GC掉那么不会出现在上面的列表中,然而上面的的a.b变量都是存在的,所以他们根本没有被GC掉,a函数我们就不讲了,因为他是全局函数,然后我们压根不希望b长久存在,因此我们必须手动解除引用b=null!,这时候我们的b变量就变成了:
这就是告诉我们全局变量(只是一个变量)只要能够清除掉那么我们都应该清除掉,而全局函数只有在页面卸载的时候被清除,当然是否可以注册onunload事件!JavaScript开发的一个关键方面是闭包:从父作用域捕获变量的匿名函数。 Meteor开发人员发现了一个特定的情况,由于JavaScript运行时的实现细节,可能以一种微妙的方式泄漏内存:
let oStr = str.substring(2);
// String对象被销毁了,返回的是一个全新的string,原来的值并没有发生改变
第二行代码,访问 str时,访问过程处于读取模式,也就是会从栈内存中读取这个字符串的值,在读取过程中,会进行以下几步:
1.创建一个String类型的一个实例;
2.在实例上调用相应的方法。
3.销毁这个实例。
基本包装函数,与引用类型主要区别就是对象的生存期,使用new操作符创建的引用类型的实例,在执行流离开当前作用域之前一直都保存在内存中,而自动创建的基本包装类型的对象,则只存在与一行代码的执行瞬间,然后被立即销毁。这也就是不能给基本类型添加属性和方法的原因了。但是通过显示的包装基本类型却可以添加属性和方法:
var s1="some text"
s1.color="red";
// 执行后添加了color属性的这个对象已经被销毁了
alert(s1.color);
//undefined
在此,第二行代码试图为字符串s1添加一个color属性。但是,当第三行代码在此访问s1时,其color属性不见了。问题的原因就是第二行创建的String对象在执行第三行代码时已经被销毁了。第三行代码又创建自己的String对象,而该对象没有color属性。但是下面的代码就可以:
var s1=new String("some text")
s1.color="red";
console.log(s1.color);
//red
下面的例子也是同样的道理:
var s = "hello";
//定义一个由小写字母组成字符串
s.toUpperCase();
//=>“HELLO”,但并没有改变s的值。首先构造一个new String("hello")然后调用它的toUpperCase方法
//得到一个新的对象后,因为没有栈中的值对它引用,所以垃圾回收机制能够立即清除它
//但是原始的s值并没有改变,因为操作的是new String而不是s本身
s;
//=>“hello”:原始字符串的值并未改变。
而且,在我看来,不管是隐式的产生基本类型包装对象还是显式的产生,他们都会在堆中被分配内存空间,而不是在栈中!只是隐式的这种方式在调用后会将产生的基本类型包装对象立即设置为null(比如toUpperCase的例子没有栈中的变量对它进行引用),从而可以立即通过垃圾回收机制回收内存!而显式的这种方式因为存在对于基本类型包装对象的引用,所以无法立即通过垃圾回收机制回收内容!
本文总结
本文讲解了JS中最深入的垃圾回收机制,并通过很多实际的例子对该问题做了深入的探讨。希望对您有帮助,欢迎大家拍砖~
责任编辑: