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 内存生命周期通常包括以下几个阶段:
- 分配 内存:JavaScript 引擎为变量、对象等分配内存空间
- 使用 内存:读写内存,也就是使用变量和对象
- 释放 内存:当变量和对象不再被使用时,由垃圾回收器自动释放内存
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 工作原理
引用计数算法的工作原理如下:
- 给每个对象添加一个引用计数器,初始值为 0
- 当对象被引用时,引用计数器加 1
- 当对象的引用被解除时,引用计数器减 1
- 当引用计数器为 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 工作原理
标记清除算法主要分为两个阶段:
- 标记阶段:从根对象开始遍历所有对象,标记所有可达对象
- 清除阶段:遍历所有对象,回收未被标记的对象(不可达对象)
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 工作原理
标记整理算法在标记清除算法的基础上增加了内存整理阶段:
- 标记阶段:与标记清除算法相同,标记所有可达对象
- 整理阶段:将所有存活对象向内存的一端移动,使它们紧凑排列
- 清除阶段:清理边界以外的所有内存空间
3.3.2 解决内存碎片问题
标记整理算法通过移动存活对象,使空闲内存空间连续,有效解决了内存碎片化问题:
- 提高内存利用率:连续的内存空间可以分配给需要大块内存的对象
- 减少分配时间:分配内存时不需要遍历整个空闲内存列表寻找合适大小的块
4 V8 引擎的垃圾回收机制
V8 是 Google 开发的 JavaScript 引擎,用于 Chrome 浏览器和 Node.js 环境。V8 采用了分代垃圾回收机制,结合多种优化技术,以提高垃圾回收效率。
4.1 分代回收策略
V8 引擎的垃圾回收基于代际假说(The Generational Hypothesis),该假说有两个特点:
- 大部分对象在内存中存在的时间很短
- 不死的对象,会活得更久
基于这个假说,V8 将堆内存分为两个主要区域:
- 新生代(Young Generation) :存储存活时间较短的对象
- 老生代(Old Generation) :存储存活时间较长或体积较大的对象
4.1.1 新生代与老生代
- 新生代:通常只支持 1~8 MB 的容量,采用 Scavenge 算法进行垃圾回收
- 老生代:容量较大(64位环境下约为 1400MB),采用标记-清除和标记-整理算法
4.1.2 对象晋升机制
当新生代对象满足以下条件之一时,会被晋升到老生代:
- 对象经历了一定次数的垃圾回收后仍然存活(通常是 2 次)
- 在 Scavenge 算法的复制过程中,To 空间的使用率超过 25%
- 对象体积过大,直接分配到老生代
4.2 新生代垃圾回收
新生代采用 Scavenge 算法(也称为复制算法)进行垃圾回收,该算法将新生代内存空间分为两个半区:
- From 空间:当前正在使用的空间
- To 空间:空闲空间
4.2.1 Scavenge 算法工作流程
- 新对象被分配到 From 空间
- 当 From 空间快被写满时,触发垃圾回收
- 标记 From 空间中的存活对象
- 将存活对象复制到 To 空间,并按顺序排列
- 交换 From 空间和 To 空间的角色
- 清空原 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 标记-清除算法
标记-清除算法是老生代的主要垃圾回收算法,工作流程如下:
- 标记阶段:从根对象开始遍历,标记所有可达对象
- 清除阶段:遍历整个堆内存,回收未被标记的对象
4.3.2 标记-整理算法
为解决标记-清除算法导致的内存碎片化问题,V8 在必要时会使用标记-整理算法:
- 标记阶段:与标记-清除算法相同,标记所有可达对象
- 整理阶段:将所有存活对象向内存的一端移动
- 清除阶段:清理边界以外的所有内存空间
4.4 V8 垃圾回收优化技术
为了减少垃圾回收对应用性能的影响,V8 引入了多种优化技术:
4.4.1 增量标记(Incremental Marking)
传统的标记阶段需要一次性完成,会导致长时间的停顿。增量标记将标记过程拆分为多个小任务,穿插在应用程序执行过程中:
- 垃圾回收器进行一小部分标记工作
- 然后让应用程序执行一段时间
- 重复上述过程直到标记阶段完成
这种方式显著减少了单次停顿时间,提高了应用响应性。
4.4.2 并发标记(Concurrent Marking)
并发标记允许垃圾回收器在应用程序执行的同时进行标记工作:
- 主线程执行 JavaScript 代码
- 辅助线程同时进行标记工作
- 必要时进行短暂同步
并发标记进一步减少了垃圾回收导致的停顿时间。
4.4.3 并行回收(Parallel Collection)
并行回收利用多核 CPU 的优势,使用多个线程同时进行垃圾回收:
- 新生代的 Scavenge 算法使用并行回收
- 多个线程同时复制存活对象,提高回收效率
5 内存泄漏及避免方法
尽管 JavaScript 具有自动垃圾回收机制,但如果代码中存在不当引用,仍然可能导致内存泄漏。内存泄漏会导致应用程序性能下降,甚至崩溃。
5.1 常见内存泄漏原因
5.1.1 意外的全局变量
在函数内部未使用 var
、let
或 const
声明的变量会成为全局变量,不会被垃圾回收:
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 内存快照分析
- 打开 Chrome DevTools(F12 或 Ctrl+Shift+I)
- 切换到 Memory 面板
- 选择 "Heap snapshot" 并点击 "Take snapshot" 按钮
- 快照生成后,可以分析内存中的对象分布
5.2.2 内存分配时间线
- 在 Memory 面板中选择 "Allocation instrumentation on timeline"
- 点击录制按钮开始记录
- 执行可能导致内存泄漏的操作
- 停止录制,分析内存分配情况
5.2.3 堆快照对比
- 执行操作前拍摄一次堆快照
- 执行可疑操作后拍摄第二次快照
- 在 Memory 面板中选择对比视图
- 分析两次快照之间新增的对象,重点关注持续增长的对象类型
5.3 避免内存泄漏的最佳实践
- 避免意外的全局变量 :始终使用
var
、let
或const
声明变量 - 及时清理定时器和事件 监听器 :在不需要时使用
clearInterval
、clearTimeout
和removeEventListener
- 合理使用闭包:避免在闭包中引用不必要的大对象
- 管理 DOM 引用:移除 DOM 元素时同时清理 JavaScript 中的引用
- 使用 弱引用 数据结构:对非必需的引用使用 WeakMap 和 WeakSet
- 限制缓存大小:实现缓存淘汰机制,避免无限制缓存数据
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 垃圾回收机制也在不断进化:
- 更智能的分代策略:基于对象实际生命周期调整分代策略
- 低延迟回收:进一步减少垃圾回收导致的应用停顿
- 自适应算法:根据应用类型和运行环境动态调整回收策略
- 更高效的内存压缩:减少内存碎片,提高内存利用率
了解 JavaScript 垃圾回收机制不仅有助于编写更高效的代码,还能帮助开发者更好地理解和解决性能问题。随着 JavaScript 引擎的不断优化,垃圾回收机制将变得更加高效和智能,为 Web 应用提供更好的性能基础。