5

深度理解浏览器垃圾回收及优化策略

网络上关于浏览器内存管理的文章颇多,但总体来说内容基本如下:

  • GC 的两种算法,引用计数法及标记清除法。

    • 引用计数法潜在问题——循环引用
  • 常见的内存泄漏

    • 全局变量
    • 定时器未清除
    • 闭包
    • DOM 引用
    • 等等……

本文将尝试梳理一些其他问题:

  • 在常见的实现中(如 V8 ),内存如何分配及管理的? GC 是如何执行的?
  • 如何减少 GC 触发次数?
  • GC 不定时触发,那 GC 的触发规律是什么?
  • GC 的执行及触发规律下,有哪些优化 GC 的技巧/策略?
  • 在极端边界情况下,有哪些优化 GC 的技巧/策略?
  • GC 算法在日常开发工作中的应用?
  • 如何自己实现一个内存管理机制?
  • 内存泄漏如何排查?
  • 其他注意事项?

GC 的两种回收算法

引用计数法

虚拟机记录对象引用次数,当一个对象被 0 引用,会被标记为“可回收内存垃圾”。

  • 问题:

    • 循环引用问题
      引用计数法无法解决循环引用问题,如 a.pro = b; b.pro = a。此时会造成无法回收。
标记清除法

标记-清除算法包含三个步骤:

  • 根:垃圾回收器会构建出一份所有根变量的完整列表。
  • 随后,算法会检测所有的根变量及他们的后代变量并标记它们为激活状态(表示它们不可回收)。任何根变量所到达不了的变量(或者对象等等)都会被标记为内存垃圾。
  • 最后,垃圾回收器会释放所有非激活状态的内存片段然后返还给操作系统。

现代浏览器基本都采用标记清除法。

