JavaScript 垃圾回收

1 JavaScript 垃圾回收机制概述

JavaScript 是一种具有自动内存管理机制的高级编程语言,这意味着开发者不需要手动管理内存的分配和释放。垃圾回收(Garbage Collection,简称 GC)是 JavaScript 引擎提供的一种自动内存管理机制,它可以自动识别不再使用的变量和对象并将它们从内存中清除,以释放内存空间。

1.1 什么是垃圾回收

在 JavaScript 中,垃圾回收是一种自动内存管理机制,它可以自动地识别不再使用的变量和对象并将它们从内存中清除,以释放内存空间。JavaScript 中的垃圾回收器会定期扫描内存中的对象,标记那些可达对象和不可达对象:

arduino 复制代码
// 可达对象指的是当前代码中正在被使用的对象
// 不可达对象指的是已经不再被引用的对象

垃圾回收器会将不可达对象标记为垃圾对象,并将它们从内存中清除。

1.2 为什么需要垃圾回收

程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须提供内存。对于持续运行的服务进程,必须要及时释放内存,否则,内存占用越来越高,轻则影响系统性能,重则会导致进程崩溃。

想象你的应用是个不断注水的水池,正常情况应该有进有出。但如果出水口堵住了,水池就会越来越满直到溢出------这就是内存泄漏的直观表现。在 JavaScript 中,最常见的就是该释放的对象没被释放,导致内存占用持续增长。

1.3 JavaScript 垃圾回收的特点

JavaScript 垃圾回收具有以下特点:

  • 自动化:开发者无需手动分配和释放内存
  • 不可预测性:垃圾回收的具体时机由 JavaScript 引擎决定
  • 可能导致停顿:垃圾回收过程中可能会暂停应用程序的执行

2 内存管理基础

2.1 JavaScript 内存生命周期

JavaScript 内存生命周期通常包括以下几个阶段:

  1. 分配 内存:JavaScript 引擎为变量、对象等分配内存空间
  2. 使用 内存:读写内存,也就是使用变量和对象
  3. 释放 内存:当变量和对象不再被使用时,由垃圾回收器自动释放内存

2.2 栈内存与堆内存

在 JavaScript 中,内存空间分为栈(Stack)和堆(Heap)两种类型:

  • 内存:用于存储基本类型值(如 Number、String、Boolean 等)和函数调用栈。栈内存的分配和释放是自动的、有序的。
  • 堆内存:用于存储引用类型值(如对象、数组、函数等)。堆内存的分配和释放相对复杂,是垃圾回收的主要目标。
ini 复制代码
// 基本类型存储在栈内存中
let num = 10
let str = "hello"
// 引用类型存储在堆内存中,栈中只保存引用地址
let obj = { name: "JavaScript" }
let arr = [1, 2, 3]

2.3 可达性概念

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

可达性的判断基于以下几点:

  • 从根对象(通常是全局对象如 window 或 global)出发
  • 可以通过引用链访问到的对象被认为是可达的
  • 无法通过任何引用链从根对象访问到的对象被认为是不可达的,将被回收
csharp 复制代码
// 可达对象示例
let obj1 = { name: "可达对象" }
let obj2 = obj1  // obj2 引用 obj1
// 现在 obj1 和 obj2 都是可达的
obj1 = null  // 移除 obj1 对对象的引用
// 此时 obj2 仍然引用该对象,所以对象仍然是可达的
obj2 = null  // 移除 obj2 对对象的引用
// 现在该对象不再被任何变量引用,成为不可达对象,将被垃圾回收

3 垃圾回收核心算法

JavaScript 中的垃圾回收机制主要有两种基本算法:引用计数和标记清除。现代 JavaScript 引擎大多使用标记清除算法及其变体。

3.1 引用计数算法

引用计数(Reference Counting)是一种早期的垃圾回收算法,它通过记录每个对象被引用的次数来判断该对象是否仍然被使用。

3.1.1 工作原理

引用计数算法的工作原理如下:

  1. 给每个对象添加一个引用计数器,初始值为 0
  2. 当对象被引用时,引用计数器加 1
  3. 当对象的引用被解除时,引用计数器减 1
  4. 当引用计数器为 0 时,说明该对象不再被使用,可以被回收
