0%

内存管理

内存管理

前端为什么要关注内存

  • 防止页面占用内存过大,引起客户端卡顿,甚至无响应(用户体验);
  • Node.js 使用 V8 引擎,内存对于后端服务的性能至关重要,因为后端服务的持久性,后端更容易造成内存溢出(性能)。

JS数据类型与JS内存机制

数据类型

  • 原始数据类型:字符串(String)、数字(Number)、布尔(Boolean)、空对象(Null)、未定义(Undefined)、Symbol(_NEW_)
  • 引用数据类型:Object
  • 内存空间:栈内存(stack)、堆内存(heap)

栈内存(stack)

  • 入栈:从栈顶 -> 栈底,新入栈数据为新的栈顶数据
  • 定义变量过程:
    1. 将值存入内存空间
    2. 在当前作用域声明一个变量(此时变量的值是undefined)
    3. 对变量进行赋值

  • 出栈:从栈顶 -> 栈底,从栈顶开始删除数据,最后删除最先入栈的栈底数据

堆内存(heap)

  • 原始类型的值大小是固定的,由系统自动分配存储空间。
  • 引用数据类型的值的大小是不固定的,相对于原始数据类型在栈内存中存储,引用数据类型在堆内存中进行存储。
  • JS是不允许直接访问堆内存的,因此也无法直接操作对象的堆内存空间。操作对象时,实际上是在操作对象的引用而不是实际的对象,因此引用类型的值都是按引用(保存在栈内存中的内存地址,该地址与堆内存的实际值相关联)访问的。所以引用类型的值,是保存在堆内存中的对象。

  • 定义对象

  • 定义函数

垃圾回收GC(Garbage Collection)

内存的生命周期

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

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

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

JavaScript的垃圾回收机制

找出那些不再继续使用的变量,然后释放其所占的内存,垃圾回收器会按照固定的时间间隔周期性地执行这一操作。

JavaScript 使用垃圾回收机制来自动管理内存,垃圾回收是一把双刃剑:

  • ::优势:::可以大幅简化程序的内存管理代码,降低程序员的负担,减少因长时间运转而带来的内存泄露问题。
  • ::不足:::意味着程序员将无法掌控内存。Javascript 没有暴露任何关于内存的 API。我们无法强迫其进行垃圾回收,更无法干预内存管理。

垃圾回收策略——引用计数

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

  • 引用计数(reference counting)
    这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
    即跟踪记录每个值被引用的次数,如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。

  • 原理
    每次引用加一,被释放时减一,当这个值的引用次数变成0时,就可以将其内存空间回收。

  1. 声明了一个变量并将一个引用类型的值赋值给这个变量,这个引用类型的值的引用次数是1;
  2. 同一个值又被赋值给另一个变量,这个引用类型值的引用次数加1;
  3. 当包含这个引用类型值的变量又被赋值成了另一个值了,那么这个引用类型值的引用次数减1;
  4. 当引用次数变成0时,说明没办法访问这个值了;
  5. 当垃圾收集器下一次运行时,它就会释放引用次数是0的值所占的内存。
  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    var o = { 
    a: {
    b:2
    }
    };
    // 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
    // 很显然,没有一个可以被垃圾收集

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

    o = 1; // 现在,“这个对象”只有一个o2变量的引用了,“这个对象”的原始引用o已经没有

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 循环引用 (IE8及以前的时代)
    function f(){
    var o = {};
    var o2 = {};
    o.a = o2; // o 引用 o2
    o2.a = o; // o2 引用 o

    return "azerty";
    }

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

    1
    2
    3
    4
    5
    6
    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里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
    即标记清除指的是当变量进入环境时,这个变量标记为“进入环境”;而当变量离开环境时,则将其标记为“离开环境”,最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

  • 执行环境
    执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。

    • 全局执行环境
      • 最外围的一个执行环境
      • 根据宿主环境不同表示执行环境的对象也不一样。在浏览器中,全局执行环境被认为是window对象
      • 全局变量和函数都是作为window对象的属性和方法创建的
      • 某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁
  • 环境栈(局部)
    每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript程序中的执行流正式由这个方便的机制控制着。

1
2
3
4
5
function foo() {
var a = 10; // 被标记进入环境
var b = ‘hello’ // 被标记进入环境
}
foo(); // 执行完毕,a和b被标记离开环境,内存被回收

V8引擎的内存管理机制

内存大小

  • 和操作系统有关:64位为1.4G;32位为0.7G;
  • 64位下新生代的空间为64MB,老生代为1400MB;
  • 32位下新生代的空间为16MB,老生代为700MB。

限制内存的原因

  • V8最初为浏览器而设计,不太可能遇到大量内存的使用场景;
  • Javascript 在回收的时候会中断执行(如100MB 3ms时间回收),防止因为垃圾回收所导致的线程暂停执行的时间过长。

V8的回收策略

  • V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。
  • V8分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。

新生代垃圾回收

  • 分配方式
    新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。新生代中的对象可以晋升到老生代中。

  • 算法
    新生代采用 Scavenge 垃圾回收算法,在算法实现时主要采用 Cheney 算法。


Cheney 算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象。这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。 简而言之,在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。

  • 回收示例
  • *step1**. 在From空间中分配了3个对象A、B、C

step2. GC进来判断对象B没有其他引用,可以回收,对象A和C依然为活跃对象

