.NET垃圾收集器的过去、现在和未来(一)
译者
程化
Patrick Dussud介绍:
Patrick Dussud在微软工作了11年,曾经负责VBA、Jscript、MS Java等语言运行时的垃圾收集器(Garbage Collector)的设计,目前负责.NET CLR垃圾收集器的设计。他是.NET CLR的架构师,WinFX的首席架构师,Windows架构师组的成员。
在微软之前,Patrick是德州仪器(TI)Explorer工作站系统的主要设计人,Lucid公司Energize产品的首席架构师。
Charles:好的,今天我们又回到了42号楼,采访对象是Patrick Dussud,垃圾收集器的创造者。Patrick,最近怎样?
Patrick:很好。
Charles:你还没有上过Channel 9,我们试图联系你已经有些日子了。开始的话题似乎应该是,什么是垃圾收集器?我们从这个最基本的地方开始,垃圾收集器负责什么?
Patrick:垃圾收集器使用户内存管理自动化。在以前的C++中,你必须用“malloc”或者“new”来分配内存,然后在适当的时候释放内存。你必须保证在释放之前内存没有被别人使用,如果你把内存给了别人,往往你就不确定应该何时释放内存了。当你释放了内存,不知道别人正在使用这块内存时,就产生了程序崩溃的问题。所以,当你显式进行“new”和“delete”时,内存管理是一个复杂的问题,并且,此时你的代码不可组合。要么你必须确定对自己的内存有完全的控制,因此,要达到这种完全隔离的目的,你必须在将内存传递给别的模块时进行完全拷贝,这样,别的模块就只对这个完全拷贝的内存负责。要么你就得在某个地方形成对整个内存池的统一的管理,这就是自动化内存管理,这就是垃圾收集器的工作。
垃圾收集器本质上就是负责跟踪所有对象被引用到的地方,关注对象不再被引用的情况,回收相应的内存,并且用高效率的方式来做这件事,很可能其效率甚至高于传统的“new”和“delete”范畴。事实上,我们试图超过“new”和“delete”,因为垃圾收集器给我们提供了新的机会,而你不会对新机会设置限制。举个例子,你必须知道每个对象在何处被引用,你必须确定每个对象是否真的被引用了。而一旦你做到了这一点,你会发现自己可以移动对象,压缩对象占用的内存空间,把对象在整个内存内搬来搬去,因为你知道对该对象的每处引用,你可以修改所有的引用。在C++中这是不可能的。如果我们除了使“delete”自动化外,还是象“new”和“delete”那样管理内存,我们一定会比“new”和“delete”慢,因为我们仅仅增加了额外的开销。但是,做了内存空间的智能压缩之后,我们发现自己的速度能够超过“new”和“delete”,因为我们能够保持非常紧凑,从而形成缓存本地化,页面本地化等等优势,因此,结果很好,尤其是对于非常难以管理的服务器内存来说更是如此。例如,对于服务器堆空间碎片化或者相似的问题来说,事实上,我们做得比过去任何尝试都要好。性能不会随着时间的过去而下降,我们得到了稳定的内存管理速度。
Charles:有趣。很多时候我们都听人说,“我愿意写非托管代码,我不愿意写托管代码,我可不愿意我的对象被别人控制”。很多C++程序员都这样想。
Patrick:是的,确实如此。这是对象的“微管理”问题。这个问题要靠经验甚至信念。当进行和“去除内存”有关系的操作的时候,大家对垃圾收集器感到最不放心。此时我们要和终止器,以及那些析构函数打交道。,在C++中,“析构函数在你进行delete时被调用”这点非常确定。对于我们来说,由垃圾收集器来关注对象消亡的事情,析构函数,其实就是终止器的调用时机由垃圾收集器决定。很多人对此非常吃惊。特别地,我们必须注意在析构函数中引用了哪些对象,因为当你析构若干对象的时候,这些对象的析构函数被调用的先后顺序是无法预先确定的。有可能你会先析构底层对象,然后才析构高层对象,如果高层对象析构时要对底层对象做点额外工作,就会失败,因为底层对象已经被析构了。当然,底层对象的内存还在,我们对于内存的管理很注意一致性,高层对象执行析构代码时想要访问的对象都可以访问到,只是这些对象的状态已经不能被析构函数改变了。这里必须要非常小心。
举个例子,你想用一个类层次实现文件系统,最底层的类封装操作系统的文件句柄,当文件句柄类不再被引用的时候,你想在析构函数中关闭操作系统句柄,从而避免泄漏资源。然后我们搭建高层。如果你做一个字处理器,往往会有好几层对象,所有的终止化操作层层递进,因为你往往想要先保存缓存内容,这样当最终关闭文件时,所有被缓存的内容都被自动写入了。顺带说一下,这并不是编写字处理器的好方法,正确的方法应该是显式关闭文件。对应用程序来说,第一步往往是区分对象的副作用与对象的生命周期。如果随着对象的消亡,有其他东西需要结束,你应该提供显式的方法。(就这个例子来说)当你调用这个显式的Close方法时,一切良好。但是,如果你忘记调用Close了,而你的对象已经没有被引用了,这个时候该怎样做?本质上来说,如果你的程序不能保证高层对象能够在清空缓存时一直向下处理到文件句柄,如果文件句柄先关闭了,很明显会出问题。我们就面对这个问题。我们用一个简单的办法来解决这个问题。我们有一种对象被称为“关键终止化对象”,它封装了widby(.NET 2)中的OS句柄类,它最后被终止化。当我们有一系列对象需要终止化时,关键终止化对象最后被终止化,从而直到高层干完工作前,它都可以看到文件句柄。在一般意义上,我们没有一个保证机制,因为我们不想因为终止化调用顺序问题引入复杂的对象关系图。一般说来,终止化代码没有调用顺序,我们的简单方案只是一个保险,以防程序员在对象销毁时没有正确地处理最后的副作用。事实上,调试模式下,我们许多的终止化代码中都有一句调用,说如果垃圾收集已经开始,而程序又进入到了这段终止化代码中,这就是个错误,我们抛出错误,开发人员负责修改这个错误。
Charles:很有趣。关键终止化对象的语义是什么?你们如何定义关键终止化对象?
Patrick:我们从关键句柄继承。这些东西内建在CLR里面。大家可以从关键句柄继承,但是只有系统级代码才有这种需求。
Charles:让我们谈谈CLR垃圾收集器的历史,比如,你当时面对的第一个挑战之类的……
Patrick:垃圾收集器的历史是,我写了微软绝大部分垃圾收集器。我们写的第一个产品级垃圾收集器现在还在用,那就是Jscript和VBScript的垃圾收集器。
当时我们聚了4个人,决定利用一些周末搞出Jscript来,因为我们觉得用Jscript进行网页编程很酷。很早之前,关于Perl的工具我们就有过争论,它对内存进行非常显式的管理,解释器会根据要求生成new和delete。我认为,“不,我们必须引入垃圾收集器,因为微管理会成本过高。”我的一个朋友说,“好的,我来写显式管理,你写垃圾收集器,我们看看谁的好。”我没有按时完成任务,我朋友完成得比我快,因为显式处理delete要好实现得多。然后我们开始运行他写的代码,但是发现代码的速度太慢了。他说,“好吧,我放弃了,我认为你的代码不会像我的这样慢。”然后我完成了垃圾收集器,最终放到了产品中。这个垃圾收集器非常简单,编程上很保守。我们并不知道对内存的所有引用之处,如果有个整数凑巧看起来很像某个对象的地址,我们就认为对象还活着。我们很保守,不会销毁所有能够销毁的对象,不会大量移动对象,因为如果有一个整数实际上指向某个对象,但我们不确定它是否是个指针,因为它看起来是个整数,那我们就不敢改变整数的内容,因为没准这是价格啊什么的。这个垃圾收集器非常有限,也不复杂。
然后,也是这群朋友一起开始了Java虚拟机(JVM)——微软Java虚拟机的研发。我为这个虚拟机写了另一个垃圾收集器。这个垃圾收集器继承自Jscript的垃圾收集器,也比较保守。在那个时候,所有的JVM都进行保守编程。然后,我咨询了另一个微软外的朋友,我们一起讨论,“如果我们想做一个Windows上最棒的垃圾收集器,我们应该怎样做?”于是,我们一起工作,写了一些规格说明书,然后我开始实现。有趣的是,我用的是LISP来实现,因为在那时,LISP有最好的调试工具,保护方面也很强,比如所有的数组都有边界检查。我们有非常好的调试器。我用LISP编写,然后用LISP写了一个JVM的模拟器,进行调试,然后写了一个转换器,把LISP代码自动转换成C++代码,那就是新的JVM垃圾收集器的基础。
Charles:写一个把LISP转换成C++的转换器对你来说是不是个挑战?
Patrick:不是,因为我原来在用LISP的公司工作。我曾经在德州仪器工作,开发TI Explorer。我写过一个转换器,把LISP的一种方言Zeta LISP转换成标准LISP。我们转换了所有300万行系统代码,全部自动转换,然后我们抛弃了老的方言。所以,我知道怎样做这个工作,这不麻烦。当我写LISP代码时,我很小心地只用那些方便转换到C++上的功能。所以,转换很直接,因为我有写LISP转换器的经验。
Charles:当然,CLR的垃圾收集器是用C++写的?
Patrick:是的,当我们从JVM前进到CLR时,我用了部分JVM垃圾收集器作为基础,然后进行了大幅优化。从我的观点来看,写一个好的垃圾收集器本质上是写一个坚固的,支持良好机制的基础。当你发现了一些能工作的机制后,在这个机制上你不想有太多变化,而机制之间必须足够正交化。如果你的架构良好,你就可以逐步往上加机制。在表层上,引入我称之为“政策”的东西。政策决定在哪些情况下使用何种机制。垃圾收集器的绝大部分速度和效率都来源于对政策的调整。当应用程序使用一般机制时,垃圾收集器会自动发现工作负载的增加,然后进行调整,基本上我们会把应用程序从非常无效的收集模式调整到更有效的收集模式中。年复一年,我们都在研究负载情况,如果某个负载看起来很糟,我们会问,“糟在何处?我们如何才能改善负载情况?”当我们找到方法后,我们就知道,啊,当发生这种情况时,应该使用这些机制,这样就能使负载好得多。于是,政策就会力求通过观察关联因素发现这些情况。我们观察所有代龄的收集频率,我们观察内存内部的碎片状况,我们观察内存占用,我们观察内部的记录,研究垃圾收集器内部哪些东西本来应该不太耗时,但是在特定条件下却耗时很多。我们观察所有这些开销和频率。从所有这些我们得到结论,喔,这种机制实际上没多大用,我们本来以为在尽量重用内存,但是,因为内存占用太多,我们做了一次完全的垃圾收集,但是,完全的垃圾收集却没有什么发现,所以,下一次OS告诉我们内存仍然过少的时候,我们最好不要再次对应用程序进行完全的垃圾收集,因为上次和这次之间没有发生什么,我们仍然不能从完全的垃圾收集中得到好处。这就是个动态调整如何进行的例子。事实上,垃圾收集器体现了我们对来自客户、内部、合作伙伴的许许多多工作负载进行深入观察的经验体会。我们努力找到关联因素,这些因素或者使应用程序表现良好——我们会试图重现这些因素;或者使应用程序表现恶劣——我们会试图将应用程序调整到更有效的状态。
Charles:有趣。我想问一个问题,什么定义了一个对象是否还活着?我们来谈谈对象的生命周期,以及为什么在像垃圾收集器这样一个非显式的环境中,开发人员不用明确指出对象的结束。这也正是以前的代码不可组合的原因。
Patrick:我们从头开始谈。如何表达拥有一个对象?我们有局部变量,此时我们说“object i = new object”,这里的“i”表示对象。这是一种对象来源。另一个来源是静态变量,讲起来更加复杂,不太有趣,但是道理一样,都是句柄,你可以创建自己的句柄。这就是执行引擎(EE)拥有对象的主要方式。显然,对象会拥有其他对象。这就是树图的开始。本质上,我们可以把一群对象看作树图,或者一系列的树图,这些树图的根要么是你栈上所有的变量,要么是你程序拥有的所有静态变量。这就是最初的树集。我们管这叫树集。在收集的时候,在EE和垃圾收集器模块之间有划分明确的协议。
Charles:EE是执行引擎吗?
Patrick:是的,就是CLR。当垃圾收集模块决定要开始收集的时候,它调用到EE中,请求停止所有的线程,这样才可以检查线程堆栈。EE照此办理,所有的栈被冻结。然后垃圾收集器告诉EE,现在你必须遍历所有的栈和静态变量,然后返回最初的树集。EE中有一个遍历模块负责这件事。然后,CLR每次用一个树调用垃圾收集器模块。垃圾收集器收到树后,将遍历编译器生成的静态数据,这些数据告诉我们对象实例的哪个偏移量对应着对其他对象的引用。我们挨个检查所有的引用位置,对每个位置进行递归检查。当退出递归过程的时候,树图中由这个根出发能够到达的各个树都被检查过了,这个根能够到达的所有地方都被标记了。我们用很多方法做标记,这个过程不太有趣。最终,我们能够说出是否可以到达某个对象,就是靠判断是否做了标记。基本的想法就是留点痕迹,拿着一个对象,你能说出它被标记了没有。我们或者在对象内部别人通常不太可能看到的地方写点东西,或者做一张外部表。我们两种方法都用,具体用哪种方法看具体情况下的效率。顺带说一句,工作代码并不按递归方式编写,因为你可能有一个非常、非常长的检查链,有可能会耗光栈空间。我们用数据栈,只记录需要检查的对象的引用。弹栈,检查里面的东西,将该对象的所有引用压栈,如此反复直到栈变空为止。栈变空意味着我们已经标记了这个根能够到达的所有对象。我们对所有的局部变量、保存着引用的寄存器、静态变量重复这个操作。一旦完成,我们就没有遗漏地标记了程序能够到达的每一个对象。此时,我们就能逐个对象地检查内存,发现它被标记了,好的,留下。没有被标记?喔,我们有一个垃圾了。特定的时候,我们会决定是否压缩所有的垃圾。这就是基本想法。重要的是我们称之为“完全的垃圾收集”的操作,因为我们检查所有的根能够到达的所有对象。我们也有办法只收集那些最近分配的对象,我们称之为“第0代”收集,此时垃圾收集器只检查那些最新分配的对象。因此,我们也要找到一个办法,保证如果较老的对象引用了这些新对象的话,我们可以知道。我们有办法很快地找到这些特殊的引用位置,不用在所有的对象中去遍历查找。
Charles:现在是很好的阐述“代龄”的意思的时候。对于垃圾收集器来说,这是垃圾收集器最近一次查找的垃圾?
Patrick:是的,那是最新的一代,我们叫它“第0代”。一般说来,你都会在这里找到大量的垃圾。它的局部性也很好,缓存中往往有刚刚创建的对象的引用,如果你幸运的话,大部分刚创建的对象都在缓存中,因此处理起来很快,进行压缩也很有效率。所以,如果当你处理刚刚创建的对象的时候,这些对象在缓存中,并且都过了生命期,你就碰到了最佳情况。实际情况很少有这样理想的,但这是你想要首先处理新对象的动力。政策引擎力图保证这个过程高效。比如,如果我们发现第0代没有垃圾,我们会说,“哎,也许我们不应该频繁收集,因为这次没有找到东西,浪费了时间”。反过来,如果找到了很多垃圾,我们会说,“嘿,太好了,让我们一会儿再来一次。”这是政策引擎力图保证高效运转的方法之一。
Charles:几年之前,关于“确定性终止化”有过一次大辩论,我曾经和C++开发组的一个程序员聊过“确定性终止化”,托管C++现在也有某种“确定性终止化”。对吗?毕竟C++中有析构函数。
Patrick:C++基本上处于混合世界中。如果对象被显式地创建和销毁,它们就不由垃圾收集器管理了,因此,它们需要“确定性终止化”。这些对象处于自己的世界中,即使将这些对象加上“__gc”前缀,试图指出它们是托管对象,垃圾收集器也帮不上太多忙。关于这个问题,我曾经用了近6个月的时间试图提供一个整合的解决方案。最后,我们花了些钱,请Chris Sells帮助我们解决了这个问题。他用的办法非常聪明,然而,通过测量发现,在中等强度的对象分配过程中,效率上的损失至少为2个基准点。所以,当垃圾收集器对应用程序作用很大的时候,你会付出效率上的损失。但是,在这点上我们不能强求程序员。我们的建议是:不要进行微管理,最终,通过这样或那样的方式,我们都会调用终止器,能够解决问题。垃圾管理器从整个内存角度出发考虑问题,试图使整个过程高效,而不只局限在某个特定部分。
Charles:我明白了。某种意义上这是一个通用的管理平台。但很有趣的是,既然这是通用平台,我为什么不能在托管代码中标记出某个对象说,我想要自己管理这个对象,我会告诉垃圾收集器这个对象何时生命结束,然后垃圾管理器才能收集它?你的意思是,垃圾管理器整体扫描,自行收集各个对象。
Patrick:是的。如果由你来告诉垃圾收集器,这并不安全,因为你可能把对象传给了程序,而你并不知道,这样一来,你就可能引入让程序崩溃的Bug。