深入理解 JavaScript 垃圾回收机制与内存优化
本文深入解析 JavaScript 的自动垃圾回收机制,重点阐述标记清除、引用计数及 V8 引擎的分代回收策略(Scavenge、增量标记)。针对闭包、缓存、DOM 引用等常见内存泄漏场景提供具体排查与优化方案,帮助开发者提升应用性能。
JavaScript 垃圾回收机制概述
前言
在 JavaScript 开发中,memoization(记忆化)技术常被用于性能优化。其核心原理是将函数参数作为键、计算结果作为值,存储在对象中以空间换时间。然而,这种基于普通对象的缓存方式缺乏完善的过期策略,可能引发潜在问题。
若使用闭包缓存对象,该对象占用的内存不会在函数执行结束后立即释放。当调用频率高且参数多样化时,会导致内存持续占用且无法回收。
为此,本文将详细探讨 JavaScript 的垃圾回收机制。
JavaScript 垃圾回收机制的基本原理如下:
识别并释放不再使用的变量所占用的内存。垃圾收集器会按固定时间间隔周期性执行此操作。
如何判断变量是否仍在被使用?
局部变量的生命周期通常限于函数的声明与执行阶段,函数执行完毕后,局部变量便失去存在意义。而全局变量需等待浏览器关闭或进程终止时才会被释放。
但在某些场景下,如闭包通过作用域链访问外部自由变量,会导致自由变量驻留内存,不随函数执行结束而销毁;此外,对象间的相互引用也会使垃圾收集器难以判断变量的可用性。
// 经典闭包
function closure() {
var name = "innerName";
return function() {
console.log(name);
}
}
var inner = closure();
inner(); // innerName;
尽管标识无用变量的策略可能不同,但现代浏览器主要采用两种策略:标记清除和引用计数。
标记-清除与引用计数算法
二、标记-清除(Mark-Sweep)
自 2012 年起,所有现代浏览器均采用标记-清除垃圾回收算法。该算法的具体运作方式如下:
当变量进入执行环境时,将其标记为“进入环境”;当变量离开环境时,标记为“离开环境”。从逻辑上看,进入环境的变量所占内存永远不能被释放,因为执行流进入相应环境时可能会用到它们。
垃圾收集器在运行时会给内存中所有变量添加标记。
随后,它会移除环境中的变量及其引用的变量标记。剩余仍被标记的变量被视为待删除变量,因为环境中的变量已无法访问它们。
最后,垃圾收集器完成内存清除,销毁带标记的值并回收其占用的内存空间。
此外,标记-清除在清除后会导致内存空间不连续,产生内存碎片。若后续需要较大的连续内存空间,可能无法满足需求。标记-整理(Mark-Compact)方法可有效解决此问题。标记阶段相同,但标记结束后,会将存活对象向内存一端移动,最后清理边界内存。
三、引用计数
另一种较少见的垃圾回收策略是引用计数(Reference Counting)。该算法将“对象是否不再需要”简化为“对象是否有其他对象引用”。若无引用指向该对象(零引用),则被垃圾回收机制回收。 引用计数策略跟踪记录每个值的使用次数。当声明变量并将引用类型赋值给它时,该值的引用次数加 1;若变量值变为其他,则引用次数减 1。当引用次数为 0 时,说明无变量在使用,该值无法被访问,其占用空间可被回收。垃圾收集器会在运行时清理引用次数为 0 的值所占内存。 引用计数不再被广泛使用,主要是因为循环引用会导致内存泄漏。
function problem() {
var objA = new Object();
var objB = new Object();
objA.someObject = objB;
objB.anotherObject = objA;
}
objA 和 objB 通过各自属性相互引用,即两个对象的引用次数均为 2。函数执行完毕后,objA 和 objB 仍将继续存在,因为其引用计数永远不会为 0。若该函数被多次执行,将导致大量内存无法释放。
V8 引擎垃圾回收策略
四、NodeJs V8 中的垃圾回收机制
在 Node.js 中,通过 JavaScript 使用内存时,会发现只能使用部分内存(64 位系统下约为 1.4 GB,32 位系统下约为 0.7 GB),这导致 Node.js 无法直接操作大内存对象。
这是因为,以 1.5GB 的垃圾回收堆内存为例,V8 执行一次小型垃圾回收需 50 毫秒以上,执行一次非增量式垃圾回收需 1 秒以上,而垃圾回收过程会导致 JavaScript 线程暂停执行如此长的时间。因此,限制堆内存大小在当时是一个合理的选择。
在此内存限制下,V8 的垃圾回收机制有何特点?
4.1、内存分代算法
V8 的垃圾回收策略主要基于分代式垃圾回收机制。在 V8 中,内存分为新生代和老生代:新生代对象为存活时间较短的对象,老生代对象为存活时间较长或常驻内存的对象。

