浅谈js中的内存管理(栈和堆)

简介

诸如 C 语言这般的低级语言一般都有低级的内存管理接口,比如 malloc() 和free()。而另外一些高级语言,比如 JavaScript, 其在变量(对象,字符串等等)创建时分配内存,然后在它们不再使用时“自动”释放。后者被称为垃圾回收。“自动”是容易让人混淆,迷惑的,并给 JavaScript(和其他高级语言)开发者一个印象:他们可以不用关心内存管理。然而这是错误的。

在使用JavaScript进行开发的过程中,了解JavaScript内存机制有助于开发人员能够清晰的认识到自己写的代码在执行的过程中发生过什么,也能够提高项目的代码质量。

JavaScript 中的变量分为基本类型和引用类型。

  • 基本类型 (Undefined、Null、Boolean、Number和String)——原始值(原始值代表了原始的数据类型) 基本类型在内存中占据空间小、大小固定,他们的值保存在栈(stack)空间,是按值来访问

  • 引用类型 (Object,Function,Array等)——引用值
    引用类型占据空间大、大小不固定, 栈内存中存放地址指向堆(heap)内存中的对象。是按引用访问的

如下图所示:栈内存中存放的只是该对象的访问地址, 在堆内存中为这个值分配空间 。 由于这种值的大小不固定,因此不能把它们保存到栈内存中。但内存地址大小的固定的,因此可以将内存地址保存在栈内存中。 这样,当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。

当我们看到一个变量类型是已知的,就分配在栈里面,比如INT,Double等。其他未知的类型,比如自定义的类型,因为系统不知道需要多大,所以程序自己申请,这样就分配在堆里面。

栈内存&堆内存

  • 栈(stack) 栈stack为自动分配的内存空间,它由系统自动释放;
  • 堆(heap) 堆heap是动态分配的内存,大小不定也不会自动释放。

JavaScript中的内存也分为栈内存和堆内存。一般来说:

  • 栈内存中存放的是存储对象的地址;
  • 堆内存中存放的是存储对象的具体内容
  • 对于原始类型的值而言,其地址和具体内容都存在于栈内存中
  • 基于引用类型的值,其地址存在栈内存,其具体内容存在堆内存中

堆内存与栈内存是有区别的:栈内存运行效率比堆内存高空间相对推内存来说较小,反之则是堆内存的特点。

所以将构造简单的原始类型值放在栈内存中,将构造复杂的引用类型值放在堆中而不影响栈的效率

var str = "Hello World"; // str:"Hello World"存在栈中
var obj = {value:"Hello World"}; // obj存在栈中,{value:"Hello World"}存在堆中,通过栈中的变量名obj(访问地址)访问

V8的变量存放

  • handle

    handle是指向对象的指针,在V8中,所有对象都是通过handle来引用,handle主要用于V8的垃圾回收机制。进一步的,handle分为两种:

    • 持久化(Persistent handle),存放在堆上

    • 本地化(Local handle),存放在栈上

  • scope

    scope是handle的集合,可以包含若干个handle,这样就无需将每个handle逐次释放,而是直接释放整个scope。

  • context

    context是一个执行器环境,使用context可以将相互分离的JavaScript脚本在同一个V8实例中运行,而不互相干涉。在运行JavaScript脚本时,需要显示的指定context对象。

内存生命周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

在所有语言中第一步和第二步都很清晰。最后一步在低级语言中很清晰,但是第三步的操作在JavaScript中不是那么明显。

JavaScript 的内存分配

值的初始化

为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

通过函数调用的内存分配

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象

var e = document.createElement('div'); // 分配一个 DOM 元素

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果

值的使用

使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

当内存不再需要使用时释放

大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的 (无法通过某种算法解决).

来看看内存中发生了什么?

var str_a = "a"; // 为str_a分配栈内存:str_a:"a"
var str_b = str_a; // 原始类型直接访问值,so,为str_b新分配栈内存:str_b:"a"

str_b = "b"; // 栈内存中:str_b:"b"。str_b的值为"b",而str_a的值仍然是"a"
// 分隔 str 和 obj -----------------------------------------------------------//
var obj_a = {v:"a"}; // 为obj_a分配栈内存访问地址:obj_a,堆内存中存对象值:{v:"a"};
var obj_b = obj_a; // 为obj_b分配栈内存访问地址:obj_b,引用了堆内存的值{v:"a"}

