js内存泄露与垃圾回收机制
文章目录
- 一、内存泄漏
- 1. 什么是内存泄露
- 2. 常见内存泄漏场景及代码示例
- A 意外的全局变量
- B 被遗忘的定时器或回调
- C 闭包(Closures)的不当使用
- D 脱离 DOM 的引用
- 二、JavaScript 垃圾回收机制 (Garbage Collection)
- 1. 垃圾回收的核心判断标准:可达性 (Reachable)
- 2. 核心算法
- A. 标记-清除算法 (Mark-and-Sweep)
- B. 引用计数算法 (Reference Counting)
- 3. V8 引擎的优化:分代回收 (Generational Collection)
- A 核心内存分代架构
- B 为什么必须分代?
- C V8 进阶优化技术
- 4. 常见的内存泄漏场景
- 三、JavaScript 运行时内存布局解析
- 1. 内存空间划分概览
- 2. 不同 JS 内容的存放位置
- 2.1 基础类型变量 (Primitive Types)
- 2.2 引用类型对象 (Reference Types)
- 2.3 函数 (Functions)
- 2.4 局部变量 (Local Variables)
- 2.5 全局变量 (Global Variables)
- 2.6 闭包中的变量 (Variables in Closures)
- 3. 内存分配示意
- 4. 为什么要这样划分?
一、内存泄漏
1. 什么是内存泄露
简单来说,内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃。
在 JavaScript 中,这通常意味着一些本该被垃圾回收机制(GC)销毁的对象,由于某些非预期的引用链,依然被标记为“可达”。
2. 常见内存泄漏场景及代码示例
A 意外的全局变量
全局变量的生命周期与页面一致,除非页面关闭,否则不会被回收。
functionleak(){// 这种写法等同于 window.bar = "I am a leak";bar="I am a leak";}避免方法:始终使用 let、const 或 var 声明变量。开启 use strict 严格模式可以有效防止此类错误。
B 被遗忘的定时器或回调
如果循环定时器(setInterval)没有被显式清除,它内部引用的变量将永远留在内存中。
代码如下(示例):
letsomeData=loadData();setInterval(function(){letnode=document.getElementById('container');if(node){node.innerHTML=JSON.stringify(someData);}},1000);// 即使 container 节点被删除了,定时器依然在运行,someData 也就无法回收避免方法:在不需要定时器时,调用 clearInterval()。
C 闭包(Closures)的不当使用
闭包可以访问外部函数的变量。如果闭包长期存在,它引用的变量也会被长期占用。
letleak=function(){letheavyData=newArray(1000000);// 占用大内存returnfunction(){console.log("I still have access to heavyData");};};letstayInMemory=leak();// heavyData 无法被回收避免方法:只有在确有必要时使用闭包,或者在闭包使用完后将持有闭包的变量置为 null。
D 脱离 DOM 的引用
有时候你在 JS 里保存了对 DOM 节点的引用,但之后这个节点在页面上被删除了。由于 JS 变量依然指向它,该 DOM 节点及其子元素都无法被回收。
letelements={button:document.getElementById('button')};functionremoveButton(){document.body.removeChild(document.getElementById('button'));// 此时虽然页面上没按钮了,但 elements.button 依然引用着它}避免方法:删除 DOM 节点后,手动将对应的变量设为 null。
二、JavaScript 垃圾回收机制 (Garbage Collection)
简单来说,JavaScript 的垃圾回收机制(简称 GC)就像是一个自动化的“内存清洁工”。
在编写代码时,我们会创建变量、对象和函数,这些都会占用计算机的内存。如果这些不再使用的内存不被释放,就会导致“内存泄漏”,最终让程序变卡甚至崩溃。JS 引擎(如 Chrome 的 V8)会自动找出那些不再需要的变量,并释放它们占用的空间。
1. 垃圾回收的核心判断标准:可达性 (Reachable)
JS 判断一个对象是否需要被回收,主要看它是否“可达”。
- 根对象 (Roots):包括全局对象 (
window/global)、当前执行栈中的局部变量和参数等。 - 可达:如果从根对象出发,通过引用链条能够找到某个对象,说明该对象还在使用,不能回收。
- 不可达:如果一个对象没有任何引用指向它,或者从根部出发无法访问到它,它就被视为“垃圾”。
2. 核心算法
A. 标记-清除算法 (Mark-and-Sweep)
这是现代浏览器最常用的算法。它分为两个阶段:
- 标记:垃圾回收器从根对象开始遍历,给所有能找到的对象打上“活跃”标记。
- 清除:遍历整个内存,将那些没有标记的对象销毁,回收内存空间。
优点:能够有效处理“循环引用”的问题(即两个废弃对象互相引用,但都与外界断开的情况)。
B. 引用计数算法 (Reference Counting)
这是一种较老的策略,现在基本不再单独使用。
- 每当有一个地方引用该对象,计数器+ 1 +1+1。
- 引用失效时,计数器− 1 -1−1。
- 当计数为 0 时,内存立即被回收。
缺点:无法处理循环引用。如果 A 引用 B,B 也引用 A,它们的计数永远不为 0,导致内存泄漏。
3. V8 引擎的优化:分代回收 (Generational Collection)
JavaScript 的内存管理是自动化的,V8 引擎通过高效的垃圾回收策略来平衡程序性能与内存占用。
V8 引擎采用分代回收(Generational Collection)的核心理由是基于计算机科学中一个著名的假说:“弱分代假说”(The Generational Hypothesis)。
- 核心观点: 绝大多数对象在内存中存活的时间都很短(“朝生夕死”),而熬过多次回收的对象通常会存活很久。
为了兼顾 **“回收频率” **和 **“扫描性能” **,V8 将堆内存划分为两个主要的区域:新生代(Young Generation)和老生代(Old Generation)。
A 核心内存分代架构
V8 将堆内存分为新生代 (Young Generation)和老生代 (Old Generation),并对它们采用不同的回收策略。
新生代 (Young Generation)
- 适用对象:存活时间短的对象(如局部变量、临时计算结果)。
- 内存特征:空间小(通常 1MB - 8MB),回收极其频繁。
- 核心算法:Scavenge (Semi-space)
- 划分:内存被对半分为
From-space(活跃区)和To-space(空闲区)。 - 分配:新对象始终在
From-space中分配。 - 标记与复制:当
From-space快满时,GC 开始运行。它将From-space中的存活对象连续复制到To-space。 - 整理:复制过程中,对象被紧凑地排列,直接解决了内存碎片问题。
- 角色翻转:清空
From-space,然后交换两个空间的角色。
- 划分:内存被对半分为
- 晋升机制:对象如果经历过两次 Scavenge 回收依然存活,或者
To-space占比超过 25%,则会被移动到老生代。
老生代 (Old Generation)
- 适用对象:存活时间长(如全局变量、闭包)或体积巨大的对象。
- 核心算法:Mark-Sweep & Mark-Compact
- Mark-Sweep (标记-清除):遍历堆内存,标记可达对象,直接释放不可达对象的内存。
- Mark-Compact (标记-整理):为了解决清除后产生的碎片,将所有存活对象推向内存的一端,然后清理掉端边界以外的内存。
| 区域 | 存放对象特点 | 主要算法 |
|---|---|---|
| 新生代 (Young Gen) | 存活时间短的对象(如局部变量) | Scavenge 算法:将空间对半分为 From 和 To,通过复制活跃对象来清理空间。 |
| 老生代 (Old Gen) | 存活时间长或常驻的对象(如全局变量) | 标记-清除 (Mark-Sweep)与标记-整理 (Mark-Compact)。 |
B 为什么必须分代?
如果不分代,每次 GC 都进行全堆扫描,会导致显著的性能问题:
- 降低 Stop-The-World (STW) 影响:全堆扫描大型内存(如 1.5G)会使 JS 主线程暂停数百毫秒,导致界面掉帧或卡死。分代后,高频的新生代回收仅需几毫秒。
- 效率优化:“因材施教”。对短命对象用“空间换时间”(Scavenge 复制快),对长寿对象用“时间换空间”(Mark-Compact 减少浪费)。
C V8 进阶优化技术
为了将 STW 的卡顿感降到最低,现代 V8 引入了多种并行与异步技术:
| 技术名称 | 原理说明 |
|---|---|
| 增量标记 (Incremental Marking) | 将一次完整的标记分解成多个小步,每执行一会儿就让出主线程给 JS 代码,交替进行。 |
| 并发回收 (Concurrent GC) | 在主线程执行 JS 的同时,辅助线程在后台进行标记和清理工作。 |
| 并行回收 (Parallel GC) | 主线程和辅助线程同时执行 GC 工作,利用多核 CPU 加速回收过程。 |
| 延迟清理 (Lazy Sweeping) | 在标记完成后,不立即清理所有垃圾,而是根据内存需求按需清理。 |
4. 常见的内存泄漏场景
虽然有自动回收,但以下不当操作会导致内存无法释放:
| 泄露原因 | 典型代码示例 | 预防措施 |
|---|---|---|
| 意外的全局变量 | function f() { leak = "data"; } | 开启"use strict",始终使用const/let。 |
| 未清理的定时器 | setInterval(() => { ... }, 1000) | 页面卸载前调用clearInterval()。 |
| 闭包引用未释放 | 闭包持有大对象且长期被引用 | 及时将外部持有闭包的变量置为null。 |
| 脱离 DOM 的引用 | let elements = { btn: document.getElementById('id') } | DOM 删除后,同步手动清除 JS 中的引用。 |
总结:垃圾回收是自动的,但理解其“可达性”原则能帮我们写出更高效、更健壮的代码。
三、JavaScript 运行时内存布局解析
在 JavaScript 引擎(如 V8)执行代码时,内存空间主要根据数据的生命周期和大小,划分为栈 (Stack)和堆 (Heap)两大部分。
1. 内存空间划分概览
| 区域 | 存储内容 | 管理方式 | 特点 |
|---|---|---|---|
| 栈内存 (Stack) | 基础类型数据、引用地址、执行上下文 | 系统自动分配与释放(后进先出) | 空间小、访问速度极快 |
| 堆内存 (Heap) | 引用类型对象(对象、数组、函数) | 垃圾回收机制 (GC) 自动管理 | 空间大、存储灵活 |
| 代码区 (Code) | 编译后的机器码/字节码 | 只读 | 存储程序逻辑 |
2. 不同 JS 内容的存放位置
2.1 基础类型变量 (Primitive Types)
- 包含:
Number,String,Boolean,null,undefined,Symbol,BigInt。 - 位置:栈内存。
- 原因:这些数据占用空间固定,大小已知,存储在栈中可以实现快速读取。
2.2 引用类型对象 (Reference Types)
- 包含:
Object,Array,Function。 - 位置:堆内存。
- 注意:虽然对象实体存放在堆中,但在栈内存中会保留一个指向该堆地址的指针(引用地址)。
2.3 函数 (Functions)
- 函数名:存放在栈内存(或闭包相关的空间)中,作为变量指向堆地址。
- 函数体:作为对象存放在堆内存中。
- 函数参数:存放在栈内存中(当前执行上下文的栈帧里),随函数执行完毕而销毁。
2.4 局部变量 (Local Variables)
- 位置:栈内存。
- 生命周期:随函数调用而创建,随函数执行结束而弹出栈并销毁。
2.5 全局变量 (Global Variables)
- 位置:堆内存(老生代)。
- 原因:全局变量生命周期极长(直到页面关闭),因此被存放在老生代区域中,由 GC 维护。
2.6 闭包中的变量 (Variables in Closures)
- 位置:堆内存。
- 原因:由于闭包允许函数访问外部作用域,即使外部函数执行完毕,变量也不能被销毁。为了长久保存,引擎会将这些变量从栈“逃逸”到堆内存中。
3. 内存分配示意
示例代码分析:
leta=100;// a 是基础类型,值 100 存在栈letb={name:"Gemini"};// b 的地址存在栈,对象实体 {name: "..."} 存在堆functionmultiply(x,y){// 函数实体存在堆letresult=x*y;// 参数 x, y 和局部变量 result 存在该函数的栈帧中returnresult;}multiply(a,2);// 调用时创建栈帧,结束时栈帧弹出,result 销毁调用函数时: 引擎会创建一个栈帧(Stack Frame),压入栈顶。
存入参数和局部变量: x, y, c 被填入这个栈帧。
函数返回后: 该栈帧弹出,内部的 x, y, c 瞬间被销毁。而堆内存中的 b 对象则等待垃圾回收器扫描。
4. 为什么要这样划分?
栈:高效。 栈遵循“先进后出”原则,引擎只需要移动栈指针就能快速分配和释放内存,适合频繁创建和销毁的局部变量。
堆:灵活。 引用类型的数据大小是不确定的(比如数组可以动态推入元素),栈无法预留空间,因此放在堆中。通过在栈里存地址,既保持了栈的轻量,又实现了对大数据的访问。
总结:
栈 = 存“快餐”(基础类型、引用地址、执行逻辑)。
堆 = 存“大餐”(复杂的对象、全局数据、闭包数据)。
