前言
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()
和free()
用于分配内存和释放内存。
而对于JavaScript 来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。
因为自动垃圾回收机制的存在,让大多 Javascript 开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。
JS 内存的生命周期
内存声明周期,即内存管理
JS 环境中分配的内存有如下生命周期:
- 内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
- 内存使用:即读写内存,也就是使用变量、函数等
- 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存
内存分配
JS 内存空间分为栈(stack)、堆(heap)、池(一般也会归类为栈中)。
其中栈存放变量,堆存放复杂对象,池存放常量。基本数据类型与栈内存
基础数据类型:number
、string
、boolean
、undefined
、null
、Symbol
、bigInt
。// 如下一段代码:
var a = 0;
var b = '123';
var c = true;
引用数据类型与堆内存
JS 的引用数据类型object
,细分后有Object
、Array
、Function
、Date
、RegExp
等。它们值的大小是不固定的。
JS 的引用数据类型的值是保存在堆内存中的对象。// 比如下面代码
var a = 0; // 存在栈内存
var obj = { name: 'konsoue'}; // 声明的 obj 变量是一个指针,存在栈内存中,而 obj 指针指向的值存在堆内存中
var arr = [1, 2, 3]; // Array 引用类型与上述 Object 引用类型同理
function foo() {
console.log('foo');
}
内存使用
使用值的过程实际上是对分配内存进行读取与写入的操作。
读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。内存释放(垃圾回收)
不同的编程语言有不同的垃圾回收机制。不同机制会采用不同的垃圾回收算法。
由于 JS 是运行在 V8 引擎中的,V8 引擎的垃圾回收策略是:分代回收【新生代和老生代】。
新生代采用的算法是:Scavenge 算法,老生代采用的算法是标记清除算法。这个在后面会详细讲解。
由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
基本概念:
- GC(Garbage Collection):垃圾回收
- GC Roots:垃圾回收时,引用的起始点。
- 活动对象:从 GC Roots 开始遍历,能引用到的变量
- 非活动对象:从 GC Roots 开始遍历,不能引用到的变量
垃圾回收算法
垃圾自动回收常见的有许多算法,我们下面介绍与 JS 相关的两种
- 引用计数算法
- 初级的算法,目前浏览器基本都不用它了。
- 存在的问题:如果两个对象出现循环引用,那么内存将无法自动释放
- 标记清除算法
- 目前,IE、Firefox、Opera、Chrome 和 Safari 的 JS 实现使用的都是标记清除式的垃圾回收策略(或类似的策略),只不过垃圾收集的时间间隔互有不同
引用计数算法
引用计数算法思想:
为对象设置引用计数器,判断当前引用数是否为 0,来决定是否垃圾对象。
如果对象的引用数为 0 时,启动 GC 对该对象进行回收。
示例一:普通引用
// 举个例子
var obj = {
name: 'konsoue',
p: { foo: 123 }
};
var tmp = obj;
obj = null;
tmp = null;
示例二:循环引用
// 循环引用,导致的无法回收
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o 这里
return "azerty";
}
f();
上面我们申明了一个函数 f ,其中包含两个相互引用的对象。
在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。
但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。
再来看一个实际的例子:
var div = document.createElement("div");
div.onclick = function() {
console.log("click");
};
变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。
一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。
为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。
标记清除算法
标记-清除算法由标记阶段
和清除阶段
构成,两个阶段反复执行。
在标记阶段
会从GC roots
开始遍历,把所有的活动对象
都做上标记,然后在清除阶段
会把没有标记的对象,也就是非活动对象
回收。
工作流程:
- 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
- 从根部(
GC roots
)出发将能触及到的对象的标记清除。- 那些还存在标记的变量被视为准备删除的变量。
- 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。
标记阶段
说到GC roots
(GC 根),在浏览器中,可以当做GC roots
的对象有以下几种:
- 全局的 window 对象(位于每个 iframe 中)
- 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成。
- 存放内存栈上的变量。
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。 当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
清除阶段
一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片。
V8 引擎垃圾回收
代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:
- 第一个是说大部分对象在内存中存活的时间很短
- 比如函数内部声明的变量、块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。
- 因此这一类对象一经分配内存,很快就变得不可访问。
- 第二个是不死的对象,会活得更久
- 比如全局的 window、DOM、Web API 等对象
由于受到代际假说影响,目前 V8 采用了两个垃圾回收器,
- 主垃圾回收器
Major GC
- 负责老生代的垃圾回收
- 副垃圾回收器
Minor GC (Scavenger)
- 负责新生代的垃圾回收
副垃圾回收器(新生代)
负责新生区的垃圾回收,新生区区域不大(为了执行效率),回收频繁
副垃圾回收器采用 Scavenge 垃圾回收算法,在算法实现时主要采用 Cheney 算法,工作流程如下
Cheney 算法将内存一分为二,叫做
semispace
,一块处于使用状态【对象区】,一块处于闲置状态【空闲区】
- 加入对象阶段:新加入的对象都会存放到
对象区
,当对象区
快被写满时,就需要执行 标记 - 清除(Mark-Sweep)算法 - 复制对象阶段:清理完成后,将存活的对象复制
BFS算法
到空闲区
,同时把这些对象有序的排列起来,相当于做了内存整理 - 交换空间阶段:复制过程中,
对象区
也清空了,现在把空的对象区
和存着对象的空闲区
进行交换。
主垃圾回收器(老生代)
负责老生区中的垃圾回收,老生区中对象占用空间大,对象存活时间长
主垃圾回收器是使用了 标记 - 清除(Mark-Sweep)的算法,工作流程如下
- 标记阶段:从
GC Roots
开始递归遍历,能到达的元素就是活动对象,否则就是垃圾。 - 清除阶段:清除算法进行垃圾回收,不过回收后会产生大量不连续的内存碎片。
- 整理阶段:整理时可以让存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
参考资料
《「前端进阶」JS中的内存管理》
《Java 虚拟机垃圾回收机制》
《从零开始,手写GC算法 | 标记-清除【附完整可运行源码】》
《浅谈V8引擎中的垃圾回收机制》