垃圾收集器没有像在 Android 应用程序中那样释放“垃圾内存”

Tal*_*dar 7 java performance android garbage-collection memory-leaks

你好!

我是一名初级 Java 和 Android 开发人员,最近在处理我的应用程序的内存管理方面遇到了麻烦。我将把这篇文章分成几个部分,以使其更清晰易读。


我的应用程序的简要说明

这是一个由几个阶段(级别)组成的游戏。每个阶段都有一个玩家的起点和一个出口,引导玩家进入下一个阶段。每个阶段都有自己的一套障碍。目前,当玩家到达最后阶段(我目前只创建了 4 个)时,他/她会自动回到第一阶段(1 级)。

一个名为GameObject(扩展Android.View)的抽象类定义了玩家和游戏中存在的所有其他对象(障碍物等)的基本结构和行为。所有对象(本质上是视图)都绘制在我创建的自定义视图中(扩展 FrameLayout)。游戏逻辑和游戏循环由一个侧线程(gameThread)处理。这些阶段是通过从 xml 文件中检索元数据来创建的。

问题

除了我的代码中所有可能的内存泄漏(我一直在努力寻找和解决所有这些问题)之外,还有一个与垃圾收集器发生相关的奇怪现象。我将使用图像,而不是用文字来描述它并冒着让您感到困惑的风险。孔子说:“千言万语”。好吧,在这种情况下,我刚刚让您免于阅读 150,000 个单词,因为我下面的 GIF 有 150 帧。

第一次加载时的第 1 阶段。 该应用程序的总内存分配为 85mb。 第二次加载时的第 1 阶段。 该应用程序的总内存分配为 130mb。 在我强制执行 2 次垃圾回收(使用 Android Profiler)后,内存回到 85mb。

描述:第一张图片代表我的应用程序在第一次加载“stage 1”时的内存使用情况。第二个图像 (GIF) 首先表示第二次加载“阶段 1”时我的应用程序的内存使用时间线(这种情况发生,如前所述,当玩家击败最后一个阶段时),然后是四个强制启动的垃圾收集由我。

您可能已经注意到,这两种情况在内存使用方面存在巨大差异(几乎 50MB)。第一次加载“第一阶段”时,当游戏开始时,应用程序使用 85MB 内存。第二次加载同一个stage的时候,稍晚一点,内存使用量已经是130MB了!这可能是由于我的一些糟糕的编码,因此我不在这里。你有没有注意到,在我强行执行了 2 次(实际上是 4 次,但只有前 2 次重要)垃圾收集之后,内存使用情况又回到了它的“正常状态”(与第一次加载舞台时的内存使用情况相同)?这就是我所说的奇怪现象

问题

如果垃圾收集器应该从不再被引用的内存对象中删除(或者至少只有弱引用),为什么你在上面看到的“垃圾内存”只有在我强行调用GC和不是关于GC的正常执行?我的意思是,如果我手动启动的垃圾收集可以删除这个“thrash”,那么正常的GC执行也可以删除它。为什么没有发生?

我什至尝试在切换阶段时调用System.gc(),但是,即使垃圾收集发生这种情况,这种“颠簸”内存不会像我手动执行GC那样被删除。我是否遗漏了一些关于垃圾收集器如何工作或 Android 如何实现它的重要信息?

最后的考虑

我花了几天时间搜索、研究和修改我的代码,但我找不到为什么会这样。StackOverflow 是我最后的选择。谢谢!

注意:我打算发布一些可能与我的应用程序源代码相关的部分,但由于问题已经太长了,我将在这里停止。如果您觉得需要检查某些代码,请告诉我,我将编辑此问题。

我已经读过:
如何在 Java 中强制垃圾收集?
Android 中的垃圾收集器
Java Garbage Collection Basics by Oracle
Android Memory Overview Android 中的内存
泄漏模式
避免 Android 中的内存泄漏
管理您的应用程序的内存
关于 Android 应用程序内存泄漏您需要了解的内容
使用 Memory Profiler
LeakCanary(内存) 查看 Java 堆和内存分配Android和Java的泄漏检测库)
Android内存泄漏和垃圾收集
通用Android垃圾收集
如何从内存中清除动态创建的视图?
引用如何在 Android 和 Java
Java 垃圾收集器中工作 - 不定期正常运行
android 中的垃圾收集(手动完成)
......还有更多我找不到了。

Ste*_*n C 7

垃圾收集比较复杂,不同平台实现方式不同。事实上,同一平台的不同版本实现垃圾收集的方式不同。(和更多 ... )

一个典型的现代收藏家是基于对大多数物品年轻时死去的观察;即它们在创建后很快就无法访问。然后将堆分成两个或多个“空间”;例如“年轻”空间和“旧”空间。

  • “年轻”空间是创建新对象的地方,它经常被收集。“年轻”的空间往往更小,“年轻”的收藏发生得很快。
  • “旧”空间是长期存在的对象结束的地方,它很少被收集。在“旧”空间收藏往往更贵。(出于各种原因。)
  • 在“新”空间中存活多次 GC 周期的对象将获得“终身使用权”;即它们被移动到“旧”空间。
  • 偶尔我们可能会发现需要同时收集新旧空间。这称为完整集合。完整的 GC 是最昂贵的,并且通常会在相对较长的时间内“停止世界”。

(还有各种其他巧妙而复杂的东西……我不会深入讨论。)


您的问题是为什么在您调用System.gc().

答案基本上是,这是做事的有效方式。

收集的真正目标不是一直释放尽可能多的内存。相反,目标是确保在需要时有足够的空闲内存,并以最小的 CPU 开销或最小的 GC 暂停来做到这一点。

因此,在正常操作中,GC 的行为将如上:频繁地进行“新”空间收集和不频繁地进行“旧”空间收集。并且集合将“根据需要”运行。

但是当您调用System.gc()JVM 时,通常会尝试取回尽可能多的内存。这意味着它会执行“完整的 gc”。

现在我想你说过需要几次System.gc()调用才能产生真正的不同,这可能与finalize方法或Reference对象或类似的使用有关。事实证明,Reference在主 GC 完成后,后台线程会处理可终结对象。对象实际上只是处于可以收集和删除之后的状态。所以需要另一个 GC 来最终摆脱它们。

最后,还有整体堆大小的问题。当堆太小时,大多数 VM 会从主机操作系统请求内存,但不愿意将其归还。Oracle 收集器会记录连续“完整”收集结束时的可用空间比率。如果在多次 GC 周期后可用空间比率“太高”,它们只会减小堆的整体大小。Oracle GC 采用这种方法的原因有很多:

  1. 当垃圾对象与非垃圾对象的比率很高时,典型的现代 GC 工作效率最高。因此,保持堆大有助于提高效率。

  2. 应用程序的内存需求很有可能再次增长。但是 GC 需要运行才能检测到。

  3. JVM 反复将内存返还给操作系统并重新请求它,这可能会破坏操作系统虚拟内存算法。

  4. 如果操作系统缺少内存资源,就会出现问题;例如 JVM:“我不需要这个内存。把它拿回来”,操作系统:“谢谢”,JVM:“哦……我又需要它了!”,操作系统:“不”,JVM:“OOME”。

假设 Android 收集器以相同的方式工作,这就是为什么您必须System.gc()多次运行才能缩小堆大小的另一种解释。


在开始向System.gc()代码添加调用之前,请阅读为什么调用 System.gc() 是不好的做法?.