本文目录

[[toc]]

GC ( Garbage Collection )

什么是垃圾

JS 的值分为原始类型( Primitive Types )和对象类型( Object Types )。

原始类型的值通常直接存储在栈中,但可能因作用域生命周期(闭包)或隐式封装(如 'abc'.toUpperCase() 会临时创建 String 对象 )被提升至堆;对象类型的值始终存储在堆中,变量仅保存指向堆内存的地址。

当变量离开作用域时,栈中的值或地址会被清除,而堆内存也就成为了垃圾,将由垃圾回收机制自动管理,回收无引用的对象。

回收策略

在 JavaScript 内存管理中有一个概念叫做 可达性 ,就是那些以某种方式可访问或者说可用的值,它们被保证存储在内存中,反之不可访问则需回收

至于如何回收,其实就是怎样发现这些不可达的对象(垃圾)它并给予清理的问题, JavaScript 垃圾回收机制的原理说白了也就是定期找出那些不再用到的内存(变量),然后释放其内存

常见的 GC 策略有两种:

  • 标记清除算法
  • 引用计数算法

标记清除算法 ( Mark-Sweep )

目前为止最为常见的策略,大部分浏览器 JS 引擎都采用这种算法,再进行优化。

流程

整个算法分为两部分:

  • 标记: 为活跃对象添加标记
  • 清除: 销毁没有标记的非活跃对象

整体流程如下:

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为 0
  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成 1
  3. 清理所有标记为 0 的垃圾,销毁并回收它们所占用的内存空间
  4. 最后,把所有内存中对象标记修改为 0 ,等待下一轮垃圾回收
---
title: 标记清除法工作流程
---
graph TD
  A[将所有变量标记为非活跃] --> B(遍历根对象,非垃圾标记为活跃)
  B --> C[清理标记为非活跃的垃圾,回收内存]
  C --> D[重置所有标记为非活跃,等待下一轮回收]

优点

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

缺点

  • 内存碎片化: 清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片
  • 内存分配速度慢: 因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢
  • GC 时阻塞: 由于 JS 是单线程的, GC 过程也是执行任务,会占用整个线程,导致当前任务被阻塞。

内存分配策略

  • First-fit 找到大于等于 size 的块立即返回
  • Best-fit 遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit 遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

优化

可以采用 标记整理( Mark-Compact )算法 ,在 GC 后整理内存,移动到内存的同一端,空出完整的剩余内存即可。

引用计数算法

早期使用的 GC 算法,根据是否被引用来决定是否活跃。

流程

  1. 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
  2. 如果同一个值又被赋给另一个变量,那么引用数加 1
  3. 如果该变量的值被其他的值覆盖了,则引用次数减 1
  4. 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存
---
title: 引用计数算法流程
---
graph TD
  Start[开始] --> Declare[声明变量并赋值引用类型]
  Declare --> Count1[引用次数=1]

  Count1 --> AssignNew{是否赋给新变量?}
  AssignNew -- 是 --> AddRef[引用次数+1]
  AddRef --> CountN[当前引用次数=N]

  AssignNew -- 否 --> OverCheck{变量被覆盖?}
  OverCheck -- 是 --> ReduceRef[引用次数-1]
  ReduceRef --> CheckZero{引用次数=0?}

  CheckZero -- 是 --> GC[垃圾回收器回收内存]
  CheckZero -- 否 --> CountN
  CountN --> AssignNew

优点

  • 立即回收垃圾: 只要检测到计数器为 0 ,即可直接回收,不需要定时执行 GC 任务。
  • GC 开销小: 不需要遍历整个堆,只需要对当前操作的对象进行回收即可

缺点

  • 内存占用大: 需要有一个庞大的计数器,保存每个对象的引用数量,由于引用数量上限未知,所以占用的空间也需要预留对应的位置,会占用较大的空间。
  • 循环引用问题: 当出现两个对象互相引用时,双方的计数都不为 0 ,都不会被销毁,也就导致了内存泄露。

V8 GC

V8 的 GC 也是基于标记清除算法进行优化的,称为分代式 GC 。

由于标记清除算法 在 GC 阶段会阻塞线程 ,所以将频繁变更、占用空间较小的部分划分出来,进行高频率 GC ,而不容易变更的部分进行低频 GC ,可以 有效降低 GC 导致的线程阻塞时间

V8 将内存分为新生代、老生代:

  • 新生代: 新创建、占用小、存活时间短的放到新生代,会较为频繁的进行 GC 任务。
  • 老生代: 早创建、占用大、存活时间长的放到老生代, GC 周期更长,减少 GC 任务执行时间。

新生代与老生代使用的不是同一个 GC ,针对不同内存空间特点进行针对性的优化。

V8 的内存结构

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

新生代 GC

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

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

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

当开始进行垃圾回收时,新生代垃圾回收器会对使用区中的活动对象做标记,标记完成之后将使用区的活动对象复制进空闲区并进行排序,随后进入垃圾清理阶段,即将非活动对象占用的空间清理掉。最后进行角色互换,把原来的使用区变成空闲区,把原来的空闲区变成使用区

---
title: 新生代 GC 流程图
---
flowchart TD
  A[使用区快被写满] --> B[触发垃圾回收]
  B --> C[标记使用区中的活动对象]
  C --> D[复制活动对象到空闲区并排序]
  D --> E[清理使用区的非活动对象]
  E --> F[使用区与空闲区角色互换]
  F --> A

新生代晋升老生代

  • 当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,随后会被移动到老生代中。
  • 如果复制一个对象到空闲区时,空闲区空间占用超过了 25% ,那么这个对象会被直接晋升到老生代空间中。

设置为 25% 的比例的原因是,当完成 Scavenge 回收后,空闲区将翻转成使用区,继续进行对象内存的分配,若占比过大,将会影响后续内存分配

