周元-JS的性能优化

# 周元-JS的性能优化

# JS的垃圾回收

# 垃圾回收

  • GC: Garbage Collection

程序工作过程中会产生很多垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的;它工作在引擎内部,这一套引擎执行就是常说的垃圾回收机制

  • 自带GC的语言,比如 Java、Python、JavaScript
  • 无GC的语言,比如 C、C++ 等,需要手动管理内存

可达性:就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收。

JavaScript 垃圾回收机制的原理:定期找出那些不再用到的内存(变量),然后释放其内存。

# 标记清除法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,只是各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

此算法分为 标记 和 清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。

Q: 怎么给变量加标记?

  1. 当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记);
  2. 维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表;

binary tag 0 -> 1

  • list1: 进入环境的变量

  • list2: 退出环境的变量

  • 标记清除算法大致过程:

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0;
  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成1;
  3. 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间;
  4. 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收;

优点:

实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单

缺点:

在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题;

常见的三种找到合适块内存的分配策略:

  1. First-fit,找到大于等于 size 的块立即返回;
  2. Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块;
  3. Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回;(空间利用率最高)
  • 综上,标记清除算法有两个很明显的缺点:
  1. 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块;
  2. 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢;
  • 标记整理(Mark-Compact)算法

可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存:

# 引用计数法

引用计数(Reference Counting),这其实是早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

策略:

  1. 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1;
  2. 如果同一个值又被赋给另一个变量,那么引用数加 1;
  3. 如果该变量的值被其他的值覆盖了,则引用次数减 1;
  4. 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存;
let a = new Object() 	// 此对象的引用计数为 1(a引用)
let b = a 		// 此对象的引用计数是 2(a,b引用)
a = null  		// 此对象的引用计数为 1(b引用)
b = null 	 	// 此对象的引用计数为 0(无引用)
...			// GC 回收此对象

在引用计数这种算法出现没多久,就遇到了一个很严重的问题——循环引用,即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A:

function test(){
  let A = new Object() // 引用计数为 1
  let B = new Object() // 引用计数为 1
  
  A.b = B // 引用计数为 2
  B.a = A // 引用计数为 2
}

对象 A 和 B 通过各自的属性相互引用着,按照上文的引用计数策略,它们的引用数量都是 2,但是,在函数 test 执行完成之后,对象 A 和 B 是要被清理的,但使用引用计数则不会被清理,因为它们的引用数量不会变成 0,假如此函数在程序中被多次调用,那么就会造成大量的内存不会被释放

缺点:

  1. 无法解决循环引用;
  2. 计数器也是要占内存的;

优点:

  1. 引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾;
  2. 标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了;

# V8对GC的优化

针对内存中存放的对象类型

  • 新生代:小的,新的,存活时间短
  • 老生代:大的,老的,存活时间长

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,而老生代的对象为存活事件较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大;

V8 整个堆内存的大小就等于新生代加上老生代的内存。

# 新生代垃圾回收

新生代对象是通过一个名为 Scavenge 的算法进行垃圾回收,在 Scavenge算法 的具体实现中,主要采用了一种复制式的方法即 Cheney算法:

Cheney算法 中将堆内存一分为二,一个是处于使用状态的空间我们暂且称之为 使用区,一个是处于闲置状态的空间我们称之为 空闲区

  1. 新加入的对象都会存放到使用区,当使用区快被写满时,就需要执行一次垃圾清理操作。
  2. 当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区。
  3. 当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中,采用老生代的垃圾回收策略进行管理。
  4. 另外还有一种情况,如果复制一个对象到空闲区时,空闲区空间占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配。

# 老生代垃圾回收

  • 标记清除
  1. 首先是标记阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象。
  2. 清除阶段老生代垃圾回收器会直接将非活动对象,也就是数据清理掉。

前面提过,标记清除算法在清除后会产生大量不连续的内存碎片,过多的碎片会导致大对象无法分配到足够的连续内存,而 V8 中就采用了上文中说的标记整理算法来解决这一问题来优化空间。

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。

# 并行回收(Parallel)

  • 全停顿(Stop-The-World)

JavaScript 是一门单线程的语言,它是运行在主线程上的,那在进行垃圾回收时就会阻塞 JavaScript 脚本的执行,需等待垃圾回收完毕后再恢复脚本执行,我们把这种行为叫做 全停顿。

V8 团队引入了并行回收机制,指的是垃圾回收器在主线程上执行的过程中,开启多个辅助线程,同时执行同样的回收工作。

新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针,这就是并行回收

# 增量标记与懒性清理

对于老生代来说,它的内部存放的都是一些比较大的对象,对于这些大的对象 GC 时哪怕我们使用并行策略依然可能会消耗大量时间。

为了减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记

  • 增量

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记.

  • 三色标记法(暂停与恢复)

老生代是采用标记清理算法,如果采用非黑即白的标记策略,那在垃圾回收器执行了一段增量回收后,暂停后启用主线程去执行了应用程序中的一段 JavaScript 代码,随后当垃圾回收器再次被启动,这时候内存中黑白色都有,我们无法得知下一步走到哪里了

三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑

  1. 白色指的是未被标记的对象;
  2. 灰色指自身被标记,成员变量(该对象的引用对象)未被标记;
  3. 黑色指自身和成员变量皆被标记;

采用三色标记法后我们在恢复执行时就好办多了,可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以。

  • 写屏障(增量中修改引用)

Q: 一次完整的 GC 标记分块暂停后,执行任务程序时内存中标记好的对象引用关系被修改了,增量中修改引用

V8 增量回收使用 写屏障 (Write-barrier) 机制,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作强三色不变性

  • 懒性清理

增量标记其实只是对活动对象和非活动对象进行标记,对于真正的清理释放内存 V8 采用的是惰性清理(Lazy Sweeping)

