内存泄露

程序在运行过程中,已经不再需要使用的内存,没有被操作系统或垃圾回收机制正确释放,导致 可用内存持续减少 的现象即 内存泄露

内存泄露的场景

  1. 意外的全局变量
1
2
3
4
5
6
7
8
9
10
function leak() {
// 没有 var/let/const,直接赋值
leakedVar = "这个变量挂在了 window 上";

// 在非严格模式下,函数内部的 this 指向 window
this.anotherLeak = "我也是全局的";
}

leak();
// leakedVar 和 window.anotherLeak 永远无法回收,直到页面刷新

使用 use strict 严格模式,它会禁止自动创建全局变量。

隐式全局变量:在非严格模式下,直接给一个未声明的变量赋值,JS 不会报错,会自动把这个变量挂载到全局对象上,变成一个全局变量。

  1. 被遗忘的定时器、事件监听器
1
2
3
4
5
6
7
8
9
10
const btn = document.querySelector("#btn");
const heavyData = new Array(100000).fill(1);

function onClick() {
console.log(heavyData);
}

btn.addEventListener("click", onClick);
btn.remove();
// DOM元素移除,但监听器还在,heavyData无法回收

需要手动解绑:btn.removeEventListener("click", onClick)

DOM 元素移除,但定时器、事件监听仍持有 对象引用,对象属于可达,GC 不会回收。

  1. 未清理的 DOM 引用
1
2
3
4
let domRef = document.querySelector("#box");

domRef.remove();
// DOM节点已经不在页面,但domRef依旧保存着对DOM节点的引用, DOM对象不会被GC回收

应该置空引用:domRef = null

domRef 变量只是一个指针,真正不能被 GC 回收的是 DOM 节点对象,不是变量 domRef

  1. 闭包使用不当
1
2
3
4
5
6
7
8
9
10
function createClosure() {
const bigObj = new Array(100000).fill("占用大量内存");

return function () {
console.log(bigObj.length);
};
}

// fn 是闭包函数,被全局变量长期持有
const fn = createClosure();

不再需要闭包函数时,及时切断引用:fn = null

闭包中的变量不会被垃圾回收,只要闭包函数还存在,这些变量就会一直占用内存。

垃圾回收机制

C / C++ 这类底层语言需要开发者 手动管理内存,例如 malloc 分配、free 释放,忘记释放就会导致内存泄漏;而 JS / Java / Python 等高级语言都存在 自动垃圾回收机制,引擎会自动识别不再需要的内存并释放,不需要开发者手动操作。

垃圾回收机制主要是针对堆内存设计的
垃圾回收机制的核心价值是解决 动态分配、生命周期不可预测 的内存回收问题,主要集中在堆内存,但也包括被闭包捕获的栈变量;栈内存的生命周期与函数执行上下文完全绑定,每个函数执行时在栈顶分配栈帧,函数执行结束后栈帧直接整体弹出,对应的内存瞬间释放。

自动垃圾回收机制的核心问题是:如何判断一块内存 不再被需要

早期方案:引用计数法

实现原理

给每个内存对象添加一个引用计数器,跟踪该对象被引用的次数:

  1. 每有一个新的变量指向该对象,计数器+1;
  2. 每有一个引用失效,计数器-1;
  3. 当计数器变为 0,直接回收其内存。
1
2
3
4
let objA = { name: "test" }; // 对象{name:'test'}被objA引用,计数=1
let objB = objA; // 又被objB引用,计数=2
objA = null; // 计数=1
objB = null; // 计数=0,对象被回收

关键缺陷

无法处理 循环引用

如果两个对象互相引用,即使没有外部访问,计数器也永远不会归零。

1
2
3
4
5
6
7
8
9
10
function fn() {
const obj1 = {};
const obj2 = {};
obj1.ref = obj2; // obj2计数+1
obj2.ref = obj1; // obj1计数+1
return;
}
fn();
// fn执行完后,obj1和obj2已经离开作用域,外部没有任何引用
// 但两者的计数器都是1,永远不会被回收

现代方案:标记清除法

实现原理

  1. 标记阶段:从所有 GC Root 出发,沿着引用链向下遍历所有能访问到的对象,给这些对象打上存活标记;
  2. 清除阶段:GC 遍历整个堆内存,回收所有没有存活标记的对象,释放内存空间。

标记清除法彻底解决了循环引用的问题,是目前所有现代 JS 引擎的基础 GC 算法。

GC Root 本身都是 程序正在直接使用、绝对不能被回收 的对象,通常包括:

  1. 全局对象:浏览器环境的 window 对象、Node.js 环境的 global 对象。
  2. 调用栈上的引用:如果某个函数正在执行,它的局部变量就一定是存活的,只有函数执行完毕,栈帧弹出后,这些引用才会从 Root 集合中移除。
  3. 闭包中的变量:闭包中的变量本身不是 GC Root,但如果闭包函数本身被某个 GC Root 引用,那么整个闭包环境都会被作为存活对象保留。

