V8引擎垃圾回收

367 阅读7分钟

什么是垃圾回收

垃圾回收(Garbage Collection, 简称GC):程序员定义了一个变量,就是在内存中开辟了一段相应的空间来存值。由于内存是有限的,所以当程序不再需要使用某个变量的时候,就需要销毁该对象并释放其所占用的内存资源,好重新利用这段空间(百度百科的解释)。简单一点来说就是将不再需要使用的变量所占用的内存释放掉的过程就叫做垃圾回收!

为什么要垃圾回收

名词解释里已经讲了,因为引擎为程序分配的内存是有限的,如果不进行垃圾回收,当程序运行时间久了,那内存空间长期无法得到释放,很快内存就会被占满,接下来想再有运算的时候,分配的内存中将无法再加入资源了,那很可能程序就会因此挂掉了。这也就是所谓的内存泄漏了...

内存大小限制

V8限制了内存的大小(64位系统分配1.4G,32位分配0.7G),为啥要限制大小呢?首先,JavaScript设计之初是为浏览器设计的,不太可能应用到大内存的场景;再者,更深层次的原因是因为V8垃圾回收机制所限制的。因为JavaScript是单线程的,当执行垃圾回收的时候,是会阻塞JavaScript应用逻辑的,直到垃圾回收结束才会重新执行JavaScript应用逻辑,若V8的内存为1.5G,V9做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1s以上。也就意味着用户界面会有1秒钟的卡顿,造成假死现象。所以V8简单粗暴地干脆限制了内存(堆内存)大小来避免这种情况!

内存回收方式

其实我们写了这么久的代码,好像都没有做过什么垃圾回收啊?那是因为引擎自动进行了垃圾回收。因此,大幅降低了程序员的负担;但同时也意味着程序员无法掌控内存,内存如果管理,垃圾何时回收,如果回收程序员完成无法掌控!

那JavaScript到底是怎么样进行垃圾回收的呢?

引用计数

引用计数的意思是跟踪记录每个值被引用的次数,当一个值被一个变量引用了那它的引用次数+1,如果被另一个值又引用了再+1,那这个值的引用次数就是2。同样的,当该变量被赋予了其他一个新的值,那之前的值的引用就-1。当一个值的引用为0,那就表示无法再通过变量访问到它了,那也就可以对它进行垃圾回收了。这样,垃圾收集器下次运行的时候就会把这个值占用的内存释放掉!

目前这种方式已经不太常见了,因为它存在一个比较严重的问题--循环引用。


function foo () {
    var a = new Object();
    var b = new Object();
    
    a.value1 = b;
    b.value2 = a;
}

a和b的属性相互指向了对方,这样一来,当函数执行完毕后,这两个对象的引用次数依然不会是0,所以无法回收,从而导致这块内存永远被占用,因而很容易导致内存泄漏!

标记清除

当变量进入执行环境的时候(函数中声明变量),垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”,在离开环境之后还有标记的变量则是需要被删除的变量。

垃圾收集器给内存中的所有变量都加上标记,然后去掉环境中的变量以及被环境中的变量引用的变量的标记。在此之后再被加上的标记的变量即为需要回收的变量,因为环境中的变量已经无法访问到这些变量。

V8的垃圾回收策略

V8将分配的内存分为两个生代--新生代(new generation)和老生代(old generation)。

新生代中的对象为存活时间较短的对象,老生代中的对象为存活较长或者常驻内存的对象,分别对新老生代采用不同的垃圾回收算法来提高效率。

默认情况下,64位系统为新生代分配32M内存,而老生人分配1.4G内存,由此可见新生代分配到的内存很小,而老生代几乎占了所分配总内存的全部。

新生代

新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。

Cheney算法将内存一分为二,叫semispace,一块处于使用状态(称为From空间),一块处于闲置状态(称为To空间)。

对象最开始都会被分配到新生代。步骤如下:

1、A B C三个变量分配到了From空间;

2、GC进来判断对象B没有被引用,可以回收,对象A和C依然为活跃对象;

3、将活动对象A C从From空间复制到To空间;

4、将From空间的所有内存清空;

5、将From空间和To空间进行交换;

6、如果有新增的变量,会再次分配到From空间;

7、下次GC进来时重复2到5步骤。

由此可见新生代每次都有一半的内存是闲置的,是典型的牺牲空间换时间的算法;也正因为每次都会有一半的内存被闲置,所以哪怕它的时间效率非常高,也不适用任何场景,因此,当对象满足以下两种情况的时候会“晋升”至老生代。

1) 当对象经历不止一次从From空间复制到To空间时,那么该对象会被移到老生代中;

2)当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。

老生代

前面已经讲过,老生代几乎占有了全部1.4G内存,已经不适用Scavenge算法了,否则会浪费0.7G的内存。

Mark-Sweep(标记清除)

Mark-Sweep分为“标记”和“清除”两个阶段,在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象:

1.老生代中有对象A B C D E;

2.GC进入标记阶段,将A C E标记为存活对象;

3.GC进入清除阶段,回收掉死亡的B D对象所占用的内存空间;

但是因为清空的死亡对象不是连续的,所以当对象被回收后就会造成很多不连续的内存空间,这种内存碎片会造成后续的大对象无法分配到足够大的内存的情况。

Mark-Compact(标记整理)

正因为Mark-Sweep会造成大量的碎片内存,所以也便有了Mark-Compact,即将活着的对象向内存空间一端移动,移动完成后,直接清理掉边界外的所有内存。

1.老生代中有对象A B C D E;

2.GC进入标记阶段,将A C E标记为存活对象;

3.GC进入整理阶段,将所有存活对象A C E向内存空间的一侧移动;

4.GC进入清除阶段,将边界另一侧的内存一次性全部回收。

因为Mark-Compact需要移动对象,所以它的执行速度上不如Mack-Sweep,所以V8的回收策略是将两者结合使用的:

V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用Mark-Compact。