• 首页
  • vue
  • TypeScript
  • JavaScript
  • scss
  • css3
  • html5
  • php
  • MySQL
  • redis
  • jQuery
  • JavaScript 内存管理和垃圾回收机制

    像其他的编程语言都有各自底层的内存管理接口,比如 C 语言的malloc()和free()。JavaScript 是在创建变量时自动分配内存,在不使用时自动释放,这个释放过程称为垃圾回收。JavaScript 的这种自动释放的机制,可以让我们开发者在大部分时间都不需要关心 JavaScript 的内存管理。

    内存生命周期

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

    1. 内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存。
    2. 内存使用:即读写内存,也就是使用变量、函数等。
    3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存。

    一般来说 JavaScript 的内存空间分为:

    • (stack):自动分配的内存空间,它由系统自动释放。栈内存比堆内存,运行效率高,内存空间较小。存放基本数据类型及对象变量的指针。
    • (heap):动态分配的内存空间,大小不定也不会自动释放。堆内存比栈内存,运行效率略低,内存空间较大。存放引用数据类型的,存放复杂对象存放引用数据类型的。
    • (pool):用于存放常量的基本数据类型,所以也叫常量池。一般将常量池归类到栈内存中,常量池内的数据类型占用空间少、大小固定并且频繁使用,所以系统效率较高。

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

    • 基本类型:包括 Undefined、Null、Boolean、Number、Bigint、String、Symbol。在内存中占据空间小、大小固定,他们的值保存在栈空间,是按值来访问。
    • 引用类型:Object 对象:Function、Array、Date、Math、Regexp等。在内存中占据空间大、大小不固定,栈内存中存放地址指向堆内存中的对象。是按引用访问的。引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

    注意:闭包中的基本数据类型变量不保存在栈内存中,而是保存在堆内存中。


    后进先出

    是一种 LIFO(Last-In-First-Out,后进先出)的数据结构,也就是最新添加的项最早被移除。而栈中项的插入(叫做推入)和移除(叫做弹出),只发生在一个位置——栈的顶部。最开始栈中不含有任何数据,叫做空栈,此时栈顶就是栈底。然后数据从栈顶进入,栈顶栈底分离,整个栈的当前容量变大。数据出栈时从栈顶弹出,栈顶下移,整个栈的当前容量变小。

    ECMAScript 为数组专门提供了push()pop()方法,以便实现类似栈的行为。push()方法可以接收任意数量的参数,把它们逐个添加到数组末尾,并返回修改后数组的长度。而pop()方法则从数组末尾移除最后一项,减少数组的 length 值,然后返回移除的项。


    先进先出

    队列数据结构的访问规则是 FIFO(Fist-In-First-Out,先进先出)。队列在列表的末端添加项,从列表的前端移除项。

    入队列操作其实就是在队尾追加一个元素,不需要任何移动,时间复杂度为 O(1)。出队列则不同,因为我们已经架设下标为 0 的位置是队列的队头,因此每次出队列操作所有元素都要向前移动。如下图所示:

    ECMAScript为数组专门提供了shift()unshift()方法,以便实现类似队列的行为。由于push()是向数组末端添加数组项的方法,因此要模拟队列只需一个从数组前端取得数组项的方法。实现这一操作的数组方法就是shift(),它能够移除数组中的第一个项并返回该项,同时将数组长度减 1。

    顾名思义,unshift()shift()的用途相反:它能在数组前端添加任意个数组项并返回新数组的长度。因此,同时使用unshift()pop()方法,可以从相反的方向来模拟队列,即在数组的前端添加数组项,从数组末端移除数组项。


    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 的结果
    


    值的使用

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


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

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

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


    垃圾回收

    垃圾回收是一种自动的内存管理机制。当计算机上的动态内存不再需要时,就应该予以释放,以让出内存。在 JavaScript 中是如何找到不再使用的内存的。主要有两种方式:引用计数和标记清除。

    引用(reference):在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。


    引用计数垃圾收集

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

    let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1
    let obj2 = obj1; // A 的引用个数变为 2
    
    obj1 = 0; // A 的引用个数变为 1
    obj2 = 0; // A 的引用个数变为 0,此时对象 A 就可以被垃圾回收了
    
    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 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。


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

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


    优化内存

    优化内存的一个最好的衡量方式就是只保留程序运行时需要的数据,对于已经使用的或者不需要的数据,应该将其值设为null,这上面说过,叫“解除引用”。需要注意的是,解除一个值的引用不代表垃圾回收器会立即将这段内存回收,这样做的目的是让垃圾回收器在下一个回收周期到来时知道这段内存需要回收。

    有些时候,我们会有意识地声明一些全局变量,这个时候需要注意,如果声明的变量占用大量的内存,那么在使用完后将变量声明为null


    减少内存垃圾的另一个方法就是避免创建对象。new Object()是一个比较明显的创建对象的方式,另外const arr =[];const obj ={};也会创建新的对象。另外下面这种写法在每次调用函数时都会创建一个新的对象:

    function func() {
        return function() {};
    }
    


    另外,当清空一个数组时,我们通常的做法是array =[],但这种做法的背后是新建了一个新的数组然后将原来的数组当作内存垃圾。建议的做法是array.length = 0,这样做不仅可以重用原来的变量,而且还避免创建了新的数组。