csharp 复制代码
// 引用计数示例
let obj = { name: "test" }  // 对象引用计数为 1
let ref1 = obj              // 引用计数加 1,变为 2
ref1 = null                 // 引用计数减 1,变为 1
obj = null                  // 引用计数减 1,变为 0,对象将被回收

3.1.2 优点与缺点

优点

  • 实时回收:引用计数可以在对象不再被引用时立即回收,不需要等待垃圾收集器的运行
  • 简单高效:实现相对简单,回收过程短暂,对程序运行影响小

缺点

  • 循环引用问题:无法处理对象之间的循环引用
  • 计数开销:维护每个对象的引用计数需要占用额外内存空间
  • 频繁更新:每次添加或删除引用都需要更新计数,增加了额外开销

3.1.3 循环引用问题

引用计数算法的主要缺陷是无法处理循环引用问题。当两个或多个对象相互引用,即使它们已经不再被其他对象引用,它们的引用计数也不会变为 0,从而导致内存泄漏。

csharp 复制代码
// 循环引用问题示例
function createCircularReference() {
  const obj1 = {}
  const obj2 = {}
  
  obj1.ref = obj2  // obj1 引用 obj2,obj2 引用计数变为 1
  obj2.ref = obj1  // obj2 引用 obj1,obj1 引用计数变为 1
  
  return "循环引用已创建"
}
createCircularReference()
// 函数执行完毕后,obj1 和 obj2 仍然相互引用,引用计数都为 1
// 使用引用计数算法的垃圾回收器无法回收这些对象,导致内存泄漏

由于循环引用问题,现代 JavaScript 引擎已经很少使用引用计数算法作为主要的垃圾回收机制。

3.2 标记清除算法

标记清除(Mark-and-Sweep)是现代 JavaScript 引擎中主流的垃圾回收算法,它通过标记可达对象并清除未标记对象来实现垃圾回收。

3.2.1 工作原理

标记清除算法主要分为两个阶段:

  1. 标记阶段:从根对象开始遍历所有对象,标记所有可达对象
  2. 清除阶段:遍历所有对象,回收未被标记的对象(不可达对象)
css 复制代码
// 标记清除算法工作流程示意
let a = { name: "对象 A" }
let b = { name: "对象 B" }
a.ref = b  // 对象 A 引用对象 B
b = null   // 移除对对象 B 的直接引用
// 此时对象 B 仍然被对象 A 引用,因此在标记阶段会被标记为可达对象
a = null   // 移除对对象 A 的引用
// 现在对象 A 和对象 B 都成为不可达对象,将在清除阶段被回收

3.2.2 优点与缺点

优点

  • 解决循环引用问题:能够正确处理对象之间的循环引用
  • 实现相对简单:算法逻辑清晰,易于实现

缺点

  • 内存碎片化:回收后会产生大量不连续的内存碎片
  • 回收停顿:在进行垃圾回收时需要暂停应用程序执行(Stop-The-World)
  • 效率问题:需要遍历所有对象,当对象数量较多时效率较低

3.2.3 解决循环引用问题

标记清除算法能够有效解决循环引用问题。即使对象之间存在循环引用,只要它们无法从根对象访问到,就会被标记为不可达对象并被回收。

csharp 复制代码
// 标记清除算法处理循环引用
function createCircularReference() {
  const obj1 = {}
  const obj2 = {}
  
  obj1.ref = obj2  // 循环引用
  obj2.ref = obj1  // 循环引用
}
createCircularReference()
// 函数执行完毕后,obj1 和 obj2 无法从全局对象访问
// 标记清除算法会将它们标记为不可达对象并回收

3.3 标记整理算法

标记整理(Mark-and-Compact)算法是对标记清除算法的改进,主要解决了内存碎片化问题。

3.3.1 工作原理

标记整理算法在标记清除算法的基础上增加了内存整理阶段:

  1. 标记阶段:与标记清除算法相同,标记所有可达对象
  2. 整理阶段:将所有存活对象向内存的一端移动,使它们紧凑排列
  3. 清除阶段:清理边界以外的所有内存空间

3.3.2 解决内存碎片问题