// 通过obj_b访问(修改)堆内存的变量,这时候堆内存中对象值为:{v:"b"},
//由于obj_a和obj_b引用的是堆内存中同一个对象值,所以这时候打印都是{v:"b"} 
obj_b.v = "b"; 

// 因为改的是整个对象,这里会在堆内存中创建一个新的对象值:{v:"c"},而现在的obj_b引用的是这个对象,
// 所以这里打印的obj_a依旧是{v:"b"},而obj_b是{v:"c"}(两者在内存中引用的是不同对象了)。
obj_b = {v:"c"};

然后看看这个问题:

var a = {n:1}; 
var b = a; 
a.x = a = {n:2}; 
// a:{n:2} a.x=undefined b:{n:1,x:{n:2}} b.x:{n:2}
console.log(a.x);// --> undefined 
console.log(b.x);// --> [object Object]

在这里a指向了一个对象{n:1}(我们姑且称它为对象A),b指向了a所指向的对象,也就是说,在这时候a和b都是指向对象A的:

这一步很好理解,接着继续看下一行非常重要的代码:

a.x = a = {n:2};

我们知道js的赋值运算顺序永远都是从右往左的,不过由于“.”是优先级最高的运算符,所以这行代码先“计算”了a.x。

这时候发生了这个事情——a指向的对象{n:1}新增了属性x(虽然这个x是undefined的):

从图上可以看到,由于b跟a一样是指向对象A的,要表示A的x属性除了用a.x,自然也可以使用b.x来表示了。

接着,依循“从右往左”的赋值运算顺序先执行 a={n:2} ,这时候,a指向的对象发生了改变,变成了新对象{n:2}(我们称为对象B):

接着继续执行 a.x=a,很多人会认为这里是“对象B也新增了一个属性x,并指向对象B自己”

但实际上并非如此,由于( . 运算符最先计算)一开始js已经先计算了a.x,便已经解析了这个a.x是对象A的x,所以在同一条公式的情况下再回来给a.x赋值,也不会说重新解析这个a.x为对象B的x。

所以 a.x=a 应理解为对象A的属性x指向了对象B:

那么这时候结果就显而易见了。当console.log(a.x)的时候,a是指向对象B的,但对象B没有属性x。没关系,当查找一个对象的属性时,JavaScript 会向上遍历原型链,直到找到给定名称的属性为止。但当查找到达原型链的顶部 - 也就是 Object.prototype - 仍然没有找到指定的属性B.prototype.x,自然也就输出undefined;

而在console.log(b.x)的时候,由于b.x表示对象A的x属性,该属性是指向对象B,自然也输出了[object Object]了,注意这里的[object Object]可不是2个对象的意思,对象的字符串形式,是隐式调用了Object对象的toString()方法,形式是:"[object Object]"。所以[object Object]表示的就只是一个对象罢了。

从内存角度看函数传值的变化

网上不少文章是关于JavaScript传值/址的解说,根据上面对值的原始类型和引用类型的区分,也能够理解传的是值还是址。原始类型的值传的便是值,引用类型的传的是内存中对象的地址。

从代码看看区别:

var str_a = "Hello World";
function fn_a(arg){
    console.log(arg); // #1 --> Hello World
    arg = "Hai";
    console.log(str_a,arg); // #2 --> Hello World , Hai
};
fn_a(str_a);
// #3 这时候str_a:"Hello World"

从上面#1处可以看出,传入函数fn_a的是str_a的值(这时候和之前案例str_a/str_b的情况一样),并且内存中分配了新的空间来保存函数参数和其值(函数运行后自动释放这部分内存,后面或说回收机制),所以在#2处打印的是2个不同的字符串。也正是因为传值时候对str_a值进行了值的复制,而这又是原始类型的值,所以在#3处的str_a与早先声明的str_a保持一致。

var obj_a = {value:1};
function fn_a(arg){
    arg={value:2};
};
fn_a(obj_a);
// 这时候obj_a还是{value:1}
function fn_b(arg){
    arg.value=3;
};
fn_b(obj_a);
// 这时候obj_a是{value:3}

