这次给大家带来怎样使用垃圾回收器,使用垃圾回收器的注意事项有哪些,下面就是实战案例,一起来看一下。

垃圾回收器是一把十足的双刃剑。其好处是可以大幅简化程序的内存管理代码,因为内存管理无需程序员来操作,由此也减少了(但没有根除)长时间运转的程序的内存泄漏。对于某些程序员来说,它甚至能够提升代码的性能。

另一方面,选择垃圾回收器也就意味着程序当中无法完全掌控内存,而这正是移动终端开发的症结。对于JavaScript,程序中没有任何内存管理的可能——ECMAScript标准中没有暴露任何垃圾回收器的接口。网页应用既没有办法管理内存,也没办法给垃圾回收器进行提示。

严格来讲,使用垃圾回收器的语言在性能上并不一定比不使用垃圾回收器的语言好或者差。在C语言中,分配和释放内存有可能是非常昂贵的操作,为了使分配的内存能够在将来释放,堆的管理会趋于复杂。而在托管内存的语言中,分配内存往往只是增加一个指针。但随后我们就会看到,当内存耗尽时,垃圾回收器介入回收所产生的巨大代价。一个未经琢磨的垃圾回收器,会致使程序在运行中出现长时间、无法预期的停顿,这直接影响到交互系统(特别是带有动画效果的)在使用上的体验。引用计数系统时常被吹捧为垃圾回收机制的替代品,但当大型子图中的最后一个对象的引用解除后,同样也会有无法预期的停顿。而且引用计数系统在频繁执行读取、改写、存储操作时,也会有可观的性能负担。

或好或坏,JavaScript需要一个垃圾回收器。V8的垃圾回收器实现现在已经成熟,其性能优异,停顿短暂,性能负担也非常可控。

基本概念

垃圾回收器要解决的最基本问题就是,辨别需要回收的内存。一旦辨别完毕,这些内存区域即可在未来的分配中重用,或者是返还给操作系统。一个对象当它不是处于活跃状态的时候它就死了(废话)。一个对象处于活跃状态,当且仅当它被一个根对象或另一个活跃对象指向。根对象被定义为处于活跃状态,是浏览器或V8所引用的对象。比如说,被局部变量所指向的对象属于根对象,因为它们的栈被视为根对象;全局对象属于根对象,因为它们始终可被访问;浏览器对象,如DOM元素,也属于根对象,尽管在某些场合下它们只是弱引用。

从侧面来说,上面的定义非常宽松。实际上我们可以说,当一个对象可被程序引用时,它就是活跃的。比如:

function f() { var obj = {x: 12}; g(); // 可能包含一个死循环 return obj.x; }def scavenge(): swap(fromSpace, toSpace) allocationPtr = toSpace.bottom scanPtr = toSpace.bottom for i = 0..len(roots): root = roots[i] if inFromSpace(root): rootCopy = copyObject(&allocationPtr, root) setForwardingAddress(root, rootCopy) roots[i] = rootCopy while scanPtr < allocationPtr: obj = object at scanPtr scanPtr += size(obj) n = sizeInWords(obj) for i = 0..n: if isPointer(obj[i]) and not inOldSpace(obj[i]): fromNeighbor = obj[i] if hasForwardingAddress(fromNeighbor): toNeighbor = getForwardingAddress(fromNeighbor) else: toNeighbor = copyObject(&allocationPtr, fromNeighbor) setForwardingAddress(fromNeighbor, toNeighbor) obj[i] = toNeighbor def copyObject(*allocationPtr, object): copy = *allocationPtr *allocationPtr += size(object) memcpy(copy, object, size(object)) return copy

在这个算法的执行过程中,我们始终维护两个出区中的指针:allocationPtr指向我们即将为新对象分配内存的地方,scanPtr指向我们即将进行活跃检查的下一个对象。scanPtr所指向地址之前的对象是处理过的对象,它们及其邻接都在出区,其指针都是更新过的,位于scanPtr和allocationPtr之间的对象,会被复制至出区,但这些对象内部所包含的指针如果指向入区中的对象,则这些入区中的对象不会被复制。逻辑上,你可以将scanPtr和allocationPtr之间的对象想象为一个广度优先搜索用到的对象队列。

译注:广度优先搜索中,通常会将节点从队列头部取出并展开,将展开得到的子节点存入队列末端,周而复始进行。这一过程与更新两个指针间对象的过程相似。

我们在算法的初始时,复制新区所有可从根对象达到的对象,之后进入一个大的循环。在循环的每一轮,我们都会从队列中删除一个对象,也就是对scanPtr增量,然后跟踪访问对象内部的指针。如果指针并不指向入区,则不管它,因为它必然指向老生区,而这就不是我们的目标了。而如果指针指向入区中某个对象,但我们还没有复制(未设置转发地址),则将这个对象复制至出区,即增加到我们队列的末端,同时也就是对allocationPtr增量。这时我们还会将一个转发地址存至出区对象的首字,替换掉Map指针。这个转发地址就是对象复制后所存放的地址。垃圾回收器可以轻易将转发地址与Map指针分清,因为Map指针经过了标记,而这个地址则未标记。如果我们发现一个指针,而其指向的对象已经复制过了(设置过转发地址),我们就把这个指针更新为转发地址,然后打上标记。

算法在所有对象都处理完毕时终止(即scanPtr和allocationPtr相遇)。这时入区的内容都可视为垃圾,可能会在未来释放或重用。

秘密武器:写屏障

上面有一个细节被忽略了:如果新生区中某个对象,只有一个指向它的指针,而这个指针恰好是在老生区的对象当中,我们如何才能知道新生区中那个对象是活跃的呢?显然我们并不希望将老生区再遍历一次,因为老生区中的对象很多,这样做一次消耗太大。

为了解决这个问题,实际上在写缓冲区中有一个列表,列表中记录了所有老生区对象指向新生区的情况。新对象诞生的时候,并不会有指向它的指针,而当有老生区中的对象出现指向新生区对象的指针时,我们便记录下来这样的跨区指向。由于这种记录行为总是发生在写操作时,它被称为写屏障——因为每个写操作都要经历这样一关。

你可能好奇,如果每次进行写操作都要经过写屏障,岂不是会多出大量的代码么?没错,这就是我们这种垃圾回收机制的代价之一。但情况没你想象的那么严重,写操作毕竟比读操作要少。某些垃圾回收算法(不是V8的)会采用读屏障,而这需要硬件来辅助才能保证一个较低的消耗。V8也有一些优化来降低写屏障带来的消耗:

大多数的脚本执行时间都是发生在Crankshaft当中的,而Crankshaft常常能静态地判断出某个对象是否处于新生区。对于指向这些对象的写操作,可以无需写屏障。

Crankshaft中新出现了一种优化,即在对象不存在指向它的非局部引用时,该对象会被分配在栈上。而一个栈上对象的相关写操作显然无需写屏障。(译注:新生区和老生区在堆上。)