标记整理算法通过移动存活对象,使空闲内存空间连续,有效解决了内存碎片化问题:

  • 提高内存利用率:连续的内存空间可以分配给需要大块内存的对象
  • 减少分配时间:分配内存时不需要遍历整个空闲内存列表寻找合适大小的块

4 V8 引擎的垃圾回收机制

V8 是 Google 开发的 JavaScript 引擎,用于 Chrome 浏览器和 Node.js 环境。V8 采用了分代垃圾回收机制,结合多种优化技术,以提高垃圾回收效率。

4.1 分代回收策略

V8 引擎的垃圾回收基于代际假说(The Generational Hypothesis),该假说有两个特点:

  1. 大部分对象在内存中存在的时间很短
  2. 不死的对象,会活得更久

基于这个假说,V8 将堆内存分为两个主要区域:

  • 新生代(Young Generation) :存储存活时间较短的对象
  • 老生代(Old Generation) :存储存活时间较长或体积较大的对象

4.1.1 新生代与老生代

  • 新生代:通常只支持 1~8 MB 的容量,采用 Scavenge 算法进行垃圾回收
  • 老生代:容量较大(64位环境下约为 1400MB),采用标记-清除和标记-整理算法

4.1.2 对象晋升机制

当新生代对象满足以下条件之一时,会被晋升到老生代:

  1. 对象经历了一定次数的垃圾回收后仍然存活(通常是 2 次)
  2. 在 Scavenge 算法的复制过程中,To 空间的使用率超过 25%
  3. 对象体积过大,直接分配到老生代

4.2 新生代垃圾回收

新生代采用 Scavenge 算法(也称为复制算法)进行垃圾回收,该算法将新生代内存空间分为两个半区:

  • From 空间:当前正在使用的空间
  • To 空间:空闲空间

4.2.1 Scavenge 算法工作流程

  1. 新对象被分配到 From 空间
  2. 当 From 空间快被写满时,触发垃圾回收
  3. 标记 From 空间中的存活对象
  4. 将存活对象复制到 To 空间,并按顺序排列
  5. 交换 From 空间和 To 空间的角色
  6. 清空原 From 空间(现在的 To 空间)
vbnet 复制代码
// Scavenge 算法示意图
// 初始状态: From 空间有对象,To 空间为空
// From: [A, B, C, D] To: []
// 标记存活对象后复制到 To 空间
// From: [A, B, C, D] To: [A, C]
// 交换空间角色
// From: [] To: [A, C]

4.2.2 Scavenge 算法的优缺点

优点

  • 回收效率高:只处理新生代中的少量存活对象
  • 无内存碎片:复制过程中自动整理内存
  • 暂停时间短:新生代空间小,操作速度快

缺点

  • 内存利用率低:需要一半的空间作为空闲空间
  • 不适合大量存活对象:如果大部分对象都是存活的,复制成本高

4.3 老生代垃圾回收

老生代中的对象具有两个特点:存活时间长和对象体积大。因此,V8 对老生代采用标记-清除 (Mark-Sweep)和标记-整理(Mark-Compact)算法结合的方式进行垃圾回收。

4.3.1 标记-清除算法

标记-清除算法是老生代的主要垃圾回收算法,工作流程如下:

  1. 标记阶段:从根对象开始遍历,标记所有可达对象
  2. 清除阶段:遍历整个堆内存,回收未被标记的对象

4.3.2 标记-整理算法

为解决标记-清除算法导致的内存碎片化问题,V8 在必要时会使用标记-整理算法:

  1. 标记阶段:与标记-清除算法相同,标记所有可达对象
  2. 整理阶段:将所有存活对象向内存的一端移动
  3. 清除阶段:清理边界以外的所有内存空间

4.4 V8 垃圾回收优化技术

为了减少垃圾回收对应用性能的影响,V8 引入了多种优化技术:

4.4.1 增量标记(Incremental Marking)

传统的标记阶段需要一次性完成,会导致长时间的停顿。增量标记将标记过程拆分为多个小任务,穿插在应用程序执行过程中:

  1. 垃圾回收器进行一小部分标记工作
  2. 然后让应用程序执行一段时间
  3. 重复上述过程直到标记阶段完成

这种方式显著减少了单次停顿时间,提高了应用响应性。

