Flash 务实主义(五)——AS3 的垃圾回收

  • flashyiyi

2011 年 5 月 5 日

话题:Java.NETRuby语言 & 开发

GC 和内存泄露无关

垃圾回收,这次是一个被无数人讨论过的传统话题。

Action Script 使用的是和 Java 相似的内存管理机制,并不会即时回收废弃对象的内存,而是在特定时间统一执行一次 GC(Gabage Collection)操作来释放废弃对象的内存,避免了重复判断是否需要回收产生的性能问题。

但要注意,这只是决定回收的时机,而不是回收的内容。这个延迟执行内存回收也就是个表面的现象,不管什么时候执行 GC,能够回收的内存最终都能回收,不能回收的肯定不能回收。唯一的影响是,因为回收是延迟执行的,你在查看内存的时候不能直观地看到因为一个对象被废弃而回收内存的过程,会产生迷惑。

但这对于解决内存泄露是无关紧要的。

内存泄露指的就是当你销毁了一个对象的时候,它占用的内存却无法被回收,这会导致可用内存越来越小最终溢出,在内存紧张的环境中将会造成系统崩溃。其原因多种多样,但一般都是开发者的疏忽所致,没有提供给系统足够的可以销毁对象的依据。

执行 GC 虽然和内存泄露没有关系,但是如果不在测试前执行 GC,你将看不到当时实际的不可回收内存的量,而内存泄露就是指不可回收内存的数量的增加。因此,测试内存回收将离不开 GC 方法。没有使用 GC 方法的测试用例是没有意义的,因为这其中掺杂了偶然性(什么时候执行 GC)。不少荒谬的测试结果都是因为没有在正确的位置执行 GC 导致的。

Flash Player 虽然没有开放发布状态的手动 gc,但调试版本是可以使用的,正好可以让我们测试。此外下面的 HACK 代码也可以在发布阶段触发 GC。

try {
    new LocalConnection ().connect ( "gc" );
    new LocalConnection ().connect ( "gc" );
} catch ( e:Error ) {}

但我再次强调,调用 GC 仅仅是用于测试。实际产品中调用 GC 基本没有意义(除了用于控制 GC 时机),总之如果你的程序出现了内存泄露,那一定和 GC 没有关系,请不要再在这种地方浪费宝贵的时间与精力。

只有在申请内存时才会触发自动 GC

AVM2 的 GC 是在每次申请内存时,根据当前内存占用来触发的。申请内存是一个必要因素。所以,如果你一直不进行申请内存的操作,就算内存达到了一个高值,它也不会进行 GC。

这确实是个不合理的地方。但是,在实际环境中,一直不请求内存的情况是很少见的,就算出现,当时也未必处于内存的高值。这种情况主要出现在测试环境中,导致一些人会怀疑自动 GC 的功能是否正常。实际上这也是没有必要的。

Flash 中垃圾回收的条件

在 AVM2 中,除去特殊的 BitmapData 必须调用 dispose 才能回收内存外,其他的部分都是用引用计数法和标记清除法作为判断是否应该回收内存的手段,而且并没有提供主动回收的 API,详细部分请看这篇日志,我就不重复了。

http://www.cnblogs.com/cos2004/archive/2010/11/07/1870980.html

因此,你要回收一个对象,只要保证没有任何对象引用它,而且他的方法没有被当做事件函数——或者说,他和程序的其他部分已经没有任何联系,它就满足了引用计数法的标准,就一定会被回收。做到这一点的方法就是一般说的“执行 removeChild,removeEventListener,将对他的引用设置为 null”。

但是,实际上回收一个对象的要求并没有那样严格,就在于 FP 除了引用计数法,还包括标记清除法。标记清除法是从程序的根对象开始(stage, 静态属性, 活动的定时器和加载器,ExternalInface.callBack)一级一级遍历对象,只要遍历不到,即使不满足引用计数法的条件也可以回收。比如两个对象互相引用,但是和外界都没有关系,形成了孤岛,它们就可以被回收,尽管它们因为互相引用使得引用数不为 0。比起单纯的引用计数,这种办法能确实能找到已经无法再访问到的实际上的闲置对象。所以,可以看到很多人的代码实际上并没有设置 null,甚至没有 removeEventListener,它一样可以被正常回收,少写这些代码可以使得程序更简洁,要全部符合标记清除法的条件,会很累。

“无法被根访问”,这种说法很暧昧,基本不能当做判断依据。所以我下面会举几个具体例子,来说明什么样的情况是符合标记清除法的要求的。

