js - 垃圾回收

512 阅读10分钟

垃圾回收说的是什么

来源:红宝书

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在 C 和 C++等语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。

JavaScript 为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。

基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。

垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。


可达性

主要表达的是数据的可达性,是否内存监控的时候内存当中的数据是否可以寻源到。当js存在性能问题的时候,主要原因是一些本该不可达的数据,还是可达的。这样就没有通过JS内存GC掉。

  • 全局对象
  • 正被调用的函数的局部变量和参数
  • 相关嵌套函数里的变量和参数
  • 其他(引擎内部调用的一些变量)
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Document</title>
    <style>
    </style>
  </head>
  <body>
    <div class="left">LEFT</div>
    <div class="right">RIGHT</div>

    <script type="text/javascript">
      var t = null;

      var replaceThing = function () {
        var o = t;
        var unused = function () {
          if (o) {
            console.log("hi");
          }
        }

        t = {
          longStr: new Array(100000).fill('*'),
          someMethod: function () {
            console.log(1)
          }
        }
      }

      setInterval(replaceThing, 1000)
    </script>

  </body>
</html>


image.png

这里每一次的定时器方法执行都会存在前后两次方法执行的引用。造成每一次方法执行创建的数据仍然可以访问。


垃圾回收的几种方式

标记清理

Js最常用的垃圾回收策略。

JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永 远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时, 也会被加上离开上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下 文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现 并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它 会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记 的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内 存清理,销毁带标记的所有值并收回它们的内存。


标记清理步骤

  • 垃圾回收器获取根并“标记”(记住)它们。然后它访问并“标记”所有来自它们的引用。
  • 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
  • 以此类推,直到有未访问的引用(可以从根访问)为止。
  • 除标记的对象外,所有对象都被删除。


image.png


标记清理缺点

  • 碎片化, 会导致无数小分块散落在堆的各处分配速度不理想,
  • 每次分配都需要遍历空闲列表找到足够大的分块
  • 与写时复制技术不兼容,因为每次都会在活动对象上打上标记
  • 运行速度不高,运行时需要暂定应用

引用计数

垃圾回收策略是引用计数(reference counting)。

其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。

function problem() { 
   let objectA = new Object(); 
   let objectB = new Object(); 
   objectA.someOtherObject = objectB; 
   objectB.anotherObject = objectA; 
}

问题:

在这个例子中,objectA 和 objectB 通过各自的属性相互引用,意味着它们的引用数都是 2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和 objectB 在函数结束后还会存在,因为它们的引用数永远不会变成 0。

基本上现代浏览器都不采用引用计数清理策略。



不同的浏览器所有的垃圾回收机制

关于垃圾回收策略,可能有误,后续查询到相关信息,在更新。

浏览器内核js引擎垃圾回收策略
FirefoxGeckoSpiderMonkey(1.0-3.0)/ TraceMonkey(3.5-3.6)/ JaegerMonkey(4.0-)SpiderMonkey【分代式GC】
增量回收GC
Chromewebkit->blinkV8分代式GC
SafariwebkitNitro(4-)/Javascript Core分代式GC
IEtrident->EdgeHTMLJScript(IE3.0-IE8.0) / Chakra(IE9+)不知道
OperaPresto->blinkLinear A(4.0-6.1)/ Linear B(7.0-9.2)/ Futhark(9.5-10.2)/ Carakan(10.5-)分代式GC



分代回收

V8引擎

V8引擎运行在浏览器和node环境。V8引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存,在这样的限制下,必然会导致在node中无法直接操作大内存对象。

限制原因

  • JS单线程机制:单线程机制,垃圾回收的过程会阻碍主线程逻辑的执行。【web worker多线程】
  • 垃圾回收机制:假如V8堆内存过大,则回收过程耗费时间过长。浏览器等待的时间就会很长。

node命令查看内存

// 设置新生代内存中单个半空间的内存最小值,单位MB
node --min-semi-space-size=1024 xxx.js

// 设置新生代内存中单个半空间的内存最大值,单位MB
node --max-semi-space-size=1024 xxx.js

// 设置老生代内存最大值,单位MB
node --max-old-space-size=2048 xxx.js

process.memoryUsage()

image.png

  • heapTotal:表示V8当前申请到的堆内存总大小。
  • heapUsed:表示当前内存使用量。
  • external:表示V8内部的C++对象所占用的内存。
  • rss(resident set size):表示驻留集大小,是给这个node进程分配了多少物理内存,这些物理内存中包含堆,栈和代码片段。对象,闭包等存于堆内存,变量存于栈内存,实际的JavaScript源代码存于代码段内存。使用Worker线程时,rss将会是一个对整个进程有效的值,而其他字段则只针对当前线程。

分代式垃圾回收结构

  • 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
  • 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
  • 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
  • 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
  • map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单

image.png


新生代

新生代主要用于存放存活时间较短的对象。新生代内存是由两个semispace(半空间)构成的,内存最大值在64位系统和32位系统上分别为32MB和16MB,在新生代的垃圾回收过程中主要采用了Scavenge【主要是Cheney 复制算法】算法。

  • 激活区域:From空间。
  • 未激活区域:To空间。


image.png

Scavenge算法的垃圾回收过程主要就是将存活对象在From空间和To空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。


对象晋升

当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中。对象从新生代转移到老生代的过程称之为对象晋升。主要两个条件。

  • 对象是否经历过一次scavenge算法
  • To空间的内存占比是否已经超过25%


image.png

老生代

在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)和Mark-Compact(标记整理)来进行管理。

关于标记清除,在上面已经提到过。为了解决碎片化问题,mark-compact算法提了出来,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存
image.png


增量标记

juejin.cn/post/684490… 为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。

image.png

参照


www.php.cn/js-tutorial…
juejin.cn/post/684490…