游戏优化 | 减少 JavaScript 垃圾回收

2021/5/17 12:25:36

本文主要是介绍游戏优化 | 减少 JavaScript 垃圾回收,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

图片

 

这篇文章介绍了为什么 GC 会造成游戏卡顿,以及 JavaScript 代码优化的一些方法,以减少内存垃圾

虽然文章中介绍的是 Construct 2,但和 Creator 原理相同,本人就不再画蛇添足了,只是对文章进行下整理和校对,希望官方的大佬们可以让 Creator 在世界的舞台上绽放光彩

 

翻译作者:

人人网FED

 

英文链接: 

https://www.construct.net/en/blogs/construct-official-blog-1/write-low-garbage-real-time-761

 

// 减少垃圾回收的必要性 //

 

对于用 JavaScript 开发的 HTML5 游戏来说,垃圾回收暂停会严重阻碍游戏的流畅体验。JavaScript 并没有提供显式的内存管理机制,这就意味着你能创建对象但是并不能释放他们。浏览器迟早需要清理这些对象,一旦开始清理,就意味着当前执行的任务必须暂停,浏览器必须计算出哪一部分内存正在使用中,从而释放其他没有使用的内容所占用的内存空间

 

这篇博客将会深入研究避免过度垃圾回收的技术细节,而这也正是用Construct 2 提供的 JavaScript SDK 开发插件或特性的开发人员正需要了解的

 

浏览器开发者在实现浏览器的过程中,就使用了许多技术来减少垃圾回收暂停,但是如果你的代码创建了非常多的内存垃圾,浏览器仍然不得不暂停当前执行的任务,并且陷入内存清理的工作中

 

随着内存对象的不断创建,浏览器将会间歇性的执行内存清理,清理过程如下“锯齿形内存使用统计图”所示

 

下图就是在游戏 Space Blaster 运行过程中 Chrome 的内存使用情况图

图片

 

进行 JavaScript 游戏时的锯齿形内存使用统计图,这真实的反映了大多数情况下 JavaScript 的内存使用情况(除了内存泄露的情况)

 

另外,一个以60帧每秒的速度运行的游戏,每一帧的渲染之间只有16毫秒,但是一次垃圾回收过程则经常需要占用100毫秒或者更长的时间。这就导致了一个非常容易察觉的暂停,或者更坏的情况,那就是非常卡的游戏体验

 

因此,对于游戏引擎这种实时性要求很高的 JavaScript 代码,在每一帧中尽量减少创建的对象是减少垃圾回收的一个重要解决方案

 

由于大多数看上去没有问题的 JavaScript 代码,很可能会产生内存垃圾,而这些代码必须从每一帧都需要运行的代码中移除掉。由于问题代码的隐蔽性,导致通过改善代码质量来减少垃圾回收变得异常困难

 

在 Construct2 引擎的 per-tick 引擎中,为避免过多的垃圾回收,我们进行了大量的工作,并且取得了可喜的进展。虽然就像上图所示的那样,仍然有一小部分对象创建,迫使 Chrome 必须每隔几秒钟就进行一次垃圾清理

 

虽然每次垃圾清理过程中,都只清理了少数内存,比起大量内存清理引起的大锯齿,这或许并不能引起足够的重视。但是这也是可以接受的,因为小规模的垃圾回收速度更快,并且收集的时间是随机的,用户也不容易觉察到。再说了,避免新的内存分配,确实是一件异常困难的事情

 

话虽这样说,但是对于第三方插件和特性开发者来说,遵循下面介绍的这些规则和技术,也是非常必要的。因为一个创建了大量垃圾的写的很烂的插件,会使得游戏变得很卡,即使 Construct 2 主引擎产生的垃圾很少,也经不起这么折腾

// 基础优化 //

 

首先,最明显的,new 关键字就意味着一次内存分配,例如 new Foo()。最好的处理方法是:在初始化的时候新建对象,然后在后续过程中尽量多的重用这些创建好的对象

 

另外还有以下三种内存分配表达式(可能不像new关键字那么明显了):

– {} (创建一个新对象)

– [] (创建一个新数组)

– function() {…} (创建一个新的方法,注意:新建方法也会导致垃圾收集!!)

 

1对象 object 优化

为了最大限度的实现对象的重用,应该像避免使用 new 语句一样避免使用 {} 来新建对象

 

{“foo”:”bar”} 这种方式新建的带属性的对象,常常作为方法的返回值来使用,可是这将会导致过多的内存创建,因此最好的解决办法是:每一次函数调用完成之后,将需要返回的数据放入一个全局的对象中,并返回此全局对象。如果使用这种方式,就意味着每一次方法调用都会导致全局对象内容的修改,这有可能会导致错误的发生。因此,一定要对此全局对象的使用进行详细的注释和说明

 

有一种方式能够保证对象(确保对象 prototype 上没有属性)的重复利用,那就是遍历此对象的所有属性,并逐个删除,最终将对象清理为一个空对象

 

cr.wipe(obj) 方法就是为此功能而生,代码如下:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
// 删除obj对象的所有属性,高效的将obj转化为一个崭新的对象!cr.wipe = function (obj) {    for (var p in obj) {        if (obj.hasOwnProperty(p))            delete obj[p];    }};

  

