对于JavaScript 的垃圾回收机制,你是否还停留所分配的内存不再需要的阶段呢?那么问题来了,浏览器是怎么确定所分配的内存不再需要了呢?下面我将从内存触发,深入理解JS的垃圾回收机制,并介绍Chrome浏览器中V8引擎的垃圾回收机制。
内存管理
内存
在硬件层面,计算机内存是由大量的触发器组成的。每一个触发器都包含有一些晶体管,能够存储1比特。单个触发器可通过一个唯一标识符来寻址,这样我们就可以读和写了。因此从概念上讲,我们可以把计算机内存看作是一个巨大的比特数组,我们可以对它进行读和写。
但是作为人类,我们并不善于用比特来思考和运算,因此我们将其组成更大些的分组,这样我们就可以用来表示数字。8个比特就是一个字节。比字节大的有字(16比特或32比特)。
有很多东西都存储在内存中,比如所有被程序使用的变量和其他数据,或者程序的代码,包括操作系统自身的代码。内存管理指的是软件运行时对计算机内存资源的分配和使用的技术。
当你编译你的代码时,编译器可以检查原始的数据类型并且提前计算出将会需要多少内存。然后把所需的(内存)容量分配给调用栈空间中的程序。这些变量因为函数被调用而分配到的空间被称为堆栈空间,它们的内存增加在现存的内存上面(累加)。如它们不再被需要就会按照 LIFO(后进,先出)的顺序被移除。
生命周期
无论是是使用什么编程语言,内存生命周期几乎都是一样的:
- 内存分配(Allocate memory ):当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
- 内存使用(Use memory ):即读写内存,也就是使用变量、函数等
- 内存释放(Release memory ):使用完毕,由垃圾回收机制自动回收不再使用的内存
内存分配
根据 值传递和引用传递 一文我们知道,JS将内存空间分配为堆 (Heap) 和栈 (Stack) 两个区域,代码运行时,解析器会先判断变量类型,根据变量类型,将变量放到不同的内存空间中(堆和栈)。基本类型的数据存储在栈空间中,引用类型的数据存储在堆空间中。
内存使用
使用值的过程实际上是对分配内存进行读取与写入的操作。 读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
内存释放
**MDN:**大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“所分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
高级语言嵌入了一个叫垃圾收集器的程序,它可以跟踪内存分配和使用情况,以找出在哪种情况下某一块已分配的内存不再被需要,并自动的释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)
以JS为例:
// 分配内存
let obj = {}
// 使用内存
obj.name = 'sunshine'
// 释放内存
obj = null
到这里,我们应该知道的是:
- 内存是一个巨大的数字字节组成的数组,我们可以对它进行读和写,
- 内存空间分为堆内存和栈内存,堆内存存储引用类型数据,栈内存存储基本类型数据
- 释放内存的时候会使用到垃圾收集器
垃圾回收
闭包的原理 一文有介绍到,JS的内存管理是自动执行的,创建、回收对象这些工作都是不用我们去操作的,垃圾回收的标准就是这对象是否具有可达性,或者说是否是一个可达对象。
可达性
JavaScript 中内存管理的主要概念是可达性,“可达性” 的值就是那些以某种方式可访问或可用的值,它们被保存在内存中。可达对象一般分为两种,一种是从根上访问到的对象,另一种是被引用的对象。
从根上访问到的对象
有一组基本的固有可达值,由于显而易见的原因无法删除,这些值称为根,常见的根有以下几种:
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
- 全局变量
被引用的对象
在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
如果局部变量中有对象,并且该对象具有引用另一个对象的属性,则该对象被视为可达性,它引用的那些也是可以访问的
垃圾(可达对象)识别之后,js引擎将垃圾占据的空间进行回收,即垃圾回收
举个栗子:
function objGroup(obj1, obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
o1: obj1,
o2: obj2
}
}
let obj = objGroup({ name: 'obj1' }, { name: 'obj2' })
console.log(obj)
上面的代码中,obj.o1,obj.o1.next,obj.o2.prev都可以在全局作用域中被访问,所以都是可达对象,而如果删除了obj.o1和obj.o2.prev,那么obj1的对象空间就找不到了,就会变成垃圾,js引擎就会进行垃圾回收。
GC算法
浏览器的 Javascript 具有自动垃圾回收机制 (简称GC:Garbage Collecation),也就是说,执行环境会负责管理代码执行过程中使用的内存。其原理是:垃圾收集器会定期(周期性)找出那些不在继续使用的变量,然后释放其内存。但是这个过程不是实时的,因为其开销比较大并且 GC 时停止响应其他操作,所以垃圾回收器会按照固定的时间间隔周期性的执行。
垃圾回收与GC
JavaScript 使用垃圾回收机制来自动管理内存,代码最终由 JS 引擎来完成执行,所以回收也由不同的平台决定。
GC 回收内存, 这句话中的内存一般指的是存放在堆栈空间的内存(引用类型存放位置)。代码执行时会不断的向执行栈当中加入不同的执行上下文(函数调用),某个执行上下文中的代码运行结束之后,原则上就会回收该上下文中所引用的堆内存空间(闭包例外)
当下执行 JS 的引擎不同,依据不同的算法又存在不同的回收时机。总体说,回收空间时就是 GC 工作时
引用计数
引用计数是程序执行过程中完成GC这件事情的一种统计方法,用于统计哪些内容属于 “垃圾”,引用计数发现某个变量引用数为0(零引用)之后认定它是 “垃圾”,GC开始工作,回收它占用的空间。
实现原理
引用计数的核心思想是设置引用数,判断当前引用数是否为0 ,如果有一个对象空间引用这个对象 +1, 如果删除了空间 -1 。引用关系改变时,修改当前对象引用对应的数字,引用数字为0时立即回收。
var obj1 = {
a: {
b:2
}
}
var obj2 = obj1
obj1 = 1
var oa = obj2.a
obj2 = 2
oa = null
以上代码可以分解为如下步骤:
- 执行
var obj1 = { a: { b:2 }}
:创建obj1和它的引用对象,一个作为另一个的属性被引用,另一个被分配给变量o,此时两个对象的引用数+1,即都不是零引用,所以不会被回收
- 执行
var obj2 = obj1
:创建obj2,此时{a: 引用地址}
的引用数 +1 = 2
- 执行
obj1 = 1; var oa = 2
:删除了obj1对{a: 引用地址}
的引用,{a: 引用地址}
的引用数 -1 = 1,创建变量oa,将obj2的给了它,此时{b:2}
这个对象的引用数 +1 = 2
- 执行
obj2 = 2
:删除了obj2对{a: 引用地址}
的引用,{a: 引用地址}
的引用数 -1 = 0,内存释放=>垃圾回收,同时没有了{a: 引用地址}
的引用,{ b: 1 }
的引用数-1 = 1
-
执行
oa = null
:删除了oa 对{b: 1 }
的引用,{b: 1 }
的引用数 -1 = 0,内存释放=>垃圾回收
优缺点
引用计数的优点是:发现垃圾时立即回收,最大限度减少程序的暂停:执行平台的内存爆满时,引用计数会立即找到引用数量为0的内存空间,删除对象的引用空间
引用计数缺点是:无法回收循环引用的对象,时间开销大,资源消耗大:因为实时监听修改,所以消耗时间长
来看一个循环引用的例子:
function fn () {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return 'sunshine is a coder'
}
fn()
上面我们申明了一个函数 fn ,其中包含两个相互引用的对象。 在调用函数结束后,对象 obj1 和 obj2 实际上已离开函数范围,因此不再需要了。 但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。
再来看一个实际的例子:
var element = document.getElementById("some_element")
var myObject = new Object()
myObject.e = element
element.o = myObject
这个例子在一个 DOM 元素 element
与一个原生js对象 myObject
之间创建了循环引用。其中,变量 myObject
有一个属性 e
指向 element
对象;而变量 element
也有一个属性 o
回指 myObject
。由于存在这个循环引用,即使例子中的 DOM 从页面中移除,它也永远不会被回收。
- 黄色是指直接被 js变量所引用,在内存里
- 红色是指间接被 js变量所引用,如上图,refB 被 refA 间接引用,导致即使 refB 变量被清空,也是不会被回收的
- 子元素 refB 由于
parentNode
的间接引用,只要它不被删除,它所有的父元素(图中红色部分)都不会被删除
为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。
标记清除
实现原理
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”,即上面说的可达对象。
此算法可以分为两个阶段,一个是标记阶段(mark),一个是清除阶段(sweep)。
- 标记阶段:垃圾回收器会从根对象开始遍历。每一个可以从根对象访问到的对象都会被添加一个标识,于是这个对象就被标识为可到达对象。
- 清除阶段:垃圾回收器会对堆内存从头到尾进行线性遍历,如果发现有对象没有被标识为可到达对象,那么就将此对象占用的内存回收,并且将原来标记为可到达对象的标识清除,以便进行下一次垃圾回收操作。
简单看看下面两张图片
在标记阶段,从根对象1可以访问到B,从B又可以访问到E,那么B和E都是可到达对象,同样的道理,F、G、J和K都是可到达对象。在回收阶段,所有未标记为可到达的对象都会被垃圾回收器回收。
再看之前循环引用的例子:
function fn () {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return 'sunshine is a coder'
}
fn()
函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。
从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。
优缺点
- 优点:解决对象循环使引用不能回收的问题
- 缺点:由于回收的垃圾对象在地址上不连续,导致的空间碎片化,并且不会立即回收垃圾对象
标记整理
标记清除的增强算法,标记阶段的操作和标记清除一致,区别是标记整理会在清除阶段先执行整理,移动对象位置,但是不会立即回收垃圾对象
V8引擎的GC算法
V8是一款主流的js执行引擎,采用即时编译。V8引擎回收策略是,采用分代回收(Generation GC)思想,内存分为新生代和老生代,空间和针对不同对象存储的数据类型略有不同。V8中常用的GC算法:分代回收、空间复制、标记清除、标记整理、标记增量。
内存限制
V8引擎内存设置了上限,V8内存空间一分为二,小空间用于存储新生代对象,大空间用于存储老生代对象:
- 64位系统下约为1.4GB,新生代内存大小为16MB,老生代内存大小为700MB
- 32位系统下约为0.7GB,新生代内存大小为32MB,老生代内存大小为1.4GB
新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。
这个限制在node启动的时候可以通过传递--max-old-space-size 和 --max-new-space-size来调整,如:
node --max-old-space-size=1700 app.js //单位为MB
node --max-new-space-size=1024 app.js //单位为MB
上述参数在V8初始化时生效,一旦生效就不能再动态改变。
内存限制的原因
表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景。而深层次的原因则是由于V8的垃圾回收机制的限制。
由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。
若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。
新生代回收
新生代中的对象主要是指存活时间较短的对象,例如局部变量。新生代中对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用Cheney算法和标记整理。
Cheney算法
Cheney算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,然后把 A 空间内容复制到 B 空间,然后将 A 中的内容全部当做垃圾回收。这两个空间中只有一个处于使用中,一个处于闲置状态。处于使用状态的空间称为From空间,处于闲置的空间称为To空间。
回收过程
分配对象时,先是在From空间中进行分配,当开始垃圾回收时,会检查From空间中的存活对象,并将这些存活对象标记整理后将活动对象复制到To空间中,而非存活对象占用的空间被释放。完成复制后,From空间和To空间角色互换。
简而言之,垃圾回收过程中,就是通过将存活对象在两个空间中进行复制。
Scavenge算法的缺点是只能使用堆内存中的一半,但由于它只复制存活的对象,对于生命周期短的场景存活对象只占少部分,所以在时间效率上有着优异的表现。
新生代晋升
以上所说的是在纯Scavenge算法中,但是在分代式垃圾回收的前提下,From空间中存活的对象在复制到To空间之前需要进行检查,在一定条件下,需要将存活周期较长的对象移动到老生代中,这个过程称为对象晋升。
有几类对象会移动至老生代中,分别是:
- 在老生代中使用的一些对象
- 在一轮GC之后还存活的对象
- To空间使用率超过内存的25%后
老生代回收
老生代中的对象指的是:存活时间较长的对象,例如全局对象,闭包中的对象。由于存活对象占比较大,再采用Scavenge方式会有两个问题:一个是存活对象就较多,复制存活对象的效率将会降低;另一个依然是浪费一半空间的问题。为此,V8在老生代中主要采用标记清除(Mark-Sweep)、标记整理(Mark-Compact)、增量标记算法几种方式相结合进行垃圾回收。
回收过程
Scavenge只复制活着的对象,而标记清除只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。但是这个算法有个比较大的问题是,内存碎片太多。如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。所以在此基础上提出标记整理算法。
老生代回收的过程如下:
- 在标记阶段:遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。
- 标记整理阶段:将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。
- 增量标记:将一整段的垃圾回收操作拆分成多个步骤,组合完成回收。
首先使用标记清除算法完成垃圾空间的回收,相对空间碎片问题,速度提升比较明显。标记整理针对的是新生代晋升老生代时,老生代内存空间不足的对象。最后采用增量标记算法进行效率优化。
增量标记算法
- 将一整段的垃圾回收操作拆分成多个步骤,组合完成回收,这样可以实现垃圾回收和程序执行交替完成,可以让时间消耗更合理。
- 增量标记可以理解为增量标记就是基于标记整理和标记清除算法的整合,增量标记可以看做是 V8 引擎本身最终采用的一种优化后的 GC 算法,所以认为最终使用的都是增量标记
- 增量标记工作过程中需要用到的就是标记清除,增量标记主要是对于时间调度的制定
总结
关于几种算法的理解,整理如下:
- 引用计数通过设置引用数,判断当前对象是否为零引用的方式进行垃圾回收
- 标记整理的过程:标记可达对象=>遍历删除可达对象=>清除标记
- 标记清除弥补了引用计数算法的缺陷,循环引用问题
- 标记整理和标记清除区别是在清除阶段先执行整理,后移动对象位置
- Cheney算法是一种采用复制的方式实现的垃圾回收算法
- 增量标记算法就是基于标记整理和标记清除算法的整合
参考文章
最后
文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。
想阅读更多优质文章、可我的微信公众号【阳姐讲前端】,每天推送高质量文章,我们一起交流成长。