前言
垃圾回收是一种自动内存管理机制,用于检测和清除不再使用的对象,以释放内存空间。当一个对象不再被引用时,垃圾回收器会将其标记为垃圾,然后在适当的时候清除这些垃圾对象,并将内存回收给系统以供其他对象使用。
什么是"对象不再被引用"?
当一个对象不再被任何变量或属性引用时,它就成为垃圾。例如,当一个函数执行完毕后,其中创建的局部变量将成为垃圾,因为它们无法再被访问到。
"适当的时候"是指什么时候?
首先 JavaScript 引擎会定期找出垃圾,也会在内存使用量到达某一阈值或内存分配失败时去找垃圾,还有一些其他的机制这里不做拓展。
JavaScript 引擎是如何找到并清理垃圾的?
有两种方式:标记清除法和引用计数法。现在大都用的是标记清除法,因为引用计数法在处理循环引用时存在问题。
对于栈中的垃圾和堆中的垃圾,清理时有什么区别?
当我们在 JavaScript 中讨论垃圾回收机制时,主要关注的是堆内存中的回收机制。栈内存的管理是由编译器和解释器自动进行的,当执行上下文被销毁时,其所占用的栈内存会自动被回收。
栈中的垃圾清理 | 堆中的垃圾清理 | |
---|---|---|
存储内容 | 基本类型数据、函数调用上下文 | 复杂数据结构(对象和数组) |
管理方式 | 自动管理,由编译器和解释器负责 | 依赖垃圾回收机制,自动回收不再被引用的对象 |
垃圾清理方式 | 无需显式清理,执行上下文销毁时自动释放内存 | 通过垃圾回收机制周期性检查并回收不再被引用的对象内存 |
标记清除
它分为两个阶段:标记阶段和清除阶段。
-
标记阶段 : 在标记阶段,垃圾回收器会对内存中的所有对象进行遍历,从根对象 开始(通常是全局对象)递归地遍历对象的引用关系。对于每个被访问到的对象,垃圾回收器会给它打上标记,表示该对象是可达的,即不是垃圾。这个过程确保了所有可达对象都会被标记。
-
清除阶段 : 在清除阶段,垃圾回收器会遍历整个内存,对于没有标记的对象,即被判定为垃圾的对象,会被立即回收,释放内存空间。这样,只有被标记的对象会被保留在内存中,而垃圾对象会被清除。
上图中蓝色的元素代表被访问到的对象,即可达对象 ,灰色代表没有被访问到的对象,即不可达对象
如何理解"递归地遍历对象的引用关系"?
在 JavaScript 中,内存中的对象通过引用关系相互连接,形成了复杂的引用图。
当 JavaScript 代码执行时,会创建各种对象,包括全局对象、函数对象、数组对象、普通对象等等。这些对象在内存中占据一定的空间。
随着程序的执行,对象之间会建立引用关系。这种引用关系通常是通过将一个对象的引用赋值给另一个对象的属性、局部变量或函数的参数来实现的。
例如你在全局作用域中写了这行代码 var obj = {}
,这就代表着你给全局对象设置了一个 obj 属性;再例如一个函数将一个对象作为参数,实际上传递的是对象的引用...这些都会成为引用关系的一部分。通过这些引用关系,JavaScript 引擎会动态地构建一个复杂的引用图。这个引用图以根对象为起点,通过对象之间的引用关系不断扩展和延伸。
标记阶段完成之后,如果其中一个变量不再被引用了,此时该变量还是被标记状态的,那么清除阶段怎么识别它呢?
在本次清除阶段识别不了,但是在下一轮的标记阶段,该变量不会被打上标记,那它就可以被清除啦。
如果一个变量在标记阶段没有被引用,它没有被打上标记,但是在清除阶段之前,它又被引用了,那它会被视作垃圾回收吗?
无需担心,现代垃圾回收器通常会使用各种策略(如写屏障、增量标记、并发标记等)来跟踪引用变化,不会错误地回收仍然被引用的对象。
如何理解根对象?
根对象通常包括以下几类:
-
全局对象 :在浏览器中全局对象是
window
(Node.js 中是global
),它包含了全局作用域中定义的所有属性和方法。全局对象总是可达的,因为它是程序的入口点之一,这也意味着全局对象永远不会被垃圾回收机制视为垃圾对象来回收。 -
活跃的执行上下文(Execution Contexts) :可以理解为当前正在执行或即将执行的代码块所对应的执行上下文。"活跃的执行上下文"可以是全局执行上下文,也可以是某个函数执行上下文,具体取决于当前执行的代码块。全局对象其实就是全局执行上下文中的一个组成部分。
-
内置的持久性引用:有些 JavaScript 引擎可能会维护一些内置的、持久的引用,这些引用指向的对象也会被视为根对象。例如,一些引擎可能会将当前执行的栈帧或特定类型的内部数据结构保留在内存中,以便进行调试或性能分析。
引用计数
它的策略是每个对象维护一个计数器来记录其被引用的次数,当对象被新引用时计数加 1,当引用被移除时计数减 1,当计数减至 0 时对象被自动回收。
两者优缺点比较
标记清除:
-
优点:
-
简单有效
-
可以处理循环引用
-
-
缺点:
-
标记清除算法会暂停程序的执行,也就是说垃圾回收过程中会停顿
-
标记清除算法会在回收过程中产生大量的不连续的、碎片化的内存空间,从而使得内存的利用率降低。
-
引用计数:
-
优点:
-
简单高效
-
实时回收:用计数可以在对象不再被引用时立即回收,不需要等待垃圾收集器的运行。
-
-
缺点:
- 循环引用:当两个或多个对象相互引用时,它们的引用计数都不为零,即它们无法被回收,这会导致内存泄漏
哪些操作会导致内存泄漏?
全局变量导致内存泄漏
如果意外地将变量声明为全局变量(例如,在函数外部或在函数内部但未使用 var
、let
或 const
声明的变量),那么这些变量在程序的整个生命周期内都将保持活动状态,可能导致内存泄漏。
js
function myFunction() {
a = "I am a global variable now!"; // 忘记使用 var, let 或 const
}
myFunction();
// 此时 a 成为了全局变量,即使函数执行完毕,a 也不会被回收
闭包
js
function createLeakyClosure() {
var localVar = "我是局部变量,但由于闭包,我可能导致内存泄漏";
// 返回一个匿名函数,该函数引用了localVar
return function() {
return localVar;
};
}
// 外部引用闭包
var closure = createLeakyClosure();
// 如果 closure 不再需要,但由于它是闭包,它仍然持有对 localVar 的引用,
// 这可能导致 localVar 占用的内存无法被回收,直到 closure 被明确置为 null 或 undefined。
DOM 引用未释放
如果一个 DOM 元素被从 DOM 树中移除,但仍有 JavaScript 变量或对象保持对该 DOM 元素的引用,那么这块 DOM 元素占用的内存(包括其属性、事件监听器等)将不会被垃圾回收机制回收,从而造成了内存泄漏。
例如有一个长时间存活的对象(如全局对象),它有一个属性的值是该 DOM 元素,即使你通过操作 DOM 树删除了该 DOM 元素,该 DOM 元素所占据的内存空间不会被视作垃圾回收,直到这个长时间存活的对象被销毁。
定时器/计时器
没有清除定时器,导致计时器持续运行,占用内存空间,导致内存泄漏。尤其是在 vue 中,最好是在 beforeDestory() 中清除定时器。