4.4.2 并发标记(Concurrent Marking)

并发标记允许垃圾回收器在应用程序执行的同时进行标记工作:

  1. 主线程执行 JavaScript 代码
  2. 辅助线程同时进行标记工作
  3. 必要时进行短暂同步

并发标记进一步减少了垃圾回收导致的停顿时间。

4.4.3 并行回收(Parallel Collection)

并行回收利用多核 CPU 的优势,使用多个线程同时进行垃圾回收:

  • 新生代的 Scavenge 算法使用并行回收
  • 多个线程同时复制存活对象,提高回收效率

5 内存泄漏及避免方法

尽管 JavaScript 具有自动垃圾回收机制,但如果代码中存在不当引用,仍然可能导致内存泄漏。内存泄漏会导致应用程序性能下降,甚至崩溃。

5.1 常见内存泄漏原因

5.1.1 意外的全局变量

在函数内部未使用 varletconst 声明的变量会成为全局变量,不会被垃圾回收:

scss 复制代码
// 不好的做法:意外创建全局变量
function leakData() {
  // 缺少声明关键字,变成全局变量
  largeData = new Array(1000000).fill('leak')
}
leakData()
// largeData 成为全局变量,即使函数执行完毕也不会被回收

5.1.2 未清理的定时器和回调函数

如果定时器或回调函数不再需要但未被清理,会导致其引用的对象无法被回收:

scss 复制代码
// 不好的做法:未清理的定时器
function startTimer() {
  const data = { important: 'data' }
  
  // 定时器引用了 data 对象
  setInterval(() => {
    console.log(data.important)
  }, 1000)
  
  // 即使不再需要定时器,也没有清理
}
startTimer()
// data 对象和定时器回调函数将永远存在,导致内存泄漏

正确的做法是在不需要定时器时清理它:

javascript 复制代码
// 好的做法:及时清理定时器
function startTimer() {
  const data = { important: 'data' }
  const timerId = setInterval(() => {
    console.log(data.important)
  }, 1000)
  
  return timerId
}
const timerId = startTimer()
// 不再需要时清理定时器
clearInterval(timerId)

5.1.3 闭包引起的内存泄漏

闭包可以访问外部函数的变量,但如果闭包被长期保存,会导致外部函数的变量无法被回收:

javascript 复制代码
// 不好的做法:闭包导致的内存泄漏
function createClosure() {
  // 创建一个大对象
  const largeArray = new Array(1000000).fill('memory')
  
  // 返回闭包,引用了 largeArray
  return function() {
    // 即使只使用数组长度,也会保留整个数组的引用
    return largeArray.length
  }
}
// 闭包被长期保存
const closure = createClosure()
// largeArray 将永远无法被回收,导致内存泄漏

优化方法是只保留必要的引用:

javascript 复制代码
// 好的做法:减少闭包中的引用
function createBetterClosure() {
  const largeArray = new Array(1000000).fill('memory')
  // 只保留必要的信息
  const length = largeArray.length
  
  // 闭包只引用必要的变量
  return function() {
    return length
  }
}
const betterClosure = createBetterClosure()
// largeArray 在函数执行后即可被回收

5.1.4 DOM 引用问题

如果在 JavaScript 中保留了对 DOM 元素的引用,而这些元素已经从 DOM 树中移除,会导致 DOM 元素无法被回收:

scss 复制代码
// 不好的做法:DOM 引用导致的内存泄漏
function createDOMLeak() {
  const element = document.getElementById('leak-element')
  
  // 将 DOM 元素存储在数组中
  const elements = []
  elements.push(element)
  
  // 移除 DOM 元素
  element.parentNode.removeChild(element)
  
  // 但 elements 数组仍然引用该 DOM 元素,导致无法回收
}
createDOMLeak()

正确的做法是在移除 DOM 元素后,同时移除 JavaScript 中的引用:

scss 复制代码
// 好的做法:清理 DOM 引用
function safeRemoveDOM() {
  const element = document.getElementById('safe-element')
  const elements = []
  elements.push(element)
  
  // 移除 DOM 元素
  element.parentNode.removeChild(element)
  
  // 清理引用
  elements.pop()
  element = null
}
safeRemoveDOM()

5.2 使用 Chrome DevTools 检测内存泄漏