step3. 将活跃对象A、C从From空间复制到To空间

step4. 清空From空间的全部内存

step5. 交换From空间和To空间

step6. 在From空间中又新增了2个对象D、E

step7. 下一轮GC进来发现对象D没有引用了,做标记

step8. 将活跃对象A、C、E从From空间复制到To空间

step9. 清空From空间全部内存

step10. 继续交换From空间和To空间,开始下一轮

  • 通过上面的示例,我们可以很清楚的看到,进行From和To交换,就是为了让活跃对象始终保持在一块semispace中,另一块semispace始终保持空闲的状态。
  • Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的体现。Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制所决定的。
  • 由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模的应用到所有的垃圾回收中。但我们可以看到, Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

新生代对象的晋升

当一个对象经过多次复制仍然存活时,它就会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。

对象从新生代移动到老生代的过程叫作晋升

对象晋升的条件主要有两个:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中

  2. 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

老生代垃圾回收

老生代:存放存活时间较长的对象。

Mark-Sweep 和 Mark-Compact

V8在老生代中主要采用了Mark-Sweep(标记清除)和Mark-Compact(标记整理)相结合的方式进行垃圾回收。

  • Mark-Sweep
    Mark-Sweep 是标记清除的意思,它分为标记和清除两个阶段。
    与 Scavenge 不同,Mark-Sweep 并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep 在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。
    也就是说,Scavenge 只复制活着的对象,而 Mark-Sweep 只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。


可以看到,Mark-Sweep最大的问题就是,在进行一次清除回收以后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。
如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

  • Mark-Compact
    为了解决Mark-Sweep的内存碎片问题,Mark-Compact就被提出来了。
  • *Mark-Compact是标记整理的意思,**是在Mark-Sweep的基础上演变而来的。Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。

内存泄漏

本质上讲,内存泄露就是不再被需要的内存,由于某种原因,无法被释放。

内存查看

  • 浏览器 - window.performance
  • Node - process.memoryUsage()

常见的内存泄露案例

  • 全局变量
    1
    2
    3
    function foo(arg) {
    bar = "some text";
    }
    在 JS 中处理未被声明的变量,上述范例中的 bar 时,会把 bar 定义到全局对象中,在浏览器中就是 window上。在页面中的全局变量,只有当页面被关闭后才会被销毁。所以这种写法就会造成内存泄露,当然在这个例子中泄露的只是一个简单的字符串,但是在实际的代码中,往往情况会更加糟糕。

另外一种意外创建全局变量的情况:

1
2
3
4
5
function foo() {
this.var1 = “potential accidental global”;
}
// Foo 被调用时, this 指向全局变量(window)
foo();

在这种情况下调用 foo,this被指向了全局变量 window,意外的创建了全局变量。
我们谈到了一些意外情况下定义的全局变量,代码中也有一些我们明确定义的全局变量。如果使用这些全局变量用来暂存大量的数据,记得在使用后,对其重新赋值为 null。

  • 未销毁的定时器和回调函数
    在很多库中,如果使用了观察着模式,都会提供回调方法,来调用一些回调函数。要记得回收这些回调函数。举一个 setInterval的例子:

    1
    2
    3
    4
    5
    6
    7
    var serverData = loadData();
    setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
    renderer.innerHTML = JSON.stringify(serverData);
    }
    }, 5000); // 每 5 秒调用一次

    如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。但如果你没有回收定时器,整个定时器依然有效,不但定时器无法被内存回收,定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

  • 闭包
    在 JS 开发中, 我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。下面这种情况下,闭包也会造成内存泄露。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    var theThing = null;
    var replaceThing = function () {
    var originalThing = theThing;
    var unused = function () {
    if (originalThing) // 对于 ‘originalThing’的引用
    console.log(“hi”);
    };
    theThing = {
    longStr: new Array(1000000).join(‘*’),
    someMethod: function () {
    console.log(“message”);
    }
    };
    };
    setInterval(replaceThing, 1000);

    这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。同时 unused 是一个引用了 originalThing 的闭包。 这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致内存无法对其进行回收。当这段代码被反复执行时,内存会持续增长。

  • DOM 引用
    很多时候, 我们对 Dom 的操作,会把 Dom 的引用保存在一个数组或者 Map 中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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 元素的引用。

小结

JS 这类高级语言,隐藏了内存管理功能。但无论开发人员是否注意,内存管理都在那,所有编程语言最终要与操作系统打交道,在内存大小固定的硬件上工作。不幸的是,即使不考虑垃圾回收对性能的影响,最新的垃圾回收算法,也无法智能回收所有极端的情况。
唯有程序员自己才知道何时进行垃圾回收,而 JS 由于没有暴露显示内存管理接口,导致触发垃圾回收的代码看起来像“垃圾”,或者优化垃圾回收的代码段看起来不优雅、甚至不可读。
所以在 JS 这类高级语言中,有必要掌握基础内存分配原理,在对内存敏感的场景,比如 nodejs 代码做严格检查与优化。谨慎使用 dom 操作、主动删除没有业务意义的变量、避免提前优化、过度优化,在保证代码可读性的前提下,利用性能监控工具,通过调用栈定位问题代码。

  • 关注内存
  • JS数据类型、JS内存机制
  • 垃圾回收
  • V8内存机制
  • 内存泄漏

#开发笔记/JavaScript进阶/函数