如何在Java游戏中避免垃圾回收延迟?(最佳实践)

66

我正在为Android平台上的交互式游戏进行性能优化。偶尔会出现由于垃圾回收而导致绘制和交互出现问题的情况。通常不到0.1秒,但在非常慢的设备上有时可能高达200毫秒。

我使用Android SDK中的ddms分析器来查找内存分配的来源,并将其从我的内部绘图和逻辑循环中删除。

最严重的问题出现在短循环中,例如:

for(GameObject gob : interactiveObjects)
    gob.onDraw(canvas);

每次循环执行时都会分配一个迭代器,现在我正在使用数组(ArrayList)来存储对象。如果我需要在内部循环中使用树或哈希表,我知道需要小心处理,甚至需要重新实现它们,而不是使用Java集合框架,因为我无法承受额外的垃圾回收。这可能会在我查看优先队列时出现。

我还遇到了问题,当我想要使用Canvas.drawText显示分数和进度时。这是不好的。

canvas.drawText("Your score is: " + Score.points, x, y, paint);

因为字符串字符数组StringBuffer将被分配到各个地方以使其正常工作。如果您有几个文本显示项,并且每秒运行帧60次,那么这些消耗将会增加并增加垃圾收集中断。我认为在这里最好的选择是保留char[]数组,手动将您的intdouble解码到其中,并将字符串连接到开头和结尾。如果有更好的解决方法,我很乐意听听。

我知道肯定还有其他人在处理这个问题。您是如何处理它的,您发现了什么陷阱和最佳实践,可以在Java或Android上进行交互式运行?这些gc问题足以让我想念手动内存管理,但没那么多。


3
请参考Romain Guy在Google I/O大会上的讲座中有关视图失效、垃圾回收和分配查看器工具的部分:http://code.google.com/events/io/2009/sessions/TurboChargeUiAndroidFast.html此外,官方的Android性能指南(http://developer.android.com/guide/practices/design/performance.html) 已经建议尽量避免在标准数组中使用增强for循环语法,因为这会导致反复分配迭代器。在合并字符串时,建议使用StringBuilder(只分配一次并在需要重新使用时清空),而不是用“+”进行连接。 - Yoni Samlan
3
我认为去年 Google I/O 大会上更重要的演讲是 Chris Pruett 关于游戏的。它包含了很多关于实时游戏中性能优化的好线索:http://code.google.com/intl/de-DE/events/io/2009/sessions/WritingRealTimeGamesAndroid.html - Moritz
从@Moritz更新的链接:http://www.google.com/events/io/2010/sessions/writing-real-time-games-android.html。 - maaartinus
6个回答

57

我曾经从事过Java移动游戏开发... 要避免GC(垃圾回收)对象(这反过来会在某个时刻触发GC,从而降低你的游戏性能),最好的方法就是在主游戏循环中一开始就避免创建它们。

没有“干净”的方法处理这个问题,我首先给出一个例子...

通常情况下,屏幕上有四个球,分别位于(50,25)、(70,32)、(16,18)、(98,73)。那么,这就是你的抽象(为了简单起见,此处进行了简化):

n = 4;
int[] { 50, 25, 70, 32, 16, 18, 98, 73 }

您"弹出"了第2个球,该球消失,您的int []变为:

n = 3
int[] { 50, 25, 98, 73, 16, 18, 98, 73 }

(注意我们甚至不用 "清除" 第四个球(98,73),我们只是跟踪剩余球的数量)。

遗憾的是,这需要手动跟踪对象。这是当前大多数在移动设备上运行的流畅Java游戏的做法。

现在对于字符串,以下是我的做法:

  • 在游戏初始化时,使用drawText(...)绘制数字0到9并保存在BufferedImage[10]数组中。
  • 在游戏初始化时,预先绘制一次"Your score is: "
  • 如果"Your score is: "真的需要重新绘制(因为它是透明的),那么从预存的BufferedImage重新绘制它
  • 循环计算得分的数字,并在"Your score is: "之后逐个手动添加每个数字(通过从您预存的BufferedImage[10]复制每个数字对应的时间)

这样可以让您兼顾两全:您可以重用drawtext(...)字体,并且在主循环期间没有创建任何对象(因为您避开了调用drawtext(...),后者本身可能会生成一些无用的东西)。

这种"零对象创建绘制得分"的另一个"优势"是,小心地为字体缓存和重用实际上不是"手动对象分配/释放",而只是小心的缓存。

这不是"干净的",也不是"好的做法",但这就是一流移动游戏(例如Uniwar)中的做法。

而且它快。真的很快。比涉及创建对象的任何事情都要快。

P.S:实际上,如果您仔细观察一些移动游戏,您会发现通常字体实际上不是系统/Java字体,而是针对每个游戏特别制作的像素完美字体(这里我只给您提供了如何缓存系统/Java字体的示例,但显然您也可以缓存/重用像素完美/位图字体)。


3
Android开发人员很幸运,图形代码在Skia本地库中而不是Java的轻量级框架中。结果是我们可以将各种变换和缓冲区甚至文本轻松地投射到屏幕上,无需过多担心内存和性能。问题在于当您需要为手动反走样或手动计算变换矩阵分配像素缓冲区时;我有一些手动跟踪对象的缓存。我认为字符串和StringBuffers需要相同的处理方式,下一步就是循环计算数字。感谢详细说明。 - Brian

15

虽然这是一个两年前的问题......