Chrome 开发者工具提供了强大的内存分析功能,可以帮助我们检测和定位内存泄漏问题。

5.2.1 内存快照分析

  1. 打开 Chrome DevTools(F12 或 Ctrl+Shift+I)
  2. 切换到 Memory 面板
  3. 选择 "Heap snapshot" 并点击 "Take snapshot" 按钮
  4. 快照生成后,可以分析内存中的对象分布

5.2.2 内存分配时间线

  1. 在 Memory 面板中选择 "Allocation instrumentation on timeline"
  2. 点击录制按钮开始记录
  3. 执行可能导致内存泄漏的操作
  4. 停止录制,分析内存分配情况

5.2.3 堆快照对比

  1. 执行操作前拍摄一次堆快照
  2. 执行可疑操作后拍摄第二次快照
  3. 在 Memory 面板中选择对比视图
  4. 分析两次快照之间新增的对象,重点关注持续增长的对象类型

5.3 避免内存泄漏的最佳实践

  1. 避免意外的全局变量 :始终使用 varletconst 声明变量
  2. 及时清理定时器和事件 监听器 :在不需要时使用 clearIntervalclearTimeoutremoveEventListener
  3. 合理使用闭包:避免在闭包中引用不必要的大对象
  4. 管理 DOM 引用:移除 DOM 元素时同时清理 JavaScript 中的引用
  5. 使用 弱引用 数据结构:对非必需的引用使用 WeakMap 和 WeakSet
  6. 限制缓存大小:实现缓存淘汰机制,避免无限制缓存数据

6 垃圾回收性能优化

了解垃圾回收机制可以帮助我们编写更高效的 JavaScript 代码,减少垃圾回收对性能的影响。

6.1 减少垃圾回收压力

6.1.1 避免创建过多临时对象

频繁创建和销毁对象会增加垃圾回收的频率和压力:

javascript 复制代码
// 不好的做法:频繁创建临时对象
function processData(dataArray) {
  return dataArray.map(item => {
    // 每次迭代都创建新对象
    return {
      id: item.id,
      value: item.value * 2
    }
  })
}
// 频繁调用会创建大量临时对象
setInterval(() => {
  processData(largeDataset)
}, 100)

优化方法:

scss 复制代码
// 好的做法:减少临时对象创建
// 预先分配数组
const resultArray = new Array(largeDataset.length)
function processDataOptimized(dataArray, resultArray) {
  for (let i = 0; i < dataArray.length; i++) {
    // 复用现有对象或直接修改属性
    if (!resultArray[i]) {
      resultArray[i] = {}
    }
    resultArray[i].id = dataArray[i].id
    resultArray[i].value = dataArray[i].value * 2
  }
  return resultArray
}

6.1.2 对象复用与对象池

对于频繁创建和销毁的对象,可以使用对象池技术进行复用:

kotlin 复制代码
// 对象池示例
class ObjectPool {
  constructor(createFn, resetFn) {
    this.pool = []
    this.createFn = createFn
    this.resetFn = resetFn
  }
  
  // 获取对象
  get() {
    if (this.pool.length > 0) {
      return this.pool.pop()
    }
    // 创建新对象
    return this.createFn()
  }
  
  // 回收对象
  release(obj) {
    // 重置对象状态
    this.resetFn(obj)
    this.pool.push(obj)
  }
}
// 使用对象池
const particlePool = new ObjectPool(
  () => ({ x: 0, y: 0, velocity: 0 }),
  (obj) => {
    obj.x = 0
    obj.y = 0
    obj.velocity = 0
  }
)
// 获取对象
const particle = particlePool.get()
// 使用对象...
// 不再需要时回收
particlePool.release(particle)

6.2 弱引用数据结构

ES6 引入了 WeakMap 和 WeakSet 两种弱引用数据结构,它们不会阻止垃圾回收器回收其键名/元素。

6.2.1 WeakMap 的应用

WeakMap 的键是弱引用的,当键对象不再被其他地方引用时,对应的键值对会被自动移除:

