如何编写避免垃圾开销的实时 Javascript 代码

3,874
原文链接: www.qcyoung.com

编辑于 2012 年 3 月 27 日: 哇,这篇文章已经写了有很长一段时间了,十分感谢那些精彩的回复!其中有一些对于一些技术的指正,如使用 ‘delete’ 。我知道了使用它可能会导致其他的降速问题,因此,我们在引擎中极少使用它。一如既往的你还需要对所有的事进行权衡并且需要通过其他关注点来平衡垃圾回收机制,这也只是一个在我们引擎中发现的的实用、简单的技术列表,它并不是一个完整的参考列表。但是我希望它还是有用的!

一个用 Javascript 编写的 HTML5 游戏,要达到流畅体验的一个最大阻碍就是垃圾回收 ( GC ) 卡顿。 Javascript 并没有一个显式的内存管理,意味着你创造东西后却不能释放它们占用的内存。因此迟早浏览器便会替你决定去清理它们:这时代码执行就会被暂停,浏览器会找出哪一部分内存是现在仍在被使用的,并把其他所有东西占用的内存释放掉。这篇博文将会去探究避开GC开销的技术细节,这对方便进行使用任何插件或是使用 Construct 2 进行 Javascript SDK开发都应该能派上用场。

浏览器有很多技术性手段来减少 GC 卡顿,但是如果你的代码创造了许多垃圾,迟早浏览器也将会暂停并进行清理。随着对象逐步创建的过程中,之后浏览器又突然清理,这最后将导致内存使用情况图表呈现 z 字形。例如,下面是 Chrome 在玩太空爆破手时的内存使用情况。

Chrome garbage-collected memory usage

当在玩一个 Javascript 游戏时会呈现 z 字形的内存占用情况。这可能是一个内存泄漏错误,但是实际上是 JavaScript 的正常操作。

此外,游戏以 60 fps 运行时只有 16 ms 的时间来渲染每一帧,但是 GC 会很轻易的产生最少 100 ms 以上明显的卡顿,在更糟的情况下,这会导致不断卡顿的游戏体验,因此对于像游戏引擎一样实时运行的 Javascript 代码,解决办法是努力尝试在典型帧的持续时间内你不要创建任何东西。这实际上是相当困难的,因为有许多看上去无害的 Javascript 语句实际上却创造了垃圾,它们必须从每帧动画的代码路径里删除掉。在 Construct 2 中我们竭尽全力减少每一处引擎的垃圾开销,但是你可以从图表中看到上面仍然有许多小的对象被创建所以 Chrome 还会每隔数秒进行一次清除。要注意这里只是一个小的清理 - 这里并没有大量的内存被清理出来,因为一个更高更极端的z曲线会更引起关注,但是它可能已经足够好了,因为小型的垃圾集合执行会更快并且偶尔的小卡顿也一般不太引人注意 - 因此我们应该看到了,有时我们确实很难避免产生新的资源分配。

同样重要的包括第三方插件以及开发人员行为也需要遵守这些原则,否则,一个写的不好的插件可以产生许多垃圾并会让游戏十分卡顿,尽管主引擎 Construct 2 已经是一个非常低垃圾开销的引擎了。

简单的技巧

首先,最明显的是,关键词 new 指示了资源的分配,例如 new Foo() 在可能的情况下,它会在启动时尝试创建一个对象,并且尽可能长时间、简单的重新使用相同的对象

不太明显的是,这里有三种快捷语法方式来相似的调用 new :

{} (创建一个新对象)
[] (创建一个新数组)
function () { ... } (创建一个新函数,也会被垃圾收集)

对于对象,用避免 {} 一样的方式来避免 new - 尝试去回收对象。请注意这包括像 { "foo": "bar" } 这样带属性的对象,也就是我们在函数中常用的一次性返回多个值。或许将每一次的返回值写入一个相同的(全局)对象来返回的写法是更好的 - 在文档中要仔细记录这一点,因为如果你保持引用这样的返回对象,可能在每次调用改变的时候发生错误。

实际上你可以回收一个存在的对象(如果它没有原型链)通过删除它的所有属性,将它还原为一个空的对象如 {} 一样。为此你可以使用 cr.wipe(obj) 函数,它的定义如下:

// remove all own properties on obj,
effectively reverting it to a new object
cr.wipe = function (obj)
{
    for (var p in obj)
    {
        if (obj.hasOwnProperty(p))
            delete obj[p];
    }
};

因此在某些情况下,你可以调用 cr.wipe(obj) 并为其再次添加属性来重用一个对象。比起重新简单分配 {} 现场清除一个对象可能需要更长的时间,但是在实时处理的代码中更重要的是避免产生垃圾,从而减少未来可能产生的卡顿情况。

分配 [] 到一个数组中被经常用来作为一个快捷方式去清除这个数组(例如 arr = [];),但请注意这将创建一个新的空数组并使旧的数组成为一个垃圾!更好的写法是 arr.length = 0; ,这种方式具有相同的效果但却继续使用了相同的数组对象。

