12 垃圾回收机制

Posted by CodingWithAlice on April 11, 2021

12 垃圾回收机制

参考文章:垃圾回收

核心要点记录如下:

V8 - 64位系统 —> 1.4GB内存

V8 - 32位系统 —> 0.7GB内存

垃圾数据回收分为两种策略:

  • 手动回收:C/C++(手动分配内存/销毁)
  • 自动回收【垃圾回收器】:js、java、python

内存泄漏:如果这段数据已经不再需要了,但是又没有销毁。

栈 - 垃圾回收机制

提问:我们之前说到,函数执行完后,(原始数据类型被分配到栈中,引用类型被分配到堆中)函数的 执行上下文 会从 堆栈 中被销毁掉,怎么销毁的呢?

  • 解答:

有一个记录当前执行状态的指针(称为ESP),指向调用栈中showName函数的执行上下文,表示当前正在执行showName函数。 —> 执行完showName后,JavaScript会将ESP下移到foo函数的执行上下文,这个 指针下移操作就是销毁showName函数执行上下文的过程。 —> 上面showName的执行上下文虽然保存在栈内存中,但是已经是 无效内存 了。比如当foo函数再次调用另外一个函数时,这块内容会被直接 覆盖 掉,用来存放另外一个函数的执行上下文

image-20210411195059967

堆 - 垃圾回收机制(JS引擎V8)

代际假说特点:

  • 第一个大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 第二个是不死的对象,会活得更久

垃圾回收算法 <— 有很多种,需要权衡来确定什么时候使用哪一种

V8的垃圾回收策略主要是基于 分代式垃圾回收机制,在 V8 中会根据 对象的存活时间 分为 新生代老生代 两个区域:

新生代 老生代
存放生存时间短的对象 存放生存时间久的对象
通常只支持 1~8M 的容量 容量大很多
副垃圾回收器 主垃圾回收器

垃圾回收器的工作流程

主副垃圾回收器有一套共同的执行流程:

  • 第一步是 标记 空间中活动对象和非活动对象 (所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象)
  • 第二步是 回收 非活动对象所占据的内存 (其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象)
  • 第三步是做内存 整理 (一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些 不连续的内存空间 称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况)

热知识:副垃圾回收器不产生内存碎片

副垃圾回收器(新生代)

内存最大值在64位系统和32位系统上分别为32MB16MB

新生区的垃圾回收 - 区域虽然不大,但是垃圾回收比较频繁 – Scavenge 算法

Scavenge 算法:把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,如下图所示

image-20210411195415523

新加入的对象都会存放到对象区域,当 对象区域快被写满 (时机)时,就需要执行一次垃圾清理操作。

—> 在垃圾回收过程中,首先要对对象区域中的垃圾做 标记(step1);标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些 存活的对象 复制到空闲区域 (step2)中,同时它还会把这些对象 有序地排列(step2) 起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

—> 完成复制后,对象区域与空闲区域进行角色翻转(step3),也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般 新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了 对象晋升策略,也就是 经过两次垃圾回收依然还存活的对象,会被移动到老生区 中。

主垃圾回收器(老生代)

老生区中的垃圾回收 - 除了新生区中晋升的对象,一些大的对象会直接被分配到老生区 - 标记 - 清除 (方法一)(Mark-Sweep)+ 标记 - 整理 (方法二)(Mark-Compact)的算法来进行管理

  • 早前有一种算法叫做 引用计数 — 看对象是否还有其他引用指向它,如果没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收 —> 存在问题 :遇到 循环引用时,就算函数执行完成,内存也不会被清理,导致内存泄漏(2012年弃用)

      function foo() {
          // 函数执行完成后,作用域中包含的变量a和b本应该可以被回收
          let a = {};
          let b = {};
          // 引用计数 - 两个变量均存在指向自身的引用 --> 依旧无法被回收
          a.a1 = b;
          b.b1 = a;
      }
      foo();
    

老生区对象有两个特点:

  • 对象占用空间大
  • 对象存活时间长

首先是 标记 (step1)过程阶段。标记阶段就是从 一组根元素 (浏览器 - window) 开始,递归遍历 这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据 -> 上个引用计数的例子中的变量无法到达,就会被清理

  • 大致的 标记过程 如下:

    showName 函数执行结束之后,ESP 向下移动,指向了 foo 函数的执行上下文,这时候如果遍历调用栈,是不会找到引用 1003 地址的变量,也就意味着 1003 这块数据为垃圾数据,被标记为红色。由于 1050 这块数据被变量 b 引用了,所以这块数据会被标记为活动对象。

    image-20210411195458566

接下来就是垃圾的 清除 过程 (step2)。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:

image-20210411195551014

不过对一块内存多次执行标记 - 清除算法后,会 产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理 (方法二)(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

image-20210411195637678

全停顿

  • 全停顿现象

    由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停 下来,待垃圾回收完毕后再恢复脚本执行。

在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。 为了降低老生代的垃圾回收而造成的卡顿,V8 将 标记过程 分为一个个的 子标记过程 ,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为 增量标记 (Incremental Marking)算法。

image-20210411195717116

补充:得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。