首先明确一点,标记清除法是只以能否能被根访问作为唯一依据的,并不需要关注被引用的次数,请不要混淆。

  • 属性的相互引用是很明确的,一般都是一个对象包含着若干属性,那么这个对象自然可以维持它的属性的引用。如果这个类不会被回收 (能够被根访问),他的所有属性也都不会被回收。同样的,如果这个类可以被回收的话(不能被根访问),也就不会妨碍属性的回收。所以你并不需要将所有属性设置为 null,除非你希望在对象存在时候就回收其属性的内存,这种需求基本不存在。
  • 静态属性是一个特殊的情况。静态属性本身就是根,所以你必须将其设置 null 才有可能被回收,没有别的办法。
  • 至于在显示列表中的对象。既然根 (stage) 可以用 getChildAt 访问到自己的所有子对象,那么只要你在显示列表中,就肯定不会被回收。然而,如果显示对象的父层对象已经不再显示列表内,它的子对象就算还在父层对象之中也没有关系,因为它已经不能被 stage 访问到了。所以你不需要 removeChild 各层的全部对象,而只需要 removeChild 最高一层的父对象即可。
  • A.addEventListener(“event”,B.handler),像这样添加过事件后,你可以认为 B.handler 成为了 A 的一个属性(因为 A 在需要的时候要能调用 B.handler),这里也符合属性相互引用的原则。但是事件判断起来的确要比属性麻烦,因为相互引用的情况很多。在这里可以分为三种情况:
    1. 对自己监听自己的事件,这相当于用自己的属性保存自己引用,任何情况都不会阻碍自己被回收。
    2. 对自己的子对象(属性或者 child)监听自己的事件。因为子对象本来就是自己在维持它的引用,那么即使它们会维持你的引用,也只会形成一个循环。一旦你和 stage 脱离了联系,子对象同样也会脱离联系,当然也无法妨碍你自己被回收了。除非子对象因为一些原因可以单独维持引用(诸如被保存在静态属性中),但这种情况很少见。
    3. 对自己的父对象 (parent 或者 stage) 监听自己的事件。因为这使得你成为了父对象的一个属性,只要 parent 或者 stage 不被回收,那么自己就不会被回收。尤其是 stage,它肯定不会被回收。这种情况一般都会导致自己无法回收,是必须 removeEventListener 的。

总得来说,就是务必注意对 stage,parent 的事件监听,其他情况一般都是不会妨碍回收的。而对 stage,parent 的监听大多都是各种鼠标,键盘事件。数量并不多,专门注意这里可以杜绝大部分因为事件造成的内存泄露。

其实,内存泄露并不容易出现。按照普通的编程习惯,只有监听 stage 事件这种做法会造成意料之外的泄露,一般都是可以顺利回收的。这比每次都要手工回收内存要方便多了。

这里只有 BitmapData 是例外。除了遵从上面的规则外,要回收它的内存,必须手动调用 dispose 方法,习惯自动回收的人会很累。务必注意,Bitmap 对象的 bitmapData 属性是需要手动销毁的,Loader 加载的位图是需要手动销毁的,当你用一个生成的位图作为位图填充绘制平铺的图像后,在销毁这个图像后也必须销毁这个位图(所以你必须一直保存位图的引用)。BitmapData 是 32 位的未经任何压缩的图像,随便一个体积都会非常大,不处理好它们的回收,一个 BitmapData 泄露就可以顶你数万个复杂对象的泄露。

如果出现非常明显的内存泄露,大部分时候都是位图泄露。所以在研究上面的引用计数法和标记清除法以及 GC 之前,请先保证位图部分不出问题。

弱引用时的例外

弱引用会改变垃圾回收的规则。如果使用了弱引用,addEventListener 将不会影响对象回收,即使对 stage 添加监听,也不会导致自己被回收。但是这同时也是缺点,因为有的时候你就是希望用引用限制住对象的回收,使用弱引用会使得这个对象有时回收有时不回收。虽然极少出现,但一旦出现,这种不容易重现的错误是很难查出来的。因此我并不推荐使用弱引用。

弱引用在 AVM2 中只有两处:

  • 一处是 addEventListener 的第 5 个属性,名为 userWeakReference,设置为 true,监听事件将不会影响对象回收。
  • 一处是 Dictionary 的构造函数参数,名为 weakKeys,设置为 true,当键为复杂对象时,即使 Dictionary 存在,键依然可以被回收。注意,这里说的是键,不是值,值是不享受弱引用待遇的。这个属性也写得也很明白,是 weakKeys。

内存泄露的查找方法

Flash Builder 提供了一个概要分析工具,可以帮助我们查找内存泄露。大多数情况都可以帮助我们解决问题。可以查看下面的文章:

http://blog.csdn.net/bbmjfpig/archive/2010/12/30/6107347.aspx

关键点在于,检测内存泄漏应该是“创建,取样,销毁,再创建,取样”,然后以两次取样的对比数据来观察泄露。因为对象在第一次创建时会有一些缓存数据,它们在设计上就不会随着对象销毁而回收的,比如类定义的缓存,比如皮肤。它们只会创建一次,和我们看到的泄露并不是一回事。

必要时可以执行强制 GC

因为每次 GC 都需要消耗性能,对象越多,GC 越慢。我理解 Flash Player 禁用发布版本的 System.gc() 是为了避免开发者滥用这个方法,但有些时候我们的确需要手动控制 GC 时机,因为 GC 过程如果遇到大量可回收对象会让 Flash Player 卡住。

比如,我们需要在切换屏幕时回收一次内存,这时候卡是看不出来的,而不是切换完后播放动画时回收然后让动画顿住。或者,我们会定期在必要的时候执行一次 GC,将 GC 需要的时间分担开。所以这时候用 HACK 方法强制执行一次 GC 也不失为一个选择。当然,这和内存泄露半点关系都没有。

Flash Player 这个地方的设计特别的不好。它自己又不支持分步 GC,一旦 GC 的时候没有办法避免卡的问题。结果 GC 的时机还不给控制……

微量剩余内存

测试中 FLASH 的确存在微量内存无限增加的问题,原因未知。我将 50 万个对象扔在一个数组中,销毁后确实会多出 1M 的内存占用(如果没扔在数组中不会),但这个数量很小,但达到能看得出来的 100M 内存需要 5000 万个对象,这个数额在通常情况下很难达到。

不过也有人说这只是对象销毁而内存并未全部释放的表现,实际上最后还是能完全释放的。或者是因为 totalMemory 的不精确所造成的。这个我就不清楚了。

不过就算这个的确是 FlashPlayer 的 BUG,也无伤大雅吧。

Java.NETRuby语言 & 开发