浏览器/JS的垃圾回收机制

202 阅读6分钟

浏览器内存

  • 浏览器内存分为栈和堆
    • 变量的声明存在于栈中 (简单类型或者是局部临时变量,这是由系统自动分配的)
    • 变量的定义(数组或对象的具体指向)存放于堆中 (ps: 一般存放的就是全局变量,数组对象等引用类型数据,往往由我们的代码去申请)

什么是垃圾

  • 不被需要,即为垃圾
  • 全局变量随时都可能用到,所以一定不是垃圾

不被需要怎么判定

V8采用的是可达性(reachability)算法来判断堆中的对象该不该被回收

  • 具体算法:
    • 从根节点(Root)出发去遍历所有的对象
    • 可以遍历到的对象,是可达的(reachable)
    • 遍历不到的对象,是不可达的(unreachable)
  • 在浏览器环境中,根节点有很多种,主要包括以下几种:
    • 全局变量Window,位于每个iframe
    • 文档 DOM 树
    • 存放在栈上的变量
    • ...

这些根节点不是垃圾,不可能被回收

怎么进行回收

  • 根据判定算法将不可达的数据标记出来,在所有标记完成之后,统一清理内存中不可达的对象

回收后就完了吗?存在什么问题?

  • 我们频繁的回收对象后内存中会存在大量不连续的空间,专业名词叫做“内存碎片”
  • 当内存中存在大量的内存碎片,这个时候如果需要分配一个大量连续空间,就会出现内存不足的情况
  • 所以在我们清理完后需要进行内存的整理(但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。)

垃圾会回收的时机?什么时候进行垃圾回收合适?

  • 浏览器在进行垃圾回收时会暂停JS 脚本的执行,等回收完后才能继续JS执行
  • 对于普通应用这样没什么问题,但对于JS游戏,动画对连贯性要求比较高的应用,如果暂停就会造成页面卡顿
  • 接下来我们讨论下在什么时候进行回收才能避免长时间的暂停

分代收集

浏览器将数据分为两种,一种是「长久」对象,一种是「临时」对象。

  • 临时对象:
    • 大部分对象在内存中存活时间很短
    • 比如函数内部声明的变量,或者块级作用域中存在的变量。在函数或者块级代码执行结束后,作用域中的变量就会被销毁
    • 像这种很快就不会被用到的对象就该被尽快回收
  • 长久对象:
    • 生命周期很长的对象,比如全局的window,DOM,Web API 等
    • 这类对象很容易随时被用到,所以应该被慢点回收

这两种对象对应不同的回收策略,所以V8 把堆分为新生代和老生代两个区域,新生代对象存放临时对象,老生代对象存放长久对象。

并且让副垃圾回收器,主垃圾回收器分别负责新生代、老生代垃圾的回收。

接下来我们讨论下V8以怎样的策略来实现高效的垃圾回收

副垃圾回收器(负责新生代垃圾回收)

新生代中的对象主要通过Scavenge算法进行回收,Scavenge的具体实现中主要采用的是Cheney算法,如下图了解整个过程:

新生代算法

  • 新生代被分为两个区域:一半对象区(From空间),一半闲置区(To空间)
  • 对象区是处于使用中的活跃地带,而闲置区是非使用状态的空闲地带
  • 新加入对象分配空间时,首先从对象区分配,当开始垃圾回收时,将From空间的对象复制到To空间,接下来清除整个From空间的对象,释放对象区
  • 完成复制后,From 空间 和 To 空间进行角色互换
  • 简而言之,Cheney 算法的核心思想是采用**对象,一种是复制的思想去实现的简图如下: 简图

总结: 新生代垃圾回收的缺点显而易见,它只能使用到堆内存的一半。优点在于它只复制存活的对象,对于生命周期短的场景存活对象只占少部分,所以在时间效率上有着不错的表现。对于副垃圾回收器的复制思想是不存在内存整体这个步骤的,也回答了我们上述所说的:不是所有垃圾回收都需要内存整理。

上述所说的是纯Scaenge算法,但在分代回收的算法中,Form 空间的对象进入到To空间时是需要检查的,**在一定条件下,需要将存活时间较长的对象移入到老生代空间中,这个过程被称为晋升。

对象的晋升有两个条件: * 对象是否经历过Scaenge回收 * 当To空间使用度超过25%时,直接将对象直接晋升到老生代空间中

如下图描述两个条件 检查是否经历了Scaenge

这个也很好理解,经历过Scaenge的对象肯定是存活时间较久的了 To空间使用度到了25%

讲完新生代算法,接下来我们看下老生代垃圾回收的具体策略

主垃圾回收器(负责老生代垃圾回收)

特点:

对象占用空间大
对象存活时间久
基于上述的对象特点,老生代主要采用**标记-清除和**标记-整理的算法来进行垃圾回收

标记-清除(Mark-Sweep)算法

  • Mark-Sweep在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象
  • 也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因
  • 但是这个算法有个比较大的问题是,内存碎片太多。如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的
  • 所以在此基础上提出Mark-Compact算法

标记-整理(Mark-Compact)算法

  • Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存

流程可以参照下图 标记清除

直接将标记为垃圾的数据清理掉。

标记整理

多次标记-清除后,会产生大量不连续的内存碎片,需要进行内存整理。

分代收集的总结

将堆分为新生代与老生代,多回收新生代,少回收老生代。

这样就减少了每次需遍历的对象,从而减少每次垃圾回收的耗时。