函数则有一点棘手,函数通常在执行时创建并且不倾向于在运行时进行过多分配 - 但这意味着它们在动态创建时很容易被忽视。一个例子是返回函数的函数。主要的游戏循环使用了 setTimeout 或者 requestAnimationFrame 方法来调用一个成员函数类似如下:

setTimeout((function (self) { return function () {
self.tick(); }; })(this), 16);

这看起来像是一个合理的方式来每 16ms 调用一次 this.tick() 。然而,这也意味着每一次执行 tick 函数都会返回一个新函数!这可以通过永久存储函数的方法来避免,例如:


// at startup
this.tickFunc = (function (self) { return function () {
self.tick(); }; })(this);

// in the tick() function
setTimeout(this.tickFunc, 16); 

这将在每次执行 tick 函数时重复使用相同的函数来代替产生一个新的函数。这个方法可以应用到任意其他地方的返回函数中或是运行创建的函数中。

进阶技巧

随着我们的进展,进一步的避免产生垃圾变得更加困难,由于 Javascript 本身就是围绕着 GC 所设计的。许多 Javascript 中方便的库函数也总是创建了新的对象。这儿没有什么你可以做的但是当你返回文档查阅那些返回值时。例如,数组中的 slice() 方法会返回一个数组(基于保持不变的原始数组范围内),字符串的 substr 会返回一个新的字符串(基于保持不变的原始字符串字符的范围),等等。调用这些函数都会产生垃圾,而你能做的就是不要去调用它们,或是在极端情况下重写你的函数使它们不再产生垃圾。例如在 Construct 2 这种引擎,由于各种原因一个经常的操作是通过索引去删除数组里的一个元素。这个方法的快捷使用方式如下:

var sliced = arr.slice(index + 1);
arr.length = index;
arr.push.apply(arr, sliced);

然而 slice() 返回一个原始数组的后半部分来组成了一个新的数组,并且在被(arr.push.apply)复制后产生了垃圾。由于这是我们引擎中一个生产垃圾的热门处,它被改写为了一个迭代版本:

for (var i = index, len = arr.length - 1; i < len; i++)
    arr[i] = arr[i + 1];

arr.length = len;

显然重写大量的库函数是相当痛苦的,所以你需要仔细的权衡需求实现的方便性以及垃圾产生之间的平衡。如果它在每帧中被调用了很多次,你可能最好重写这个你需要的函数库。

这里可以很容易的使用 {} 语法来沿着递归函数传递数据。通过一个数组来表示一个堆栈,在这个堆栈中对递归的每一级进行 push 和 pop 是更好的。更好的是,实际上你并不需要在数组中 pop - 你应该将数组中最后一个对象像垃圾一样处理掉。来代替使用一个 ‘top index’ 变量进行简单减量。然后为了代替 pushing ,则增加 top index 并且如果有的话就重用数组中的下一个对象,否则执行真正的 push。

此外,在所有可能的情况下避免向量对象(如 vector2 中的 x 和 y 属性)。虽然可能函数返回这些对象会让它们立刻改变或返回这两个值时会方便些,你可以在每一帧中轻松地结束数百个这样的创建对象,这将导致可怕的 GC 性能。这些函数必须分离出来在每个单独的组件中工作,例如:使用 getX()getY() 来代替 getPosition() 来返回一个 vector2 对象。

有时候你无法摆脱一个库是一个产生垃圾的噩梦。 Box2Dweb 是一个典型的例子:它每一帧产生了数百个 b2Vec2 对象并且不断的在浏览器产生垃圾,并最终导致垃圾处理器产生显著的卡顿效果。在这种情况下最好的办法是创建一个缓存回收机制。我们一直在测试 Box2D(Box2Dweb-closure) 的修正版本,它似乎可以使 GC 暂停进行缓解(虽然没有完全解决)。查阅 b2Vec2.jsGetFree 代码。这里有一个名字叫 ‘free cache’ 的数组,在之后的整个代码中如果不再使用 b2Vec2,它就会在 free cache 中被释放,当需要请求一个新的 b2Vec2,而它如果在 free cache 中还存在那么它就会被重用,否则才会分配一个新的。这并不完美,在一些测试后通常只有一半的 b2Vec2s 被创建并回收,但它确实帮助 GC 缓解了压力从而减少了频繁的卡顿。

结论

在 Javascript 中很难去完全避免垃圾。它的垃圾收集模式根本上是不符合像游戏这样的实时软件的需求的。从 Javascript 代码中需要进行大量的工作来消除垃圾,因为有很多直接的代码含有创建大量垃圾的副作用。然而,只要仔细小心一些,Javascript 也是可以在实时项目中不产生或是制造很少的垃圾开销,而对于需要保持高度响应性的游戏和应用程序这也是至关重要的。