内存模型

download (6).png

内存生命周期

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

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

    JavaScript 的内存分配

    值的初始化

    为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。
    1. var n = 123; // 给数值变量分配内存
    2. var s = "azerty"; // 给字符串分配内存
    3. var o = {
    4. a: 1,
    5. b: null
    6. }; // 给对象及其包含的值分配内存
    7. // 给数组及其包含的值分配内存(就像对象一样)
    8. var a = [1, null, "abra"];
    9. function f(a){
    10. return a + 2;
    11. } // 给函数(可调用的对象)分配内存
    12. // 函数表达式也能分配一个对象
    13. someElement.addEventListener('click', function(){
    14. someElement.style.backgroundColor = 'blue';
    15. }, false);

    通过函数调用分配内存

    有些函数调用结果是分配对象内存:
    1. var d = new Date(); // 分配一个 Date 对象
    2. var e = document.createElement('div'); // 分配一个 DOM 元素
    有些方法分配新变量或者新对象:
    1. var s = "azerty";
    2. var s2 = s.substr(0, 3); // s2 是一个新的字符串
    3. // 因为字符串是不变量,
    4. // JavaScript 可能决定不分配内存,
    5. // 只是存储了 [0-3] 的范围。
    6. var a = ["ouais ouais", "nan nan"];
    7. var a2 = ["generation", "nan nan"];
    8. var a3 = a.concat(a2);
    9. // 新数组有四个元素,是 a 连接 a2 的结果

    使用值

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

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

    大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
    高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

    垃圾回收机制

    垃圾回收的关键是如果确定哪些对象是垃圾。
    引用计数好像每个人留着谁认识你的一个数字,没人认识你了,你才算死亡。如果存在我认识你,你认识我,就不会被清除了。
    标记清除好像把葡萄提起来,掉下来的就是垃圾。

    什么是垃圾回收

    一个对象被直接或间接引用的,那么还存活
    已经被引用不到的,视为死亡。
    把这些已经死亡的找出来,释放其占用的内存,就是 gc
    为此,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间), 周期性地执行这一操作。

    引用计数

    这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。

优点:

  1. 如果没有引用指向该对象(零引用),对象可以立即被回收
  2. 实现简单
  3. 不用遍历所有对象
  4. 不用停止程序

缺点:

  1. 无法处理循环引用的事例。
  2. 需要有自动管理计数器,手动管理容易出错
  3. 不适合并行处理,如果多个线程同时对引用计数进行增减,会发生内存泄露,所以 objc 有原子属性和非原子属性。

IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄漏:
该算法有个限制:无法处理循环引用的事例。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

  1. function f(){
  2. var o = {};
  3. var o2 = {};
  4. o.a = o2; // o 引用 o2
  5. o2.a = o; // o2 引用 o
  6. return "azerty";
  7. }
  8. f();

标记清除

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收

mark phase-标记阶段

这个算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象,把所有找到的对象打上标记,这种标记是通过对象内部的标志来实现的。

sweep phase

将全部对象扫描一遍,将没有标记的对象进行回收。在扫描的同时还要把已经有标记的对象清除掉

这个算法比前一个要好,因为“零引用的对象”总是不可获得的,循环引用的对象也是不可获得的,解决了引用计数垃圾回收机制

复制收集

标记清除算法有一个缺点,就是在分配了大量对象,并且只有一小部分存活的情况下,消耗的时候会大大超过必要值。
复制收集在标记的时候复制,减少了清除阶段。

标记阶段

标记的时候把对象复制到新空间
这样就不需要清除去遍历所有对象。如果大部分对象都死亡的时候,遍历所有开销不少

V8垃圾回收机制

垃圾回收流程

  1. 函数运行结束,清除执行上下文,执行上下引用的数据失去引用
  2. js 运行垃圾回收,标记垃圾数据
  3. js 清除垃圾数据

    代际假说

    代际假说有以下两个特点:
  • 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 第二个是不死的对象,会活得更久。

    分代收集

    通常,垃圾回收算法有很多种,但是并没有哪一种能胜任所有的场景,你需要权衡各种场景,根据对象的生存周期的不同而使用不同的算法,以便达到最好的效果。根据代际假说设计分代收集的垃圾处理机制
    所以,在 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
    默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。
    对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代)。
    新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升。

    新生区

    新生代中用Scavenge 算法来处理,所谓 Scavenge 算法其实就是使用了复制收集方法。新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间

From空间快要满时,就进行一次垃圾回收。
算法
其实就是使用了复制收集算法。首先从根开始扫描,新生区会把这些存活的对象 复制 到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,对象晋升的条件主要有两个:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中
  2. 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

作者:leocoder
链接:https://juejin.im/post/5ad3f1156fb9a028b86e78be
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

老生区

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。
image.png
不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图:
image.png
标记整理过程

回收性能

垃圾收集器是周期性运行的,而且如果为变量分配的内存数量很可观,那么回收工作量也是相当大 的。在这种情况下,确定垃圾收集的时间间隔是一个非常重要的问题。

全停顿

不过由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
image.png

增量回收

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:
image.png
使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

由于在回收的过程中,程序本身会继续运行,对象之间引用关系也可能会发生变化。如果已经完成扫描和标记的对象被修改,对新的对象产生了引用,这个新对象就不会被标记,命名是存活的对象却被回收掉了。

并行回收

总结

引用计数算法清除垃圾不用停止程序,实现简单,不会遍历所有对象,但是具有需要引入自动计数器,并行是用的属性要原子性。循环引用等问题。
v8是用分代回收,

内存泄露

  1. function setName() {
  2. name = 'Jake';
  3. }
  4. // 全局对象增加了一个属性,不会释放
  1. let name = 'Jake';
  2. setInterval(() => {
  3. console.log(name);
  4. }, 100);
  5. // 只要setInterval运行,name不会释放
  6. let outer = function() {
  7. let name = 'Jake';
  8. return function() {
  9. return name;
  10. };
  11. };
  1. function createPerson(name){
  2. let localPerson = new Object();
  3. localPerson.name = name;
  4. return localPerson;
  5. }
  6. let globalPerson = createPerson("Nicholas");
  7. // do something with globalPerson
  8. globalPerson = null;

// 使用完闭包置为 null

管理内存

js和其他语言不同,操作系统给浏览器的内存通常要少,出于安全考虑。
所以一旦数据不再有用,最好通过将其值设置为 null 来释放其引用——这个 做法叫做解除引用(dereferencing),局部变量会在 它们离开执行环境时自动被解除引用,
解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离 执行环境,以便垃圾收集器下次运行时将其回收。

问题

你是如何判断 JavaScript 中内存泄漏的?可以结合一些你在工作中避免内存泄漏的方法。