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 应用提供更好的性能基础。

相关推荐
十一吖i2 小时前
vue3表格显示隐藏列全屏拖动功能
前端·javascript·vue.js
徐同保4 小时前
tailwindcss暗色主题切换
开发语言·前端·javascript
生莫甲鲁浪戴4 小时前
Android Studio新手开发第二十七天
前端·javascript·android studio
细节控菜鸡6 小时前
【2025最新】ArcGIS for JS 实现随着时间变化而变化的热力图
开发语言·javascript·arcgis
拉不动的猪7 小时前
h5后台切换检测利用visibilitychange的缺点分析
前端·javascript·面试
桃子不吃李子8 小时前
nextTick的使用
前端·javascript·vue.js
Devil枫9 小时前
HarmonyOS鸿蒙应用:仓颉语言与JavaScript核心差异深度解析
开发语言·javascript·ecmascript
惺忪97989 小时前
回调函数的概念
开发语言·前端·javascript
前端 贾公子10 小时前
Element Plus组件v-loading在el-dialog组件上使用无效
前端·javascript·vue.js
天外飞雨道沧桑10 小时前
JS/CSS实现元素样式隔离
前端·javascript·css·人工智能·ai