2008年(8065)
分类: 服务器与存储
2008-11-25 16:24:26
在嵌入式系统设计过程中,Java程序员并不需要弄清楚哪些数据占用了哪些存储器或者应该在什么位置释放哪些存储空间,设计工程师只需要简单地分配对象而后由系统来释放这些资源。这样就可以完全消除悬浮指针错误,因而极大地减小存储空间浪费的可能性。由于并不需要一些显式的规则来指定这些存储空间的释放,因此简化了接口并且增强了软件复用。自动诊测并释放这些不再使用的对象的系统进程称为存储碎片收集(garbage collection)。
假如存储碎片收集真有这么好,人们也许会怀疑为什么在ANSI C++中不具备这样的功能。事实上,C++语言扩充版允许程序员将一些类型指定为“受控对象类型”,比如在Microsoft Visual C++的.NET平台中就能找到这种应用。这些受控对象在一定的条件下,可以实现自动存储碎片收集。问题的关键是存储碎片收集器必须能够找到所有的对象指针。由于C++允许将指针映射为其它类型,所以通常情况下无法追踪所有的指针。
关联存储碎片收集是对传统存储碎片收集算法的一个重大改进。基本的思路就是关注最新的对象。这是由于大多数对象都是很快产生而又很快消失,这些很快消失的对象集通常构成了绝大部分存储碎片。当然事情远比这要复杂。
弹性清除存储碎片
基于“标示和扫描”方式的传统存储碎片收集算法工作过程如下:当存储空间太低(下降到一个失效的新值)时,系统就会停止所有用户线程,定位无法访问的对象集合并且释放这些对象。要做到这一点就必须检查每一个对象索引,如从“根”开始的局部变量和静态类型域。检查每一个索引的对象确定其是否已经被标记过。如果没有,首先标记该对象并且对它所有的索引进行处理,否则就继续处理下一个索引。这一过程结束后,任何未被标示的对象都被认为是无法访问的对象,因而可以回收再利用其占用的存储空间。由于这种技术能够正确地检测被其它消失对象索引的成组消失对象,因而要比索引计数机制高明。图1和图2分别显示出这一对应过程前/后的图像。
当然存储空间在任何时候都有可能出现自由空间过低的情况,并且标示和扫描过程所需要的时间正比于对象以及索引的数量,因而就会出现应用程序可能周期性地出现不响应或者滞后短暂的时间间隔才开始响应的问题。这样的滞后在实时系统甚至是在“软”实时的系统中都是不能接受的。
有的设计师试图使用一种称为增量式存储碎片收集(incremental garbage collector)的技术来确保这种应用的滞后时间最小。简单地说,就是存储碎片收集器仅仅在一个固定的时间增量上运行,运行结束之后交给用户线程一个重新执行的机会。应用程序有更多时间运行之后,增量式存储碎片收集器将从原来停止的位置处继续工作。注意:当应用程序线程先占了存储碎片收集器线程后,有的存储碎片收集器必须从头开始重新运行,这样的存储碎片收集器就称为可以被抢先的,但不是增量式的。
可以抢先式以及增量式存储碎片收集器都声称可以减少滞后时间,但是这些算法效率的差异却很大。对抢先式存储碎片收集器来说,如果经常被抢先,那么收集器线程永远无法进一步执行,因而也就没有任何的用处。同样,如果存储碎片收集只有当某一个应用线程极度缺乏存储空间时才启动执行的话,那么至少该线程(如果不是完整的应用程序)必须滞后运行,因为该线程要在存储碎片收集线程释放存储空间之后才能继续运行。
收集器时间增量的长度也称为等待时间,它对于应用的响应同样也是十分关键的。取决于应用的实现时间增量,其范围大约从几秒到几十分之一毫秒。时间增量越小,应用的响应就越具有实时性。很显然,在应用的响应速度与存储空间收集器的工作能力之间存在一种工程折衷。
一个去碎片(defragmenting)的存储碎片收集器可以在存储器空间各处移动有效对象,并且将闲置的存储器空间合并成少数更大的存储器区块。如果不具备这种去碎片的能力,那么可用的存储器空间将被分隔成许多小的存储器小块,因而最终大对象的存储空间分配可能失败(特别是在长运行时间的程序中)。
由于系统越来越难找到足够大的自由存储器空间以满足应用程序的需要,因此存储器去碎片也会导致每一次的存储空间分配操作要花更长的时间。相反,在一个已经去碎片的存储器空间中,存储空间的分配要快得多。系统只需要增值一个自由指针,这并不比为一种功能调用而分配一个堆栈结构更困难。
去碎片存储碎片收集器通常将存储空间分为几个区域。一个区域的存储碎片收集完成之后,通常将该区域中的有效对象集中到其它区域中去。这一过程结束后,原来的区域会被清空,而且目标区域中也不存在任何间隙。当然这还需要附加存储器,然后才能消除存储器碎片。
关联存储碎片收集
关联存储碎片收集(generational garbage collection)的工作方式如下:许多存储器区域被指定为存放新产生对象的特殊场所。当这些对象存在的时间变长(存活的时间变长,在存储碎片收集期间依然存活),就会将它们从这一特殊区域转移出去并且放到主存储区域。某些关联存储碎片收集算法甚至还区分几个“较早的”代。.NET平台以及C#语言的存储碎片收集器可以区分三代,所以可以将第一代与存放新产生对象的区域分别开来。为了简单起见,介绍仅有两代的情况,多代的算法与此相似,只是管理操作方面的运算更多。
关联存储碎片收集仅仅考虑新产生对象存储区域,从本质上来说,它假定所有驻留在非新产生对象存储区域的对象仍然在被引用(也就是说它们仍然是有效的)。要做到这一点,就需要有一种方法来计算所有索引了新产生对象的集合。为了加速这一过程,需要维护一个独立的“老对象到新产生对象的索引”表,这一索引表列出了所有非新产生对象对于新产生对象的索引。这一表格极大地加速了新产生对象存储区域对象的检查,与此同时非新产生对象则根本不需要检查。
也许有人会关心:开始的时候如何追踪非新产生对象对新产生对象的索引?答案在于一种称为“写屏蔽”技术。写屏蔽监视所有的存储操作,并且不断查询“正在存储的对象索引了非新产生的对象吗”?如果是这样,接下来就会询问是否将正在被写的旧值以及/或正在被存储的新值进行了索引,并产生了新的对象。此外,还要对这一组老对象到新产生对象的索引进行相关的刷新。请注意:通过比较索引的地址与新产生对象存储区域的边界地址,存储碎片收集器可以迅速地判定一个索引是否指向了一个新产生的对象。
典型应用的实际测试表明:老对象到新产生对象的索引集合通常都比较小。测试也证实关联存储碎片收集可以线性地分配空间收集工作。也就是说,关联存储碎片收集在大约八分之一全部存储碎片收集的时间里可以收集到最新的第八个存储器空间。这将释放比八分之一存储碎片要多得多的空间。
图3显示了存储碎片收集之前的一种情况。新产生对象存储空间包含三个对象:C、D和E。对象C包含到对象E的索引。主要存储器区域包含两个对象:A和B。对象A包含到对象C的索引。这一个索引被记录在“老对象到新产生对象”的集合中。根索引集合包含到A和E的索引。这个实例显示了对新产生对象存储区域同时进行存储碎片收集和去碎片的情况。同时也显示了对新产生对象存储区域进行存储碎片收集并且直接收集到主存储空间中的情况。这样可以提升新产生对象存储区域中的所有对象。也就是说,这些对象下一轮的关联存储碎片收集过程中将不再被检查。如果算法支持几代关联存储碎片收集过程,那么就需要通过存储器去碎片技术整合到一个区域并且进行更高一代关联存储碎片收集。
在关联存储碎片收集过程中,根到A的索引将被忽略,但是根到E的索引将把E标示为有效,所以在目标区域中会创建E的一个副本,并且刷新C到E的索引。
E不包括任何索引,因而对E不作任何处理。老对象到新产生对象的集合也需要检查,在检查中会发现A到C的索引。C会被标示为有效,所以在目标区域中就会创建C的一个副本,并且刷新从A到C的索引。对象C包含一个到E的索引。由于E已经被复制,C中的索引被刷新,但是无需再复制E。而无法访问的对象D则不会被复制,因此该区域重新整理时D就会被删除。这样就会产生图4中显示出的结果。
增量式存储碎片收集
增量式存储碎片收集(incremental garbage collection)比关联存储碎片收集更加复杂。尽管现在有几种不同的实现方式都声称是增量式存储碎片收集,但事实上这是多年来悬而未决的问题。下面描述增量式存储碎片收集的一种特定的实现方式。
考虑在“来源”和“目的”区域上运行的一个去碎片存储碎片收集器(关联型或者非关联型)。随着存储碎片收集的进行,“来源”区域中的全部有效对象都会移到“目的”区域。复制过程由索引遍历来驱动。每一个索引都采取以下的处理方式:如果目标对象还没有被复制,那么就复制该目标对象,并且将其索引添加到要处理的索引列表中。最初的索引都要重新改写以反映对象的新位置,并且指向目标对象新位置的一个前向地址会保留在那个对象最初的“来源位置”处。当所有索引的对象都已经被复制,并且所有的索引都已经重新改写,那么存储碎片收集(那一个区域的)就结束,并且“来源”区域可以重新使用。
增量式存储碎片收集通常采取突发、短时运行方式。在这些突发的运行之间,允许用户线程运行,这样就可以减少等待时间。当然,在用户线程执行之前,存储碎片收集器并不能够保证对一个对象的所有索引都进行合适的重新改写。那么当索引仅仅修正了一半的时候,程序怎样才能准确无误地继续运行?答案同样也在于写屏蔽。
当程序试图对存储器进行写操作时,系统会进行检查,以确保被写入的对象是否正在被存储碎片收集器移走。如果是这样,那么写操作就会在旧的位置和新的位置同时进行。这样的技术避免了“读屏蔽”的必要性,同时也保证借助于没有被修正的索引,所作的修改不会被丢失。
聪明的读者会注意到:对象索引的集合在突发的存储碎片收集之间可能会改变。去掉索引应该没有什么坏处。存储碎片收集器已经标示出:一个对象是有效对象移去之后这个对象最后的索引,那么该对象肯定可以在下一次的存储碎片收集过程中收集起来。
然而必须小心处理索引的增加。存储碎片收集进程“正在处理”的过程中,每一次存储一个新的索引时,当存储碎片收集继续执行时,必须将该索引添加到需要处理的索引列表中。同样写屏蔽也要确保这一过程将工作正常。
在增量式存储碎片收集过程中,关于新对象的创建也有其它巧妙的细节。这些对象必须小心地分配在一个独立的区域中,存储碎片收集结束后,该区域将成为新产生对象的存储区域。如果能让这些新对象立即参与当前的存储碎片收集过程,那么它们就可以迅速升级。而采用其它方法,它们则会错过最开始的机会,并且没有机会存储在新产生对象的存储空间中,并通过关联存储碎片收集器来进行快速收集。
新的问题
通过上面所有的讨论,存储碎片收集听上去比实际情况更直观。系统执行应用程序代码需要花费的时间总量,以及执行存储碎片收集的时间总量之间存在某种精细的平衡。如果系统需要花费太多的时间去实施存储碎片收集,那么数据吞吐量就会受到影响。如果系统花费太多的时间去执行应用程序代码,那么存储碎片收集器就不能进一步运行下去,最终导致应用程序必须等待完整的存储碎片收集完成,所以最坏情况下,滞后问题可能得不到解决,当然这种情况极为稀少。
由于存储碎片收集器上的负载随应用行为而变化,因此仅仅调节存储碎片收集的静态参数不太可能达到较好的平衡。要切实解决这一问题,存储碎片收集子系统需要动态地监视其自身与应用的相对情况。应用分配了多少存储器?存储器从新产生对象存储区域到主要存储区域之间的升级有多快?存储碎片收集正在顺利进行还是已经滞后?如果已经滞后,那么应该怎么办?需要更加频繁地实施关联存储碎片收集或者将对象在新产生对象存储区域中保持更长的时间吗?在哪一个特定时刻必须实施一轮完整的存储碎片收集,以确保所有存储器在消耗光之前有时间来完成这一过程?
关键的一点是“协调步伐”以确保存储碎片收集器总是在应用程序之前,避免最终可能产生的所有滞后,并且获得真正的实时性和确定型的行为。当然,通常这都取决于应用行为良好这样的假定,也就是说,这些应用并不会非常快速地分配和释放存储空间,从而导致存储碎片收集器不能协调工作。在那种情况下,因为没有足够的存储空间可以使用,会导致存储碎片收集器需要花时间来释放存储器。需要多少缓冲器取决于存储碎片收集器的效率以及最坏情况下应用的行为。提高关联存储碎片收集的效率,就减少了需要获得实时性能所必须的缓冲器总数。
讨论如何达到这样的一种奇迹超出了本文的范畴。一个优秀的存储碎片收集器应该能够体现许多关于内部的信息。
存储碎片收集器的评价
在评价系统性能时,存储器使用以及存储碎片收集的开销是关键的一个统计量。许多存储碎片收集器都提供一种测量API来查询存储空间的创建、收集、以及从新产生对象存储空间到主要存储器空间升级的比率。跟踪应用随时间的行为的能力,对于进一步的性能调整很有价值。
对性能进一步调整的工具通常关注测量对象创建和撤销的比率。高明的程序员通常都知道如何通过重写代码打乱对象次序来极大地提高速度。我们有一个Java程序需要将几千个时间信息(长总数)转换为字符串。有一种标准的Java类型方法可以一步实现这种转换,但是在它内部创建(并且丢弃)了一个“日期格式化程序”对象。将根据步长重新替换高级运算,就能够得到一种准确的日期格式化程序对象。这样就节省了用于创建(以及存储碎片收集)几千个日期格式化程序对象所需要的时间。
有时候应用程序知道什么时候会被闲置,这是通知存储碎片收集器启动一个完整的存储碎片收集的最好时机。要做到这一点就需要利用一个控制API来影响存储碎片收集器的行为。然而,从前面的讨论中可以了解到,存储碎片收集器通常都比较清楚什么时候应该清理这些存储碎片。
本文小结
一般来说,存储碎片收集以及特殊情况下的关联存储碎片收集已经成为近年来人们不断研究的课题。关联存储碎片收集最先出现在桌面应用环境中。比如Sun的HotSpot JVM就是采用关联存储碎片收集。增量式存储碎片收集由于减少了等待时间,所以通常在嵌入式领域应用更多。HotSpot也可以进行增量式存储碎片收集,但是时间增量非常大,在桌面应用中存储碎片收集的进展被认为比等待时间更重要。
关联存储碎片收集在性能上提升了一个数量级。它极大地提高了增量式存储碎片收集的效率。嵌入式应用开发人员会发现,综合运用增量式存储碎片收集以及关联存储碎片收集会得到最好的存储碎片收集效果,也可以调整等待时间以适合响应时间的要求。