有些时候,你可以使用 cr.wipe(obj) 方法清理对象,再为 obj 添加新的属性,就可以达到重复利用对象的目的。虽然通过清空一个对象来获取“新对象”的做法,比简单的通过 {} 来创建对象要耗时一些,但是在实时性要求很高的代码中,这一点短暂的时间消耗,将会有效的减少垃圾堆积,并且最终避免垃圾回收暂停,这是非常值得的

 

2数组 array 优化

将 [] 赋值给一个数组对象,是清空数组的捷径(例如:arr = [];),但是需要注意的是,这种方式又创建了一个新的空对象,并且将原来的数组对象变成了一小片内存垃圾!实际上,将数组长度赋值为0(arr.length = 0)也能达到清空数组的目的,并且同时能实现数组重用,减少内存垃圾的产生

 

3方法function优化

方法一般都是在初始化的时候创建,并且此后很少在运行时进行动态内存分配,这就使得导致内存垃圾产生的方法,找起来就不是那么容易了。但是从另一角度来说,这更便于我们寻找了,因为只要是动态创建方法的地方,就有可能产生内存垃圾。例如:将方法作为返回值,就是一个动态创建方法的实例

 

在游戏的主循环中,setTimeout 或 requestAnimationFrame 来调用一个成员方法是很常见的,例如:

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

  

每过16毫秒调用一次 this.tick(),嗯,乍一看似乎没什么问题,但是仔细一琢磨,每一次调用都返回了一个新的方法对象,这就导致了大量的方法对象垃圾!

 

为了解决这个问题,可以将作为返回值的方法保存起来,例如:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
// at startupthis.tickFunc = (    function (self) {        return function () {            self.tick();        };    })(this);// in the tick() functionsetTimeout(this.tickFunc, 16);

 

相比于每次都新建一个方法对象,这种方式在每一帧当中重用了相同的方法对象。这种方式的优势是显而易见的,而这种思想也可以应用在任何以方法为返回值或者在运行时创建方法的情况当中

 

// 进阶优化 //

 

从根本上来说, JavaScript 本身就是围绕着垃圾收集来设计的。随着我们工作的进行,避免内存垃圾变得越来越困难。因为很多方便实用的 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 操作。更进一步,不要调用 array 的 pop 方法(pop将会使得 array 的最后一个元素将会变成内存垃圾),而应该使用一个索引来记录数组的最后一个元素的位置,在 pop 时简单的将索引减1即可;类似的,将索引加1来代替 array 的 push 操作,只有当索引对应的元素不存在时,才执行真正的 push 为数组加入一个新元素

 

另外,在任何时候,都应该避免使用向量对象(例如:包含 x 和 y 属性的 vector2 对象)。有些方法将向量对象作为方法返回值,既可以支持返回值的再次修改,又能够将需要的属性一次性返回,使用起来非常方便。但是有时候在一帧动画中,创建了成百上千个这样的向量对象,从而导致严重的垃圾回收性能问题,也是非常常见的。因此最好将这些方法分离成具有独立职责的功能个体,例如:利用 getX() 和 getY() 方法(返回具体数据)代替 getPosition() 方法(返回一个 vector2 对象)

 

有时候,有的库是一个生产垃圾的噩梦,人人趋而避之,可是你或许就偏偏强烈依赖于这样的库。Box2Dweb 就是一个典型的例子:这个库在每一帧都会产生成百上千个 b2Vec2 对象,持续向浏览器中注入内存垃圾,最终导致严重的垃圾收集暂停。针对这种情况,最好的解决办法就是创建一个重复利用的对象缓存,我们正在对一个修改后版本的 Box2D 进行测试,这个版本已经创建了对象缓存,并且它看上去能够有助于缓解(虽然并没有完全解决)垃圾收集暂停。Get 和 Free 的源码请参见 b2Vec2.js

 

新版的 Box2D 中存在一个被称为“自由缓存”(free cache)的数组,在任何涉及 b2Vec2 对象操作的地方都包含了对自由缓存的考虑。如果 b2Vec2 对象不再使用,则将此对象放置在自由缓存中;当需要新建 b2Vec2 对象时,如果自由缓存中存在对象,则重用这些对象,如果不存在,才会创建一个新的对象。这种方案并不完美,因为在我进行的一些测试中,只有一半的 b2Vec2 对象能被重复利用,但是这种方案,的的确确减少了垃圾回收的压力,并且有效的减少了垃圾回收暂停的频率

 

// 结论 //

 

在 JavaScript 中,彻底避免垃圾回收是非常困难的。垃圾回收机制与实时软件(例如:游戏)的实时性要求,从根本上就是对立的

 

但是,为了减少内存垃圾,我们还是可以对 JavaScript 代码进行彻底检查,有些代码中存在明显的产生过多内存垃圾的问题代码,这些正是我们需要检查并且完善的

 

我认为,只要我们投入更多的精力和关注,实现实时的、低垃圾收集的 JavaScript 应用还是很有可能的。毕竟,对于可交互性要求较高的游戏或应用来说,实时性和低垃圾收集,两者都是至关重要

 

图片

更多教程

请扫码关注

图片

 

图片



这篇关于游戏优化 | 减少 JavaScript 垃圾回收的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程