V8 堆的总大小等于新生代所用内存空间加上老生代的内存空间,且仅在启动时指定,意味着运行时无法自动扩充。若超过极限值,将引起进程错误。
4.2 Scavenge 算法
在分代基础上,新生代对象主要通过 Scavenge 算法进行垃圾回收。在 Scavenge 的具体实现中,主要采用一种复制方式——Cheney 算法。
Cheney 算法将堆内存一分为二:一个处于使用状态的空间叫 From 空间,一个处于闲置状态的空间称为 To 空间。分配对象时,先在 From 空间中进行分配。
当开始进行垃圾回收时,会检查 From 空间中的存活对象,将其复制到 To 空间中,非存活对象占用的空间将被释放。完成复制后,From 空间和 To 空间的角色发生对换。

当一个对象经过多次复制后依然存活,它将被视为生命周期较长的对象,随后被移动到老生代中,采用新的算法进行管理。
还有一种情况:如果复制一个对象到 To 空间时,To 空间占用超过 25%,则该对象会被直接晋升到老生代空间。
4.3 标记-清除和标记-整理算法
对于老生代中的对象,主要采用标记-清除和标记-整理算法。标记-清除与前文提到的标记类似,与 Scavenge 算法相比,标记清除不会将内存空间划分为两半。标记清除在标记阶段会标记存活对象,而在内存回收阶段,它会清除未被标记的对象。 标记整理则是为了解决标记清除后留下的内存碎片问题。
4.4 增量标记(Incremental Marking)算法
前三种算法都需要暂停正在执行的 JavaScript 应用逻辑,待垃圾回收完毕后再恢复。这种行为称为“全停顿”(stop-the-world)。
在 V8 新生代的分代回收中,只收集新生代。由于新生代通常配置较小且存活对象较少,全停顿的影响不大;而老生代则相反。
为了降低全部老生代全堆垃圾回收带来的停顿时间,V8 将标记过程分为一个个子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成。

经过增量标记改进后,垃圾回收的最大停顿时间可减少到原来的 1/6 左右。
内存泄漏场景与优化方案
五、内存泄漏
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
六、内存泄漏的常见场景
6.1 缓存
文章前言部分提到,JavaScript 开发者喜欢用对象的键值对来缓存函数的计算结果。但缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。
6.2 作用域未释放(闭包)
var leakArray = [];
exports.leak = function () {
leakArray.push("leak" + Math.random());
}
以上代码,模块在编译执行后形成的作用域因为模块缓存的原因,不被释放,每次调用 leak 方法,都会导致局部变量 leakArray 不停增加且不被释放。
闭包可以维持函数内部变量驻留内存,使其得不到释放。
6.3 没必要的全局变量
声明过多的全局变量,会导致变量常驻内存,要直到进程结束才能够释放内存。
6.4 无效的 DOM 引用
//dom still exist
function click(){
// 但是 button 变量的引用仍然在内存当中。
const button = document.getElementById('button');
button.click();
}
// 移除 button 元素
function removeBtn(){
document.body.removeChild(document.getElementById('button'));
}
6.5 定时器未清除
// vue 的 mounted 或 react 的 componentDidMount
componentDidMount() {
setInterval(function () {
// ...do something
}, 1000)
}
vue 或 react 的页面生命周期初始化时,定义了定时器,但是在离开页面后,未清除定时器,就会导致内存泄漏。
6.6 事件监听为清空
componentDidMount() {
window.addEventListener("scroll", function () {
// do something...
});
}
同 6.5, 在页面生命周期初始化时,绑定了事件监听器,但在离开页面后,未清除事件监听器,同样也会导致内存泄漏。
七、内存泄漏优化
1.解除引用
确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为 null 来释放其引用——这个做法叫做解除引用(dereferencing)
function createPerson(name){
var localPerson = new Object();
localPerson.name = name;
return localPerson;
}
var globalPerson = createPerson("Nicholas");
// 手动解除 globalPerson 的引用
globalPerson = null;
解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。
2.提供手动清空变量的方法
var leakArray = [];
exports.clear = function () {
leakArray = [];
}
3.在业务不需要用到的内部函数,可以重构在函数外,实现解除闭包 4.避免创建过多生命周期较长的对象,或将对象分解成多个子对象
5.避免过多使用闭包
6.注意清除定时器和事件监听器
7.Nodejs 中使用 stream 或 buffer 来操作大文件,不会受 Nodejs 内存限制
8.使用 redis 等外部工具缓存数据
总结
JavaScript 是一门具有自动垃圾收集的编程语言,在浏览器中主要通过标记清除方法来回收垃圾,NodeJs 中主要通过分代回收、Scavenge、标记清除、增量标记等算法来回收垃圾。在日常开发中,有一些不引人注意的书写方式可能会导致内存泄漏,需要多注意自己的代码规范。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。
crypto-js 处理 Base64 和 Hex 编码转换的正确姿势
2026-11-05 16:39:11
ammo.js 内存管理:避免 Wasm 环境下的内存泄漏技巧
2026-11-05 10:21:09
JS几种补零方法的性能对比与选择建议
2026-11-04 21:17:50
JS数组去重最佳实践:Set、filter与reduce实现方案
2026-11-04 10:57:15
基准元素选择策略对 JavaScript 快速排序性能的影响
2026-11-03 22:37:29