避免GC滞后的唯一且最好的方法是通过静态分配(包括启动时)将所有所需对象分配,以避免使用GC。预先创建所有所需对象,永远不要使它们被删除。使用对象池来重复使用现有对象。

无论如何,在对代码进行了所有可能的优化之后,您可能仍会出现偶尔的暂停。因为除了应用程序代码之外的任何其他内容都在内部创建GC对象,这些对象最终会成为垃圾。例如,Java基础库。即使使用简单的List类也可能会创建垃圾。(因此应该避免)调用任何Java API都可能创建垃圾。而当您使用Java时,这些分配是无法避免的。

另外,由于Java旨在利用GC,如果您真的尝试避免GC,那么您将会面临缺少功能的问题。(甚至List类也应该避免使用)因为它允许GC, 所有库可能使用GC,所以你实际上/事实上没有库可用。我认为在基于GC的语言中避免GC是一种疯狂的尝试。

最终,唯一可行的方法是降低到较低级别,在那里您可以完全控制内存。例如C家族语言(C,C ++等)。因此,请转至NDK。

注意

现在Google正在发布增量(并发?)GC,这可以大大减少暂停时间。无论如何,增量GC仅意味着将GC负载分布在时间上,因此如果分布不理想,则仍会看到偶尔的暂停。此外,由于更少的批次和分配操作开销的副作用,GC性能本身也将降低。


10

我构建了自己的无垃圾版本的 String.format,至少在某种程度上。你可以在这里找到它:http://pastebin.com/s6ZKa3mJ(请原谅德语评论)。

使用方式如下:

GFStringBuilder.format("Your score is: % and your name is %").eat(score).eat(name).result

所有数据都写入了一个char[]数组中。我必须手动实现从整数到字符串的转换(逐位处理),以摆脱所有垃圾。

除此之外,我尽可能使用SparseArray,因为所有Java数据结构(例如HashMapArrayList等)都必须使用装箱才能处理基本类型。每次将int装箱为Integer时,这个Integer对象都必须由GC清理。


2
你把你的版本放在GitHub上分享了吗?(还是你只是在吹嘘?) - Brian
1
请看这里...(http://pastebin.com/s6ZKa3mJ) 目前仅支持整数和字符串,没有格式选项,因为我到目前为止还没有需要。稍后会添加更多内容。请原谅德语中的注释 :) - David Scherfgen

4

如果您不想像建议的那样预渲染文本,drawText接受任何CharSequence,这意味着我们可以制作自己的智能实现:

final class PrefixedInt implements CharSequence {

    private final int prefixLen;
    private final StringBuilder buf;
    private int value; 

    public PrefixedInt(String prefix) {
        this.prefixLen = prefix.length();
        this.buf = new StringBuilder(prefix);
    }

    private boolean hasValue(){
        return buf.length() > prefixLen;
    }

    public void setValue(int value){
        if (hasValue() && this.value == value) 
            return; // no change
        this.value = value;
        buf.setLength(prefixLen);
        buf.append(value);
    }


    // TODO: Implement all CharSequence methods (including 
    // toString() for prudence) by delegating to buf 

}

// Usage:
private final PrefixedInt scoreText = new PrefixedInt("Your score is: ");
...
scoreText.setValue(Score.points);
canvas.drawText(scoreText, 0, scoreText.length(), x, y, paint);

现在绘制分数不会造成任何分配(除非在开始时buf的内部数组可能需要扩展一两次,以及drawText在做些什么)。


1
当您调用buf.append(value)时,它会导致String对象的分配。append方法通过String.valueOf(int)int值转换为String - branoholy
String.format(...)的性能如何?它只分配一个字符串吗? - peterk

3
在需要避免GC暂停的情况下,你可以使用一个技巧,即在你知道暂停不会影响的时刻故意触发GC。例如,在游戏结束时使用垃圾密集的“showScores”函数,玩家不会因为显示分数界面和开始下一局之间多出200毫秒的延迟而过度分心... 因此,在分数界面绘制完毕后,可以调用System.gc()
但是如果使用这个技巧,需要注意只在不会引起烦恼的时刻进行操作。同时,如果担心耗尽手机电池,也不要这样做。
在多用户或非交互应用程序中不要使用这种方式,因为这样做很可能会导致应用程序整体运行速度变慢。

这并不保证GC会在你调用它时运行?它可能会在10ms到1分钟后运行(显然无法确定确切的测量值)。但无论如何,这并不是一个很好的解决方案,因为你可能会调用GC运行,但它最终会在游戏开始后运行。 - While-E
12
你可以确定的是,当调用 System.gc() 方法后,要么发生了垃圾回收,要么没发生。也就是说,System.gc() 方法并不保证一定会执行任何操作,但如果它执行了,那么所有的操作都会在函数返回之前完成。(根据文档,“当方法调用返回时,Java虚拟机会尽力回收所有已经被丢弃对象所占用的空间。”)例如,它不会启动一个垃圾回收线程然后立即返回。 - cHao
1
@cHao:我之前没意识到这一点,非常感谢你对这个主题进行的启蒙! - While-E

2

关于迭代器分配,避免在ArrayList上使用迭代器很容易。相反,可以使用

for(GameObject gob : interactiveObjects)
    gob.onDraw(canvas);

你只需要这样做

for (int i = 0; i < interactiveObjects.size(); i++) {
    interactiveObjects.get(i).onDraw();
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接