前方提醒: 篇幅较长,点个赞或者收藏一下,能够在下一次阅读时方便查找node
JavaScript是由垃圾回收机制自动进行内存管理的,在咱们编写代码的过程当中不须要像C/C++程序员那样时刻关注内存的分配和释放问题。在chrome浏览器或者node中,这些工做都是交给V8的垃圾回收器自动完成的。程序员
接下来咱们来了解一下V8是如何帮助咱们进行垃圾回收的。算法
在了解垃圾回收以前,须要先了解数据是如何保存的。chrome
V8中将内存分为栈空间和堆空间数组
var a = 1;
var b = {num: 2};
var c = 3;
var d = b;
复制代码
上述代码在内存中的保存形式如图:浏览器
咱们所要讨论的垃圾回收都是基于堆空间的。markdown
有些数据被使用以后,就不在被须要了,但仍是保存在内存中,这样的无用数据就是垃圾
。并发
修改上面的代码:函数
var a = 1;
var b = {num: 2};
var c = 3;
var d = b;
b = {num: 4};
d = b;
复制代码
此时,栈空间和堆空间变为以下的状况:oop
堆空间中地址为0x001
的对象没有被任何变量所引用,它就变成了垃圾数据,能够被垃圾回机制回收。
目前V8采用的可访问性(reachability)算法来判断对象是不是活动对象,这个算法是将一些GC Root(根对象)做为初始存活的对象的集合,从GC Roots对象出发,遍历GC Root中的全部对象:
GC Root有不少,一般包括了如下几种:
window.test = new Object();
window.test.a = [];
复制代码
执行上述代码,内存中的状况以下图所示:
再将另外一个对象赋值给a属性:
window.test.a = new Object();
复制代码
此时堆中的数组就成为了为非活动对象,由于咱们没法从一个GC Root遍历到这个数组,垃圾回收机制会把它自动清理。
代际假说是垃圾回收领域中一个重要的术语,它有如下两个特色:
V8的垃圾回收机制,就是创建在代际假说的基础之上的。接下来,咱们来分析下 V8 是如何实现垃圾回收的。
在实际的应用中,对象生存周期长短不一,不一样的垃圾回收算法只针对特定状况具备最好的效果,针对这种状况,V8将堆内存分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。V8对两个区域使用不一样的垃圾回收器,以便达到最好的效果。
新生代中的大部分对象在内存中存活的周期很短,且回收频繁,因此须要一个效率很是高的算法。副垃圾回收器使用Scavenge算法进行处理,该算法把新生代空间对半划分为两个区域,一半是From空间,处于使用状态;一半是To空间,处于闲置状态。
在新生代分配内存很是容易,咱们只须要保存一个指向内存区的指针,不断根据新对象的大小进行指针的递增便可。当该指针到达了新生代内存区的末尾,就须要一次清理。
Scavenge算法是一个空间换时间的复制算法,在占用空间不大的场景上很是适用。 新加入的对象会存放到From空间,当From空间快被写满时,就须要执行一次垃圾清理操做,大体的步骤以下:
Scavenge算法伪代码:
def scavenge(): // From和To进行交换 swap(fromSpace, toSpace) // 在To空间中维护两个指针allocationPtr和scanPtr // allocationPtr指向新对象要复制到的地方 // scanPtr指向即将要进行扫描的对象 allocationPtr = toSpace.bottom
scanPtr = toSpace.bottom
// 处理根对象可以直接访问的对象
for i = 0..len(roots):
root = roots[i]
if inFromSpace(root):
// 将根对象能直接访问到的对象root复制到To空间中allocationPtr指向的地方,并根据root的大小更新allocationPtr
rootCopy = copyObject(&allocationPtr, root)
// 更新root的地址
setForwardingAddress(root, rootCopy)
roots[i] = rootCopy
// 采用BFS的遍历方式,开始遍历全部能到达的对象
while scanPtr < allocationPtr:
obj = object at scanPtr
// 每处理一个对象,scanPtr就向后移动
scanPtr += size(obj)
n = sizeInWords(obj)
// 处理obj的全部子节点
for i = 0..n:
if isPointer(obj[i]) and not inOldSpace(obj[i]):
fromNeighbor = obj[i]
// 若是对象已经被复制到To空间,取它在To空间的地址
if hasForwardingAddress(fromNeighbor):
toNeighbor = getForwardingAddress(fromNeighbor)
// 若是对象不在To空间,将其复制到To空间allocationPtr所指的位置,并根据该对象的大小更新allocationPtr
else:
toNeighbor = copyObject(&allocationPtr, fromNeighbor)
setForwardingAddress(fromNeighbor, toNeighbor)
obj[i] = toNeighbor
// 当scanPtr == allocationPtr时,全部能到达的对象被处理完成,都被复制到了To空间,此时From空间将被清理
def copyObject(*allocationPtr, object):
copy = *allocationPtr
// 根据对象大小更新allocationPtr
*allocationPtr += size(object)
// 将object复制到copy指向的位置,也就是更新以前的allocationPtr位置
memcpy(copy, object, size(object))
return copy
复制代码
Scavenge算法过程:
var A = {C: {}};
var B = {
D: {
F: {},
G: {}
},
E: {}
};
复制代码
delete A.C;
复制代码
开始进行垃圾回收后:
1.将根对象能到达的A、B复制到To,并后移allocationPtr
2.查看scanPtr指向的A对象,因为A没有指向其余对象,因此将scanPtr后移
3.查看scanPtr指向的B对象,发现B可以访问到D、E,将D和E依次复制到To空间,并移动allocationPtr和scanPtr
4.查看scanPtr指向的D对象,发现D可以访问到F、G,将F和G依次复制到To空间,并移动allocationPtr和scanPtr
5.查看scanPtr指向的E对象,发现其没有指向其余对象,因此将scanPtr后移
6.依次查看F对象和G对象,发现其都没有指向其余对象,继续将scanPtr后移
7.scanPtr和allocationPtr相等时,说明可访问的对象都已经被处理完成,From空间中剩余的C变量将被释放
Scavenge算法的优缺点:
在必定的条件下,须要把存活周期长的对象移动到老生代中,也就是完成了对象的晋升。在从From空间复制到To空间前,会进行下面的步骤:
Scavenge算法会浪费一半空间,所以Scavenge算法并不适用于老生代空间,V8在老生代中的垃圾回收是采用了标记 - 清除(Mark- Sweep)和标记 - 整理(Mark - Compact)两种算法相结合的方式进行的。
顾名思义, 标记 - 清除算法分为两个过程:
因为清除阶段只是清除未被标记的对象,这部分对象在老生代中占比很小,因此标记 - 清除算法的效率较高。
标记 - 清除算法执行后,内存中会产生大量不连续的内存碎片,这样会致使内存中没有足够的连续内存分配给较大的对象,因而V8又引入了另一种算法:标记 - 整理(Mark - Compact)。
它的标记过程和标记 - 清除算法一致,但接下来标记 - 整理算法不是直接对未标记的对象进行清除,而是让全部标记过的对象都移向内存的一端,而后直接清理掉这一端以外的内存,起到了整理内存的做用。
因为标记 - 整理算法须要移动对象,所以它的速度不会很快,V8结合了标记 - 清除和标记 - 整理算法,主要采用标记 - 清除算法,若是空间不足的时候,才使用标记整理。
接下来学习V8是如何优化垃圾回收的执行效率的。
最初,为了不js逻辑和垃圾回收器看到的状况不一致的问题,V8采用了垃圾回收时将js执行暂停下来的方式,等待垃圾回收结束后才恢复js的执行,这种行为被成为全停顿(Stop-The-World)。
这种方式的劣势明显,它会阻塞js的执行,若是垃圾回收占用的时间较长,就会形成页面明显的卡顿。为了解决全停顿的问题,V8添加并行、增量、并发等技术对垃圾回收机制进行了优化。
接下来分别针对这三种优化方式作出解释。
并行方式是主线程在执行垃圾回收的任务的同时,使用多个辅助线程来并行处理,这样就会加快垃圾回收的执行速度。
新生代中副垃圾回收器采用的就是并行方式,它在主线程执行垃圾回收的过程当中,启动了多个辅助线程来负责垃圾清理操做,这些辅助线程同时将From空间中的数据移动到To区域。但本质上并行方式仍是一种"全停顿",所以还不能知足对性能要求更高的老生代垃圾回收。
2011年,V8 从又引入了增量回收 (Incremental)的方式。垃圾回收器不须要一次执行完整的垃圾回收过程,每次只执行整个垃圾回收过程当中的一小部分工做,好比每次标记一部分数据,能够参考下图:
主线程中,js和垃圾回收交替执行,能够避免单次垃圾回收时间过长形成的卡顿问题。
增量回收 (Incremental)会带来两个问题:
针对上面的两个问题,V8引入了三色标记法和写屏障机制(Write-barrier)来解决。
为了解决增量回收中垃圾回收恢复执行时不知道从哪一个位置继续开始执行的问题,V8采用黑、白、灰三色标记法。
垃圾回收器能够根据当前是否存在灰色节点来判断整个标记是否完成。若是没有灰色节点了,就能够清理掉白色节点了。若是还有灰色标记,当再次恢复垃圾回收时,便从灰色的节点开始继续执行。
垃圾回收器将某个节点标记为黑色后,js代码执后又为该黑色节点增长了一个节点,因为新增节点都是白色,垃圾回收器不会再次将这个白色节点进行标记了,它就会被垃圾回收器回收。
执行了:
window.a = Object()
window.a.b = Object()
window.a.b.c=Object()
复制代码
又只执行了:
window.a.b = Object() // d
复制代码
这时就出现了黑色节点指向白色节点的问题,会形成新增节点的误回收。写屏障机制就是要强制保证黑色节点不能指向白色节点。
在执行object.field = value
时,V8就插入写屏障代码,强制将value标记为灰色。
// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
if (color(object) == black && color(value) == white) {
set_color(value, grey);
marking_worklist.push(value);
}
}
复制代码
并发是指主线程不断执行js代码,而辅助线程则在后台彻底执行垃圾回收。
并发回收主要有如下两个问题:
可是权衡利弊,并行回收这种方式的效率仍是远高于其余方式。
并行、增量、并发三种方式在V8的实际应用中不是单独存在的,V8的主垃圾回收器融合了这三种机制。
本文从V8的数据存储、内存分代、垃圾回收算法、优化策略几个方面进行了讲解,虽然内容很多,但仍是忽略不少细节,V8垃圾回收机制的细节很是复杂。咱们大多数开发人员在开发JavaScript时不须要需考虑垃圾回收,可是了解一些垃圾回收的内部知识能够帮助咱们考虑内存使用的状况,更好的进行内存问题的分析、排查和解决,让咱们在快节奏的技术迭代中把握本质。
参考资料