老生代 GC

老生代中的对象通常比较大,如果再如新生代一般分区然后复制来复制去就会非常耗时,从而导致回收执行效率不高,所以老生代垃圾回收器来管理其垃圾回收执行,它的整个流程就采用的就是上文所说的 标记整理算法

并行回收

为了降低 GC 对主线程的影响,所以在 GC 时通过开启辅助线程,可以有效降低阻塞时间,也就是并行回收。

但是 即使是并行回收,主线程也会被阻塞 ,否则运行过程中内存可能会出现动态变化,导致标记失真,错误清除活跃对象,或者遗漏不活跃对象。

新生代 GC 主要采用此回收策略。

增量标记

将一次标记过程拆分为多个子任务,逐个标记,即为增量标记,增量标记 主要处理老生代的 GC ,避免一次老生代 GC 长时间阻塞整个线程的情况。

增量标记分为三个部分: 三色标记、写屏障、惰性清理。

三色标记法

原有的标记清理算法将内存对象分为两部分:

  • 已被标记: 使用黑色表示
  • 未被标记: 使用白色表示

三色标记法添加了灰色标记,即:

  • 已被标记: 使用黑色表示
  • 自身被标记,但是引用的其他对象未标记: 使用灰色表示
  • 未被标记: 使用白色表示

整个流程如下:

  1. 将所有对象标记置为白色
  2. 取出某个对象,标记为灰色,并 push 到标记工作表中
  3. 回收器从标记工作表中 pop 引用对象,将其自身由灰转为黑,将下一个引用对象转为灰
  4. 直到无灰色对象,即标记完成

整个过程可以中断、拆分,所以可以增量运行,下次只需要从标记工作表中继续任务即可,从而减少阻塞时间。

---
title: 三色标记法工作流程
---
flowchart TD
  A[所有对象标记为白色] --> B[取出根对象标记为灰色<br>并push到标记工作表]
  B --> C{标记工作表是否为空?}
  C -- 否 --> D[pop灰色对象X]
  D --> E[将X标记为黑色]
  E --> F[遍历X引用的所有子对象Y]
  F --> G{Y是否为白色?}
  G -- 是 --> H[将Y标记为灰色<br>push到工作表]
  G -- 否 --> C
  H --> C
  C -- 是 --> I[标记完成]

写屏障( Write-barrier )

写屏障主要处理的是三色标记法增量标记过程中的异常场景: 在标记后的下一次 GC 过程发生已标记对象修改对象属性,导致标记失真。

写屏障机制会在对象属性被修改的时候触发,判断对象属性是否为对象,是的话添加到缓冲区,缓冲区满或者触发 GC 了,将缓冲区内的对象重新标记为灰色,重新进行三色标记。

// 写屏障伪代码
function writeBarrier(obj, field, newVal) {
  const oldVal = obj[field]
  if (isPointer(oldVal)) {
    storeBuffer.add(oldVal) // 记录旧引用
  }
  obj[field] = newVal // 实际赋值
}
---
title: 写屏障流程图
---
flowchart TD
    A[对象属性被修改] --> B{是否为指针写入?}
    B -- 是 --> C[触发写屏障机制]
    C --> D[将目标对象记录到<br>StoreBuffer缓冲区]
    D --> E[继续应用程序执行]

    B -- 否 --> Z[正常写入操作]

    subgraph 后台处理阶段
        F{StoreBuffer已满<br>或GC需要推进} -- 是 --> G[遍历StoreBuffer]
        G --> H[标记目标对象为灰色]
        H --> I[推进标记工作]
        I --> J{所有标记完成?}
        J -- 是 --> K[完成本轮GC]
        J -- 否 --> I
    end

    E --> F
    Z --> F

整个颜色转换过程如下:

惰性清理

在内存空间足够使用的情况下,没有必要立即释放内存,所以可以延迟执行清理。

在清理的过程中,无需清理所有的非活跃对象内存,可以分任务逐步清理,也就是生产者消费者模型。

并发回收

并发回收主要解决并行回收依旧会阻塞主线程的问题,设计为完全后台运行的回收算法。

但是为了避免主线程在回收过程中重新使用空闲对象的情况, 读写过程需要加上并发锁 ,引入了较高的复杂度。

内存泄露

问题 1

问:这段代码是否会内存泄露,如果会,在哪里泄露的?怎么修改?

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000)

  const id = setTimeout(() => {
    console.log(bigArrayBuffer.byteLength)
  }, 1000)

  return () => clearTimeout(id)
}

globalThis.cancelDemo = demo()

问题 2

问:这段代码是否会内存泄露,如果会,在哪里泄露的?怎么修改?

function demo() {
  const bigArrayBuffer = new ArrayBuffer(100_000_000)

  globalThis.innerFunc1 = () => {
    console.log(bigArrayBuffer.byteLength)
  }

  globalThis.innerFunc2 = () => {
    console.log('hello')
  }
}

demo()

globalThis.innerFunc1 = undefined

问题 3

问:这段代码是否会内存泄露,如果会,在哪里泄露的?怎么修改?

function demo() {
  const bigArrayBuffer1 = new ArrayBuffer(100_000_000)
  const bigArrayBuffer2 = new ArrayBuffer(100_000_000)

  globalThis.innerFunc = () => {
    eval('任意代码,不重要')
  }
}

demo()

问题 4

问:这段代码是否会内存泄露,如果会,在哪里泄露的?怎么修改?

const customEval = eval

function demo() {
  const bigArrayBuffer1 = new ArrayBuffer(100_000_000)
  const bigArrayBuffer2 = new ArrayBuffer(100_000_000)

  globalThis.innerFunc = () => {
    customEval('任意代码,不重要')
  }
}

demo()