上面这个问题也可以从内存角度去理解,两个函数都是传址,而这个址引用了obj_a在内存中对应的对象,所以两个函数中的arg起初都是引用和obj_a同一个内存中的对象值,但是在fn_a中重新为arg赋值新的对象(和之前例子中的obj_a/obj_b情况一样),而fn_b中访问的依旧是和obj_a同一个内存对象,所有fn_b修改是成功的。

垃圾回收

为了使程序运行时占用的内存最小,通常要实现垃圾回收机制。

当一个方法执行时,每个方法都会建立自己的内存栈,在这个方法内定义的变量将会逐个放入这块栈存里,随着方法的执行结束,这个方法的栈存也将自然销毁了。因此,所有在方法中定义的变量都是放在栈内存中的。

当我们在程序中创建一个对象时,这个对象将被保存到运行时数据区中,以便反复利用(因为对象的创建成本开销较大),这个运行时数据区就是堆内存。堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用(方法的参数传递时很常见),则这个对象依然不会被销毁,只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。

如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。

引用

垃圾回收算法主要依赖于引用reference)的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最简单的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

示例

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;      // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
           // 他可以被垃圾回收了
           // 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

限制:循环引用

该算法有个限制:无法处理循环引用。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后不会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

实际例子

IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄漏:

var div;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

在上面的例子里,myDivElement 这个 DOM 元素里的circularReference 属性引用了myDivElement,造成了循环引用。如果该属性没有显示移除或者设为 null,引用计数式垃圾收集器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从DOM 树中删去了。如果这个 DOM 元素拥有大量的数据 (如上的lotsOfData属性),而这个数据占用的内存将永远不会被释放。

标记-清除算法

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

这个算法假定设置一个叫做(root)的对象(在Javascript里,根是全局对象)。定期的,垃圾回收器将从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和所有不能获得的对象。

这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,参考“循环引用”。

从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

循环引用不再是问题了

在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。

第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。

限制: 那些无法从根对象查询到的对象都将被清除

尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。

注意事项:

1.函数的变量只在函数执行过程中存在。在函数执行过程中,函数内部的变量将会在内存中被分配一定的空间,当函数执行完毕后,自动将这些变量从内存中释放,以留出空间作其他用处。

2.当内存中某个变量不再被引用,JavaScript也将清理掉这部分内存的分配。如:

var obj = {v:1}; // 内存中存在{v:1}对象,及obj这个引用地址
obj = {value:2}; // 垃圾回收机制自动清理{v:1},并为新的有用到的{value:2}分配空间

内存优化

就全局变量而言,JavaScript不能确定它在后面不能够被用到,所以它会从声明之后就一直存在于内存中,直至手动释放或者关闭页面/浏览器,这就导致了某些不必要的内存消耗。我们可以进行以下的优化。

使用立即执行函数

(function(){
    // 你的代码
})();

或者:

(function(window){
    // 你的代码
})(window);

如果你的某些变量真的需要一直存在 可以通过上面的方法挂载在window下。同样,你也可以传入jQuery进行使用。

手动解除变量的引用

var obj = {a:1,b:2,c:3};
obj = null;

在JavaScript中,闭包是最容易产生内存问题的,我们可以使用回调函数代替闭包来访问内部变量。使用回调的好处就是(针对访问的内部变量是原始类型的值,因为在函数传参的时候传的是值),在执行完后会自动释放其中的变量,不会像闭包一样一直将内部变量存在于内存中(但如果是引用类型,那么这个被引用的对象依旧存在内存中)。

function fn_a(){
    var value = "Hello World";
    return function(){
            return value;
        };
};
var getValue = fn_a();
var v = getValue(); // --> "Hello World"

在上面的代码中,虽然函数已经执行完毕,但是对于函数中变量value的引用还在,所以垃圾回收机制不会将函数中的value清理。
使用回调:

function fn_a(callback){
    var value = "Hello World";
    return callback(value);
};
function fn_b(arg){
    return arg;
};
var v = fn_a(fn_b);

需要注意,使用回调将会导致异步。同时声明,并不是说明这样做就一定比闭包好,闭包也有其好处,只是需要我们分清何时何地去使用才是恰当的。

更深入的了解,请看 【前端进阶之路】内存基本知识

results matching ""

    No results matching ""