垃圾回收
内存泄露
程序在运行过程中,已经不再需要使用的内存,没有被操作系统或垃圾回收机制正确释放,导致 可用内存持续减少 的现象即 内存泄露。
内存泄露的场景
- 意外的全局变量
1 | function leak() { |
使用 use strict 严格模式,它会禁止自动创建全局变量。
隐式全局变量:在非严格模式下,直接给一个未声明的变量赋值,JS 不会报错,会自动把这个变量挂载到全局对象上,变成一个全局变量。
- 被遗忘的定时器、事件监听器
1 | const btn = document.querySelector("#btn"); |
需要手动解绑:btn.removeEventListener("click", onClick)。
DOM 元素移除,但定时器、事件监听仍持有 对象引用,对象属于可达,GC 不会回收。
- 未清理的 DOM 引用
1 | let domRef = document.querySelector("#box"); |
应该置空引用:domRef = null。
domRef 变量只是一个指针,真正不能被 GC 回收的是 DOM 节点对象,不是变量 domRef。
- 闭包使用不当
1 | function createClosure() { |
不再需要闭包函数时,及时切断引用:fn = null。
闭包中的变量不会被垃圾回收,只要闭包函数还存在,这些变量就会一直占用内存。
垃圾回收机制
C / C++ 这类底层语言需要开发者 手动管理内存,例如 malloc 分配、free 释放,忘记释放就会导致内存泄漏;而 JS / Java / Python 等高级语言都存在 自动垃圾回收机制,引擎会自动识别不再需要的内存并释放,不需要开发者手动操作。
自动垃圾回收机制的核心问题是:如何判断一块内存 不再被需要 ?
早期方案:引用计数法
实现原理
给每个内存对象添加一个引用计数器,跟踪该对象被引用的次数:
- 每有一个新的变量指向该对象,计数器+1;
- 每有一个引用失效,计数器-1;
- 当计数器变为 0,直接回收其内存。
1 | let objA = { name: "test" }; // 对象{name:'test'}被objA引用,计数=1 |
关键缺陷
无法处理 循环引用。
如果两个对象互相引用,即使没有外部访问,计数器也永远不会归零。
1 | function fn() { |
现代方案:标记清除法
实现原理
- 标记阶段:从所有
GC Root出发,沿着引用链向下遍历所有能访问到的对象,给这些对象打上存活标记; - 清除阶段:GC 遍历整个堆内存,回收所有没有存活标记的对象,释放内存空间。
标记清除法彻底解决了循环引用的问题,是目前所有现代 JS 引擎的基础 GC 算法。
关键缺陷
回收后内存会出现大量不连续的 碎片化空闲空间,后续如果要分配大对象,可能找不到足够的连续内存。
现代JS引擎会在标记清除后额外加一步 整理操作:把所有存活的对象统一移动到内存的一端,整理出连续的空闲内存空间,避免碎片化问题。
分代回收机制
分代回收机制 把堆内存分为新生代(存放短生命周期对象)和老生代(存放长生命周期/大对象),针对两个区域的对象特性,使用不同的回收算法,最大化 GC 效率。为什么需要分代
绝大多数对象(约80% ~ 90%)在创建后很短的时间内就不再被使用了,如果对所有内存“一视同仁”,每次 GC 都把整个堆扫描一遍,效率极低。
分代回收机制中,堆内存主要划分为两个代:
- 新生代:存放新创建的对象、临时变量,空间小(通常 1~8MB),GC 频繁,速度极快。
- 老生代:存放存活时间长的大对象、全局变量、闭包,空间大,GC 不频繁,速度较慢。
Scavenge算法
新生代回收使用 Scavenge 算法,通过牺牲一部分内存空间,来换取极快的回收速度。
内存结构
新生代内存区域被平分成两个等大的半区:
- From 空间:新创建的对象 都会被分配到From空间(大对象会直接分配到老生代内存区域);
- To 空间:新生代GC会把存活的对象 复制到这片区域。
回收步骤
- 标记存活对象:从所有根节点出发,标记From空间中的 所有可达对象(只碰存活对象)。
- 复制存活对象:将存活对象从 From空间 复制到 To空间,并 按内存地址紧密排列(消除内存碎片化)。
- 清空整个 From 空间:From 空间中没有被标记的都是垃圾对象,直接 整体清空。
- 交换 From 和 To 的角色,等待下一次 GC。
对象提升
一个对象在新生代不会永远待下去,满足任意一个条件都会触发对象提升:
- 每经历一次 Scavenge 回收,对象的年龄+1。当 年龄超过阈值,就会提升到老生代。
- 如果在复制过程中,To 空间 已使用超过 25%,剩余的对象直接晋升到老生代,防止 To 空间溢出。
25% 是经验值
如果 To空间 占用太高,交换为 From空间 后,很快就会被新对象占满,触发下一次 GC,降低回收效率。
算法优势
- 速度极快:只需要处理少量存活对象(不接触任何垃圾对象),复制成本极低;
- 无内存碎片:复制后存活对象在 To 空间连续排列,完全避免碎片化问题(不需要处理内存碎片化问题)。
Scavenge算法不会产生碎片化内存,但标记清除算法需要单独处理。
算法缺陷
- 内存利用率只有50%:一半空间处于空闲状态,不适合存储大对象;
- 不适合存活对象多的场景:如果存活对象占比很高,复制开销会急剧上升。
标记清除+整理
老生代回收使用 标记清除+整理 算法,在碎片化达到阈值时,执行一次标记整理:
- 和标记清除的标记阶段完全一致,标记所有存活对象。
- 把所有存活对象 向内存的一端移动,移动完成后,所有存活对象连续排列在内存的前半部分,后半部分是完全连续的大块空闲内存。
- 清空存活对象边界以外的所有内存,得到整块的连续空闲空间。
GC执行时 JS主线程会暂停,页面渲染、用户交互都会卡住,但优化策略可以把停顿时间降低到用户无感知的程度(通常<10ms)。
两种算法的区别
Scavenge的核心是 找存活对象,剩下的整体丢弃,垃圾越多效率越高;标记清除的核心是 找垃圾,然后遍历整个内存逐个释放,垃圾越多开销越大。
正因为Scavenge算法把存活对象都复制到了新区域,因此它可以避免一次完整的内存遍历,同时完全避免产生碎片化内存;但它需要两倍的内存区域,不适合大对象GC。
GC 触发时机
垃圾回收由JS引擎根据内存使用情况自动触发,主要发生在 堆内存即将耗尽或内存分配达到某个阈值 时。
- 新生代 GC:当你在代码中不断 new 对象、创建数组、执行函数时,From 空间会逐渐被填满。当分配内存时发现 From 空间可用内存不足,就会立即触发一次新生代 GC(频繁且快速)。
- 老生代 GC:当老生代的内存占用达到JS引擎设定的某个阈值时,会触发一次垃圾回收来清理无用的对象,防止进程崩溃(缓慢且沉重)。
- 空闲 GC:事件循环空闲时,会发起一次空闲 GC,避免业务高峰期卡顿。