V8 中的垃圾回收算法

  • 内存分代与弱代假设

    • 多数对象的生命周期短。
    • 生命周期长的对象,一般是常驻对象。
    • 新生代中存储存活时间较短的对象,32 位系统下空间大小为 16MB64 位系统下为 32MB
    • 老生代存储存活时间较长或常驻内存的对象,32 位系统下空间大小约为 700MB64 位系统下约为 1.4GB
    • Node 在启动时,可通过--max-old-space-size--max-new-space-size 设置大小。

      • node --max-old-space-size=1700 test.js // 单位为 MB
      • node --max-new-space-size=1024 test.js // 单位为 KB
    • 新生代平均分成两块相等的空间,叫作 SemiSpace 。每块为 8MB32 位系统)| 16MB64 位系统)。
  • V8 中的 GC 算法

    • Scavenge 算法
      用于新生代中的对象回收,牺牲空间换取时间。具体实现采用 Cheney 算法。

      • Cheney 算法
        采用复制的方式实现垃圾回收算法。将堆内存一分为二,每一部分称为 SemiSpace 。一个使用(又称 From 空间),一个闲置备用(又称 To 空间)。
        先在 From 空间中分配内存,GC 时,检查 From 空间中的存活对象,将存活对象复制到 To 空间中。复制后,From 空间和 To 空间角色对换。
      • 优点:

        • 只复制存活对象,时间效率表现优异。
      • 缺点:

        • 只能使用堆内存的一半,不能大规模用于所有 GC 算法。只适用于新生代空间 GC 算法。
      • 对象晋升
        在一定条件下,将新生代中存活周期长的对象移动到老生代中。
      • 对象晋升触发条件

        • 对象经历过 Scavenge 回收,若已经历过一次,则会将该对象从 From 空间复制到老生代空间。
        • To 空间内存占比。ScavengeGC 时,若 To 空间已使用超过 25% ,则该对象直接晋升到老生代空间。
    • 标记清除法(Mark-Sweep
      GC 时,遍历对重所有对象,标记活着的对象。在清除阶段中,清除未被标记的对象。
      存在两个标记位图:

      • 已分配标记位图,针对内存页中每一个可分配的字,使用 1bit 表示其是否已分配出去,可用于快速扫描活跃内存
      • 状态标记位图,V8 中对象大小以 2 个字长对齐,状态标记位图以 2bit 为一个单元,共可表示 4 种状态
`GC` 标记阶段采用三色标记法,将颜色信息记录在状态标记位图中:

- 白色,尚未被 `GC` 发现
- 灰色,已被 `GC` 发现,但邻接对象还未处理完
- 黑色,已被 `GC` 发现,且邻接对象已处理完

初始状态,内存页中所有对象都是白色,标记采用深度优先搜索算法,步骤如下:

- 根可达对象标记为灰,并 `push` 进栈
- `pop` 出栈一个对象,标记为黑
- 将对象的邻接对象标记为灰,并 `push` 进栈,回到步骤二直至栈为空
  上述步骤遇到大对象可能导致栈溢出,做法是当出现溢出时只标记为灰但不 `push` 进栈,栈为空后 `GC` 会再次扫描,将之前的灰色对象 `push` 进栈继续处理。因此若程序创建过多的大对象,就会触发多次堆扫描,影响 `GC` 效率。
  最终状态,内存页中的对象全部被标记,不存在灰色,白色为可回收,黑色为不可回收。

- 优点:
  - 只清除死亡的对象。老生代中,死亡对象存活比例较小。故执行效率较高。
- 缺点:
- 标记清除回收后,内存空间会触发不连续状态(碎片空间)。会对后续内存分配造成影响,若需要分配一个大对象且所有碎片空间不满足,则会提前触发 GC,而这次 GC 是不必要的。
  • 标记整理(Mark-Compact
    将活着的对象往一端移动,移动完成后,直接清理掉边界外内存。

    • 优点:

      • 没有碎片空间。
    • 缺点:

      • 需要移动对象,执行效率慢。

V8 中,标记清除(Mark-Sweep)和标记整理(Mark-Compact)算法是结合使用,主要使用标记清除,在空间不足以对从新生代中晋升过来的对象进行分配时,使用标记整理算法。
三种 GC 算法对比:

回收算法 标记清除(Mark-Sweep) 标记整理(Mark-Compact Scavenge
速度 中等 最慢 最快
空间开销 少(有碎片) 少(无碎片) 双倍空间(无碎片)
是否移动对象
  • 增量标记(Incremental Marking
    在上述三种算法执行时,都需要将暂停应用逻辑(JS 执行),GC 完成后再执行应用逻辑。此时会有一个停顿时间(称为全停顿,stop-the-world),在新生代回收时,因默认配置较小且存活对象较少,故停顿时间较小。老生代中,空间配置大 & 存活对象较多,停顿时间则会很长。

    V8 采用了增量标记方法,将标记拆分为多个小的“步进”,每“步进”完让 JS 应用逻辑执行一会儿,GC 与应用逻辑交替执行直到标记完成。经过增量标记的改进,GC 的最大停顿时间减少到原本 1/6 左右。

GC 的触发规律

如前所述,GC 的触发规律可总结为:

  • 当程序触发内存申请时,浏览器会检测是否到达一个临界值再进行触发 GC

    • 申请新的较小内存时(新生代空间内)。
    • 申请较大内存时,可能触发老生代空间的标记清除或标记整理。
  • 老生代 GC 的时间与老生代中对象数量成正相关。

如何优化 GC

观察 GC 触发规律,故优化 GC 的指导思想如下:

  • 减少内存占用
    当内存占用过高时,浏览器可能会频繁的触发 GC ,并且老生代 GC 耗时越大。
  • 减少 GC 次数

如何减少内存占用?(以及注意事项)

  • 合理设计页面,按需创建对象/渲染页面/加载图片等。

    • 避免一次性请求全部数据。
    • 避免一次渲染全部数据。
    • 避免一次性加载/渲染全部图片(按需加载/懒加载)。
    • 优化 Vuedata 对象的属性,若字段较多可 pick 需要的字段,避免生成过多 Observer

      created() {
        getList().then(res => {
          const keys = ['id', 'type'];
          this.data.infos = res.data.map(v => {
            return pick(v, keys); //pick为 lodash.pick 方法
          });
        })
      }

      在列表渲染时,可能效果会很明显。部分后端语言(如 Java )深层次继承对象之后,列表内元素的字段可能含有大量用不到的属性。
      列表元素较多时,造成的性能问题不容忽视。

  • 尽可能避免创建对象
    如非必要,避免使用创建对象的 API ,如 Array.sliceArray.mapArray.fiter 、字符串相加、$('div')ArrayBuffer.slicecanvas.getImageData()等。
  • 不再使用的对象,手动赋值为 null 。减少虚拟机扫描内存时扫描次数等。
  • 使用 WeakMapWeekset
  • 添加的侦听器需要移除。如在 Vue 中,在 mounted 周期内 addEventListener,需要在 beforeDestroy 周期内 removeListener
  • 避免频繁创建对象,尽可能复用对象。

    • 复用对象可避免创建对象(申请新的内存),故如能复用对象则尽可能复用对象。如许多发布订阅模式中的 event 对象,都会尽可能复用,不会创建多个实例。
    • 复用 DOM 等,如重复使用一个弹窗而非创建多个。
      Vue-ElementUI 框架中,PopOver/Tooltip 等组件用于表格内时会创建 m * n 个实例,可优化为只创建一个实例,动态设置位置及数据(或者有多个容器,但只插入一份内容 DOM )。
  • 使用对象池

    对象池(英语:object pool pattern)是一种设计模式。一个对象池包含一组已经初始化过且可以使用的对象,而可以在有需求时创建和销毁对象。池的用户可以从池子中取得对象,对其进行操作处理,并在不需要时归还给池子而非直接销毁它。这是一种特殊的工厂对象。

    若初始化、实例化的代价高,且有需求需要经常实例化,但每次实例化的数量较少的情况下,使用对象池可以获得显著的效能提升。从池子中取得对象的时间是可预测的,但新建一个实例所需的时间是不确定。

    以上摘自维基百科。

    使用对象池技术能显著优化需频繁创建对象的内存消耗。但建议按不同的场景做细微优化。

    • 按需创建
      默认创建空对象池,按需创建对象,用完归还池子。
    • 预创建对象
      避免在高频操作下频繁创建对象,如滚动事件、TouchMove 事件、resize 事件、for 循环内部等情况。如有需要,可提前预创建多个对象放入池子。
      高频情况下,建议使用截流/防抖、时间切片等相关技术优化。
    • 定时释放/清理
      对象池内的对象不会被垃圾回收,若极端情况下创建了大量对象回收进池子却不释放只会适得其反。
      故池子需设计定时/定量释放对象机制,如以已用容量/最大容量/池子使用时间等参数来定时释放对象。
  • ImageData 对象是 JS 内存杀手,避免重复创建 ImageData 对象。
  • 生产环境勿用 console.log 大对象,包括 DOM 、大数组、ImageDataArrayBuffer 等。因为 console.log 的对象不会被垃圾回收。详见Will console.log prevent garbage collection?
  • 重复使用 ArrayBuffer,而非创建新的。
  • 合理使用图片,压缩图片、按需加载图片、按需渲染图片,使用恰当的图片尺寸、图片格式,如 WebP 格式等。
    这其中涉及到图片渲染流程,网上资料较少。假设渲染一张 100KB 大小,300 * 500 的带透明像素的图片,粗略的可分为三个过程(注意,这里并不精确,实际图片渲染会按流式边加载边渲染,此处为简略总结):

    • 加载图片
      从缓存中或者从远程服务器加载图片的二进制格式到内存(并设置缓存)。此时消耗了 100KB 的内存 和 100KB 的缓存。
    • 解码图片
      将二进制格式的图片解码为像素格式,此时占用宽 * 高 * 24RGB 值为 24 位,若带透明通道,则为 ARBG,占用 32 位空间)比特大小的内存, 此处为 300 * 500 * 32。约等于 585 KB。这里约定名为像素格式内存。个人猜测此时浏览器会回收加载图片时创建的 100KB 二进制内存,但浏览器会缓存像素格式内存,约 585KB
    • 渲染图片
      通过 CPU 或者 GPU 渲染图片,若为 GPU 渲染,则还需上传到 GPU 显存。该过程较为耗时,由像素格式内存尺寸 / 显存位宽决定。图片像素内存尺寸越大,则上传时间越慢,占用显存越多。

      其中,较旧的浏览器如 Firefox 回收像素内存时机较晚,若渲染了大量图片时会内存占用过高。

PS:浏览器会复用同一份图片二进制内存及像素格式内存,浏览器渲染图片会按以下顺序去获取数据:

显存 >> 像素格式内存 >> 二进制内存 >> 缓存 >> 从服务器获取。我们需控制和优化的是二进制内存及像素内存的大小及回收。

总结一下,浏览器渲染图片时所消耗内存由图片文件大小内存、宽高、透明度等所决定,故建议:

    • 使用 CSS3SVGIconFontCanvas 替代图片。展示大量图片的页面,建议使用 Canvas 渲染而非直接使用 img 标签。具体详见 Javascript 的 Image 对象、图像渲染与浏览器内存两三事
    • 适当压缩图片,可减小带宽消耗及图片内存/缓存占用。
    • 使用恰当的图片尺寸,即响应式图片,为不同终端输出不同尺寸图片,勿使用原图缩小代替 ICON 等。
    • 使用恰当的图片格式,如使用 WebP 格式等。详细图片格式对比,使用场景等建议查看 web 前端图片极限优化策略
    • 按需加载及按需渲染图片。
    • 预加载图片时(使用动态创建 img 设置 src 方式),切记要将 img 对象赋为 null ,否则会导致图片内存无法释放。当实际渲染图片时,浏览器会从缓存中再次读取。
    • 将离屏 img 对象赋为 nullsrc 赋为 null ,督促浏览器及时回收内存及像素格式内存。
    • 将非可视区域图片移除,需要时再次渲染。和按需渲染结合时实现很简单,切换 srcv-src 即可。
    • window.URL.createObjectURL 创建的 DOMString 对象,切记使用 window.URL.revokeObjectURL 回收。
      createObjectURL是创建一个内存空间的引用,并且可用于赋值给 imgsrc 等,需要通过手动调用 revokeObjectURL 触发回收。
      复用创建的 URL ,而非多次调用 createObjectURL

    极端边界情况下如何优化 GC

    常见内存泄漏

    GC 算法的日常应用及实现一个内存管理机制

    • 引用计数法
      此处经验为此前开发 Flash 游戏时积累的图片缓存( BitmapData )方案,在前端工作中暂时未有使用,但图片/二进制管理可以套用本方案。
      Flash 中, BitmapData 为图片的像素数据(类似于 JS 中的 ImageData )。同一份素材的图片,可以复用同一份 BitmapData
      故为了管理游戏中的图片资源,则是管理游戏中所有的 BitmapData ,在需要时缓存,用完时 dispose (销毁)。
      具体流程如下:

      • 图片通过 url 引用 BitmapData 时,检测是否有缓存,若没有,步骤二,若有,走步骤三。
      • 使用加载队列按优先级加载资源并按 url 缓存,走到步骤三。
      • url 将引用次数 +1 ,并更新资源使用时间。
      • 图片清理(dispose)时,按 url 将引用次数 -1 ,并更新资源使用时间。
      • 定时检测引用次数为 0 的情况,并依据一定策略延迟/定期清理资源。
      • 定时上报资源管理器中,缓存的资源及引用计数,以方便排查资源引用情况/遗漏清理情况等。
      • 进入战斗场景或对性能吃紧的场景时,清理引用计数为 0 的资源。

    此套方案的缺点在于:如果有图片对象未调用 dispose ,则会内存泄露,但可通过上报排查。
    优点则是可精确控制所有图片的资源引用情况。

    内存溢出如何排查

    Chrome 浏览器排查请参考: chrome 内存泄露(一)、内存泄漏分析工具
    NodeJS 排查请参考:
    [[译]Node.js 垃圾回收与内存泄露的排查](https://www.ctolib.com/topics...
    node 内存泄漏以及定位
    轻松排查线上 Node 内存泄漏问题

    参考链接

    garbage-collector-friendly-code/

    移动 WEB 通用优化策略介绍(二)

    H5 前端性能优化高级进阶

    Javascript 的 Image 对象、图像渲染与浏览器内存两三事

    web 前端图片极限优化策略

    MDN Weakmap

    函数节流、函数防抖实现原理分析

    chrome 内存泄露(一)、内存泄漏分析工具

    [译]Node.js 垃圾回收与内存泄露的排查

    node 内存泄漏以及定位

    轻松排查线上 Node 内存泄漏问题


    Kyle
    47 声望3 粉丝