内存里的栈和堆
探讨垃圾回收机制之前,先看一段JS的代码,以让我们了解内存空间:
function foo () {
var a = ‘hello world’;
var b = a;
var c = {name: ‘hello world’};
var d = c ;
}
foo();
上述代码定义了一个函数,调用了它。这时内存到底是如何运作? JS的内存分为三个区域:
代码空间存放执行的代码,栈空间则用来存放执行上下文,下图表示当上述代码到了 “var b = a”时,栈空间是怎样存放上述代码的变量。
JS引擎把函数压入栈里,函数开始执行前,先把里面的变量初始化,先进后出的方式把变量压入栈里,然后代码执行,对相应变量赋值,结果就如上图般。执行到 “var c = {name: ‘hello world’};”时,JS引擎判断它是一个引用类型,则会在堆空间存放对象,然后把对象的内存地址值赋给c。上述如下图般:
如果熟悉C语言,明白所谓的引用和指针的原理是类似的。
** 简单总结下,原始类型的数据值都是直接保存在栈中,引用类型的值存放在堆中,然后用栈里的变量指向它。**
为什么我们一定要堆和栈两个存储空间呢?所有数据直接存放在栈中不就可以了吗? 不妨想象一下如果所有数据都放在同一空间里,会发生什么事。如果都放在同一空间,由于对象的大小是不确定的,会使得变量的调用非常复杂,这使得代码运行效率降低。所以JS的栈空间都是存放上下文的状态,对象这种复杂的数据则放在堆里。 函数的最后一行 “var d = c ;”,d所赋的值是什么? 其实就是c的地址值,这就是为什么Js (或Java)里引用类型的变量把自己的值复制给另一个变量,另一个变量调用对象所作的改变,会影响到同一对象,因为所复制的是地址值 (如果懂C语言,这一点应该非常了解)。
常用的垃圾回收算法
因为堆要存放对象,所以堆是很大的,这意味着分配和回收堆里的数据要占用一定时间,而回收则是指垃圾回收,就是把不用的对象给回收。 不用通常意味着没有一个变量去引用它,JS引擎或虚拟机会把它回收。问题是,怎样知道对象是不用的?最简单的方法是建立一个计数器,记录每个对象有多少次引用,如果引用次数为0,则把它回收。这叫做** 引用计数法 **。这个方法的好处是简单,但要维护一个计数器,这需要时间和空间,而且遇到下面情况,则无法回收:
let obj1 = {
obj: obj2,
}
let obj2 = {
obj: obj1,
}
上述的循环引用,使得引用次数不为0,使对象无法被回收,造得内存泄漏。内存泄漏指的是由于疏忽或错误造成程序未能释放已经不再使用的内存。 ** 标记清除法 针对上述情况进行改善。简单来说,该算法从代码运行一开始跟进引用情况,如果在全局执行上下文有引用就标记,没有引用就不标记,最后把不标记的删除。它可以解决上述循环引用问题,但造成内存碎片化。由于对象所占的内存空间是固定,对某些对象进行垃圾回收,造成内存空间不连续,即部分空间有对象,有些没有,这就是内存碎片化。内存碎片化的后果是由于空间不连续,会使一些较大的对象没有位置可以放,造成内存空间浪费。 而 标记压缩清除法 **则解决上述问题。与标记清除法类似,只是在标记和清除之间多了压缩这一步,清除前,把标记的对象统一放在一个空间里,确定该空间的位置,然后把没用的垃圾给清除。缺点则是由于多了一步,使得算法的时间效率降低。
V8的垃圾回收
栈空间
由于内存空间分为栈空间和堆空间,所以我们要分别探讨栈和堆的垃圾回收。 栈的垃圾回收比较简单。简单来说,就是把不再执行的上下文环境销毁就好了。具体看一段代码:
function foo () {
var a = 1;
function bar () {
var b= 2;
}
bar()
}
foo()
当代码执行到foo()的调用,到了 “var a = 1;”时,代码如下图所示:
到了 调用bar, 执行到“var b= 2;”时,栈空间如下图:
每当一个上下文调用一个函数时,就把调用函数压入栈顶。与此同时,还有一个记录当前执行上下文状态的指针。在上图中,指针指向bar的上下文。如果它执行完,指针指向foo,bar的上下文则被销毁。
堆空间
栈的情况比较简单,堆的情况则需要一系列算法来处理。
V8引擎的堆空间分配建立在一个假说之上: 代际假说 (The Generational Hypothesis)。 该假说主要有两点:
- 多数对象存活时间较短
- 活得足够长,通常活得更长
基于这个假说,V8把堆空间分为新世代 (new generation) 与老世代 (old generation)。新世代存放生存时间较短的对象,而老世代存放较长的对象。新世代通常只支持 1 – 8M容量,而老世代则大多了。不同空间用不同垃圾回收算法来处理。 不论是新还是老,垃圾回收的执行流程是相同的 : 标记,回收和整理。即把有用的对象标记,回收没用的,最后把余下的对象整理,防止内存碎片化。 新世代采用 Scavenge算法来处理。该算法把空间分为两部分,一部分存放对象,一部分空置。对象区域快要写满时,对活动对象进行标记,把它们复制到空置空间,有序排序起来,然后把对象区域清除,交换两个区域的角色,即进行了一次垃圾回收后,本来是对象区域的,现在成了空置区域,另一个区域则成了存放对象的区域。
由于存在复制操作,如果空间较大,时间成本会较高,所以新世代空间不会设置得很大。然而,由于空间不大,很容易被对象塞满,所以需要对象晋升策略来处理,即把两次垃圾回收后依然存在的对象放在老世代空间。 老世代空间里的垃圾回收采收主流垃圾回收算法的混合,首先主要用 标记清除算法,由于多次垃圾回收后,内存空间已经存有大量碎片,所以使用标记压缩清除法,把存活对象进行整理。 另一方面,由于进行垃圾回收,需要暂停运行的JS代码,如果存有大量的对象,会造成全停顿,严重影响代码运行情况,所以V8引擎使用了增量标记算法。就是把垃圾回收的标记和JS代码交替执行,每次标记一点点,最后才进行清除。