当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。

Q: 增量标记与惰性清理的优缺?

增量标记与惰性清理的出现,使得主线程的停顿时间大大减少了,让用户与浏览器交互的过程变得更加流畅。但是由于每个小的增量标记之间执行了 JavaScript 代码,堆中的对象指针可能发生了变化,需要使用写屏障技术来记录这些引用关系的变化,所以增量标记缺点也很明显:

  1. 增加了总暂停时间;
  2. 由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量;

# 并发回收(Concurrent)

指的是主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起:

辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点。

# 总结

V8 的垃圾回收策略主要基于分代式垃圾回收机制,新生代垃圾回收器使用并行回收可以很好的增加垃圾回收的效率;

老生代垃圾回收器是几种策略融合使用:

  1. 老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成);
  2. 标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作);
  3. 清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行;

# 内存泄漏

不是所有无用对象内存都可以被回收的,那当不再用到的对象内存,没有及时被回收时,这种场景称之为内存泄漏(Memory leak)。

  • 不正当的闭包

闭包是指有权访问另一个函数作用域中的变量的函数

function fn2(){
  let test = new Array(1000).fill('xianzao')
  return function(){
    // 存在函数 fn2 中的 test 变量引用
    // 所以 test 并不会被回收,也就造成了内存泄漏。
    console.log(test)
    return test
  }
}
let fn2Child = fn2()
fn2Child()

// 解决:在函数调用后,把外部的引用关系置空就好
fn2Child = null
  • 隐式全局变量

对于全局变量,垃圾回收器很难判断这些变量什么时候才不被需要,所以全局变量通常不会被回收。

function fn(){
  // 没有声明从而制造了隐式全局变量test1
  test1 = new Array(1000).fill('xianzao')
  
  // 函数内部this指向window,制造了隐式全局变量test2
  this.test2 = new Array(1000).fill('xianzao')
}
fn()

// 要确保使用后将其置空或者重新分配,当然也很简单,在使用完将其置为 null 即可,特别是在使用全局变量做持续存储大量数据的缓存时,我们一定要记得设置存储上限并及时清理
test1 = null;
this.test2 = null;
  • 游离DOM引用

代码中进行 DOM 时会使用变量缓存 DOM 节点的引用,但移除节点的时候,我们应该同步释放缓存的引用,否则游离的子树无法释放:

<div id="root">
  <ul id="ul">
    <li></li>
    <li></li>
    <li id="li3"></li>
    <li></li>
  </ul>
</div>
<script>
  let root = document.querySelector('#root')
  let ul = document.querySelector('#ul')
  let li3 = document.querySelector('#li3')
  
  // 由于ul变量存在,整个ul及其子元素都不能GC
  root.removeChild(ul)
  
  // 虽置空了ul变量,但由于li3变量引用ul的子节点,所以ul元素依然不能被GC
  ul = null
  
  // 已无变量引用,此时可以GC
  li3 = null
</script>

如上所示,当我们使用变量缓存 DOM 节点引用后删除了节点,如果不将缓存引用的变量置空,依然进行不了 GC,也就会出现内存泄漏;解决办法就是将引用子节点的变量也置空。

  • 定时器

当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout来清除,另外,浏览器中的 requestAnimationFrame 也存在这个问题,在不需要的时候用 cancelAnimationFrame API 来取消使用。

  • 事件监听器

当事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中引用的变量或者函数都被认为是需要的而不会进行回收,如果内部引用的变量存储了大量数据,可能会引起页面占用内存过高,这样就造成意外的内存泄漏。

  • Map、Set对象

当使用 Map 或 Set 存储对象时,同 Object 一致都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收。

  1. 如果使用 Map ,对于键为对象的情况,可以采用 WeakMap,WeakMap 对象同样用来保存键值对,对于键是弱引用;
  2. 如果需要使用 Set 引用对象,可以采用 WeakSet,WeakSet 对象允许存储对象弱引用的唯一值,WeakSet 对象中的值同样不会重复,且只能保存对象的弱引用;

JS 的垃圾回收机制是如果我们持有对一个对象的引用,那么这个对象就不会被垃圾回收,这里的引用,指的就是 强引用 ,而弱引用就是一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,因此可能在任何时刻被回收

  • Console

我们之所以在控制台能看到数据输出,是因为浏览器保存了我们输出对象的信息数据引用,也正是因此未清理的 console 如果输出了对象也会造成内存泄漏。

# 内存泄漏排查、定位与修复

  • 例子:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>test</title>
  </head>

  <body>
    <button id="click">click</button>
    <h1 id="content"></h1>

    <script>
      let click = document.querySelector('#click');
      let content = document.querySelector('#content');
      let arr = [];

      function closures() {
        let test = new Array(10000).fill('xianzao');

        return function () {
          return test;
        };
      }

      click.addEventListener('click', function () {
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());
        arr.push(closures());

        content.innerHTML = arr.length;

        arr = []; // 释放内存,解决泄漏
      });
    </script>
  </body>
</html>

由于闭包函数执行结果也是一个函数并且存在对原闭包函数内部数组 test 的引用,所以 arr 数组中每一项元素都使得其引用的闭包内部 test 数组对象无法回收。

  1. 20行的闭包引用数组造成的内存泄漏;
  2. 全局变量 arr 的元素不断增多造成的内存泄漏;
  • 查看:Chrome Devtool 》Performance 》Memory勾选 》最左边圆点录制:JS Heap(堆内存)

  • 定位:Chrome Devtool 》 Memory 面板

# 讲义

# 其他

上次更新: 7/6/2023, 7:44:05 PM
最近更新
01
taro开发实操笔记
09-29
02
前端跨端技术调研报告
07-28
03
Flutter学习笔记
07-15
更多文章>