kotlin 复制代码
// WeakMap 示例
const cache = new WeakMap()
function process(obj) {
  // 检查缓存
  if (cache.has(obj)) {
    return cache.get(obj)
  }
  
  // 处理数据
  const result = expensiveCalculation(obj)
  
  // 缓存结果,键是弱引用
  cache.set(obj, result)
  return result
}
let data = { value: 42 }
process(data)
// 当 data 不再需要时
data = null
// 此时 WeakMap 中的对应条目会被自动移除,不会阻止 data 对象被回收

6.2.2 WeakSet 的应用

WeakSet 存储的对象是弱引用的,当对象不再被其他地方引用时,会自动从 WeakSet 中移除:

javascript 复制代码
// WeakSet 示例
const activeElements = new WeakSet()
function activate(element) {
  activeElements.add(element)
  // 激活元素...
}
function isActive(element) {
  return activeElements.has(element)
}
let div = document.createElement('div')
activate(div)
// 使用元素...
// 当元素被移除时
div.parentNode.removeChild(div)
div = null
// activeElements 中的条目会自动被移除

6.3 FinalizationRegistry API

FinalizationRegistry 是 ES2021 引入的 API,用于注册在对象被垃圾回收时调用的回调函数:

javascript 复制代码
// FinalizationRegistry 示例
const registry = new FinalizationRegistry((value) => {
  console.log(`对象 ${value} 已被回收`)
})
let obj = { name: "可回收对象" }
registry.register(obj, "测试对象")
// 当对象不再被引用时
obj = null
// 垃圾回收后,会调用注册的回调函数

FinalizationRegistry 可用于资源清理、缓存失效等场景,但不应过度依赖,因为无法保证回调函数的执行时机。

7 总结与展望

JavaScript 的垃圾回收机制是自动内存管理的核心,它使开发者能够专注于业务逻辑而不必过多关注内存管理细节。本文详细介绍了 JavaScript 垃圾回收的基本原理、核心算法以及 V8 引擎的优化策略。

7.1 主要知识点总结

  • 垃圾回收基本概念:自动识别和回收不再使用的内存
  • 核心算法:引用计数、标记清除、标记整理
  • V8 分代回收:新生代使用 Scavenge 算法,老生代使用标记-清除和标记-整理算法
  • 优化技术:增量标记、并发标记、并行回收
  • 内存泄漏:常见原因包括意外全局变量、未清理的定时器、闭包滥用和 DOM 引用问题
  • 性能优化:减少临时对象创建、对象复用、使用弱引用数据结构

7.2 未来发展趋势

随着 Web 应用的复杂化,JavaScript 垃圾回收机制也在不断进化:

  1. 更智能的分代策略:基于对象实际生命周期调整分代策略
  2. 低延迟回收:进一步减少垃圾回收导致的应用停顿
  3. 自适应算法:根据应用类型和运行环境动态调整回收策略
  4. 更高效的内存压缩:减少内存碎片,提高内存利用率

了解 JavaScript 垃圾回收机制不仅有助于编写更高效的代码,还能帮助开发者更好地理解和解决性能问题。随着 JavaScript 引擎的不断优化,垃圾回收机制将变得更加高效和智能,为 Web 应用提供更好的性能基础。

相关推荐
萌萌哒草头将军2 小时前
🚀🚀🚀React Router 现在支持 SRC 了!!!
javascript·react.js·preact
Adolf_19937 小时前
React 中 props 的最常用用法精选+useContext
前端·javascript·react.js
前端小趴菜057 小时前
react - 根据路由生成菜单
前端·javascript·react.js
喝拿铁写前端7 小时前
`reduce` 究竟要不要用?到底什么时候才“值得”用?
前端·javascript·面试
空の鱼7 小时前
js与vue基础学习
javascript·vue.js·学习
1024小神8 小时前
Cocos游戏中UI跟随模型移动,例如人物头上的血条、昵称条等
前端·javascript
哑巴语天雨8 小时前
Cesium初探-CallbackProperty
开发语言·前端·javascript·3d
JosieBook9 小时前
【前端】Vue 3 页面开发标准框架解析:基于实战案例的完整指南
前端·javascript·vue.js
薄荷椰果抹茶9 小时前
前端技术之---应用国际化(vue-i18n)
前端·javascript·vue.js
Kiri霧9 小时前
Kotlin重写函数中的命名参数
android·开发语言·javascript·kotlin