关键缺陷

回收后内存会出现大量不连续的 碎片化空闲空间,后续如果要分配大对象,可能找不到足够的连续内存。

现代JS引擎会在标记清除后额外加一步 整理操作:把所有存活的对象统一移动到内存的一端,整理出连续的空闲内存空间,避免碎片化问题。

分代回收机制

分代回收机制 把堆内存分为新生代(存放短生命周期对象)和老生代(存放长生命周期/大对象),针对两个区域的对象特性,使用不同的回收算法,最大化 GC 效率。

为什么需要分代

绝大多数对象(约80% ~ 90%)在创建后很短的时间内就不再被使用了,如果对所有内存“一视同仁”,每次 GC 都把整个堆扫描一遍,效率极低。

分代回收机制中,堆内存主要划分为两个代:

  1. 新生代:存放新创建的对象、临时变量,空间小(通常 1~8MB),GC 频繁,速度极快
  2. 老生代:存放存活时间长的大对象、全局变量、闭包,空间大,GC 不频繁,速度较慢

Scavenge算法

新生代回收使用 Scavenge 算法,通过牺牲一部分内存空间,来换取极快的回收速度。

内存结构

新生代内存区域被平分成两个等大的半区:

  1. From 空间:新创建的对象 都会被分配到From空间(大对象会直接分配到老生代内存区域);
  2. To 空间:新生代GC会把存活的对象 复制到这片区域

回收步骤

  1. 标记存活对象:从所有根节点出发,标记From空间中的 所有可达对象(只碰存活对象)。
  2. 复制存活对象:将存活对象从 From空间 复制到 To空间,并 按内存地址紧密排列(消除内存碎片化)。
  3. 清空整个 From 空间:From 空间中没有被标记的都是垃圾对象,直接 整体清空
  4. 交换 From 和 To 的角色,等待下一次 GC。

对象提升
一个对象在新生代不会永远待下去,满足任意一个条件都会触发对象提升:

  1. 每经历一次 Scavenge 回收,对象的年龄+1。当 年龄超过阈值,就会提升到老生代。
  2. 如果在复制过程中,To 空间 已使用超过 25%,剩余的对象直接晋升到老生代,防止 To 空间溢出。

25% 是经验值
如果 To空间 占用太高,交换为 From空间 后,很快就会被新对象占满,触发下一次 GC,降低回收效率。

算法优势

  1. 速度极快:只需要处理少量存活对象(不接触任何垃圾对象),复制成本极低;
  2. 无内存碎片:复制后存活对象在 To 空间连续排列,完全避免碎片化问题(不需要处理内存碎片化问题)。

Scavenge算法不会产生碎片化内存,但标记清除算法需要单独处理。

算法缺陷

  1. 内存利用率只有50%:一半空间处于空闲状态,不适合存储大对象
  2. 不适合存活对象多的场景:如果存活对象占比很高,复制开销会急剧上升。

标记清除+整理

老生代回收使用 标记清除+整理 算法,在碎片化达到阈值时,执行一次标记整理:

  1. 和标记清除的标记阶段完全一致,标记所有存活对象。
  2. 把所有存活对象 向内存的一端移动,移动完成后,所有存活对象连续排列在内存的前半部分,后半部分是完全连续的大块空闲内存。
  3. 清空存活对象边界以外的所有内存,得到整块的连续空闲空间。

GC执行时 JS主线程会暂停,页面渲染、用户交互都会卡住,但优化策略可以把停顿时间降低到用户无感知的程度(通常<10ms)。

两种算法的区别

Scavenge的核心是 找存活对象,剩下的整体丢弃,垃圾越多效率越高;标记清除的核心是 找垃圾,然后遍历整个内存逐个释放,垃圾越多开销越大。

正因为Scavenge算法把存活对象都复制到了新区域,因此它可以避免一次完整的内存遍历,同时完全避免产生碎片化内存;但它需要两倍的内存区域,不适合大对象GC。

GC 触发时机

垃圾回收由JS引擎根据内存使用情况自动触发,主要发生在 堆内存即将耗尽或内存分配达到某个阈值 时。

  1. 新生代 GC:当你在代码中不断 new 对象、创建数组、执行函数时,From 空间会逐渐被填满。当分配内存时发现 From 空间可用内存不足,就会立即触发一次新生代 GC(频繁且快速)。
  2. 老生代 GC:当老生代的内存占用达到JS引擎设定的某个阈值时,会触发一次垃圾回收来清理无用的对象,防止进程崩溃(缓慢且沉重)。
  3. 空闲 GC:事件循环空闲时,会发起一次空闲 GC,避免业务高峰期卡顿。

  1. 事件循环
  2. 垃圾回收
  3. 异步编程
  4. 遍历
  5. 闭包