转贴:.NET的自动内存管理(作者:蔡学镛)

自动内存管理也就是俗称的垃圾收集 (garbage collection,GC)。综观近年出现的语言,往往都具备 GC,也因此有越来越多程序员都已经在享用 GC 所带来的好处了,但是对于这个默默在背后运作的机制,大部分的程序员不太清楚它的原理。本文章以言简意赅的方式,为各位剖析了 .NET CLR 和 Rotor 的 GC。

Why GC ?

硬盘容量越来越大、内存容量也越来越大,大部份的原因在于:软件体积越来越大。几百 KB 内存就能打发一个程序的时代,已经不复存在。现在软件内部的组织方式看起来就像是许多对象彼此之间盘根错节,互相牵扯不清,所以这类软件的内部指针 (pointer) 管理相当复杂而为人所诟病,也因此容易导致内存管理不佳。

计算器科学家一直在寻找最佳的内存管理方式,每个人都有自己喜好的方式,长期以来争论不休。有一个老掉牙的笑话是这么说的:C 语言的程序员认为内存管理太重要了,所以不应该由系统来管理;Lisp 语言的程序员认为内存管理太重要了,所以不应该由程序员来管理。对同一件事,有同样的目的,看法却可以如此南辕北辙。

有人很反对 GC,他们认为只有程度不够的人才会将就着使用 GC,他们讥笑 GC 的效率不彰,认为 GC 会导致程序常常「呆住」好一阵子。

有人很赞成 GC,他们认为 GC 可以减低软件开发过程中的负担,虽然因此少了一些控制权,但是就像是蜘蛛人 (spiderman) 电影中所说的:「责任伴随着权力而来」。换言之,想要少负一些责任,就要放弃一些权利。

暂且不理会这些人的说法,而单纯地从市场的角度来看,GC 似乎越来越普及了,新一代的语言,诸如:Eiffel、Python、Ruby、Java、C#,都是支持 GC 的,究竟这样的趋势是怎么造成的?

GC 受欢迎其来有自。首先,GC 已非吴下阿蒙,经过这些年的演进,非但硬件效率提升可以让 GC 的接受度提高,连 GC 算法也更聪明、快速,更甚以往。况且,开发现代化软件是很复杂的事,程序员总是希望能把时间精力花在刀口上,系统能代劳的事,能别插手就别插手。毕竟,自行管理内存可不是一件易如反掌的小事,一个不小心还可能捅出大搂子。研究显示,C++ 项目有超过 50% 的开发时间在处理内存相关的议题。所以,在这个时代,多数应用软件的开发都有必要透过系统自动的 GC 机制。

GC 不只是给程序员带来方便,也让系统变得的可靠,即使遇到 bug 或恶意搞破坏的程序所导致的错误仍可回复。大多数的时候,.NET CLR 以及 JVM 都可以让程序员抛弃内存管理的重担,也因此不再有直接处理内存位置的必要,间接地,对于安全 (Security) 的提升也有帮助。

内存配置种类

内存配置方式可以概略地分成三种,分别是:

·                 静态配置 (static allocation):是最早出现的内存配置方式,将内存区域绑到 (bind) 某个名称,一直到程序结束为止。这类的内存,常被称为全域变量 (global variables)。

·                 堆栈配置 (stack allocation):堆栈配置使用在堆栈框 (stack frame) 中,内存配置的生命周期受到其区块范围 (block scope) 的影响,当进入区块时,会自动配置内存,当离开此区块,内存配置也随之消失。这类的内存,常被称为区域变量 (local variables)。

·                 动态配置 (dynamic allocation):动态配置可以随时配置,随时释放。配置当然是由程序员负责,但是释放则有不同的作法。对于 C/C++ 这一类传统的系统语言来说,释放仍是由程序员负责;对于Java 与 C# 这类 VM-based 现代语言来说,释放是由 VM (虚拟机)负责。这种用来进行动态配置的内存,称为 heap。请注意,这里 heap 和数据结构的 heap 没有任何关系。

上述三种方式,哪一种最好?简单地说,耗费的内存成本比越低越好,也就是说「越慢配置,越早释放」最好。堆栈配置的速度又快,配置的时间点又很晚,释放的时间点又很早,一切全自动所以程序员的负担又很轻,诸多优点使得堆栈配置成为最佳选择。祸福相倚,堆栈配置的内存也因此受到范围的影响,严重地限制了程序上的弹性。所以我们有时候必须使用动态配置。只有在堆栈配置与动态配置都不适合的情况下,才使用静态配置。

对象的内存究竟要如何配置,其实这方面深深受到对象生命期的影响。以.NET来说,绪 (thread) 以及应用域 (application domain) 等程序的控制结构 (control structure) 都有各自的方式来管理对象的生命期和内存:绪利用堆栈方式来储存数据,应用域利用静态方式来储存数据。储存的方式由这些机制所决定,然而组件生命期和资源配置必须与控制结构的生命期一致,但是有些情况下势必无法做到这一点。还好,除了堆栈和静态配置,我们还可以在heap进行动态配置,控制对象的生命期。

在动态配置的作法中,如果是由程序员控制内存的释放,会造成一些问题:

·                 应该释放的时候忘了释放,造成「内存漏失」(memory leak):程序执行时会一点一滴地把内存吃光。

·                 不应该释放的时候却释放了,造成「悬空指标」(dangling pointer)。一般咸认为,这是最糟糕的事。

在动态配置的作法中,如果是由 GC 负责控制内存的释放,就不会有这些问题了。下一节简单地介绍一个最常用的 GC 算法,并以一个范例以为说明。

标记、清扫、缩并

.NET CLR、Rotor 以及许多 Java VM 的 GC 都使用「标记、清扫、缩并」的算法。

GC 会追踪 (tracing) 所有活着的对象。那些不是活着的对象就是该被 GC 清除的垃圾。追踪活着对象的方式,就是到所有的静态配置、动态配置、以及堆栈配置的对象内找寻看看有那些指向heap的指标,每找到一个指标 (pointer),都要顺着再继续找下去,以找出更多的指标,一直到全部找过一遍为止,这就叫做沿着根指标追踪 (tracing the roots)。根指标的取得方式比较复杂,牵涉到 JIT Compiler 等 .NET CLR 的子系统 (sub-system),所以本文章不对此部分进行说明。

在追踪期间,活着的对象被标记起来,然后未用的内存就可以被扫除,这个方法就称为标记且清扫 (mark and sweep collection)。

标记且清扫的作法固然简单又有效,但是一段时间之后内存就会支离破碎,出现许多缝隙,容易导致内存不够用。为了解决此问题,我们可以采用缩并收集 (compacting collection) 的方式。最简单的缩并手法是:移除垃圾对象腾出空位之后,将下一个活着的对象往前搬移到此位置,紧邻着上一个活着的对象。对象只要有被搬移,就必须把所有参考到此对象的指针也更新成新的地址。如此的缩并收集等于是把所有可用的内存通通集中在一起,放在 heap 的后面,连带造成的正面效益是:后续建立的数个对象都可以集中在一起。如此的内存区域性 (locality),可以减少虚拟内存的 paging,对于执行效率很有帮助。

通常程序中会充斥着许多短命对象,当对象的「存活率」不高时,缩并收集尤其能发挥效益,因为搬移的次数少,且缩并出来的空间大。试想,当对象一口气全死光了,连一次的搬移都不必了。

为了帮助理解,下面以一个简单的范例作为说明:

1.      一开始 heap 是空的

2.      配置一个对象 A

3.      配置一个对象 B


图 1

4.      依序配置对象 C、D、E、F、G、H、I

5.      配置一个对象 J

6.      当准备配置一个对象 K 时,发现所耗费的内存已经超过段落限定的大小 (下一节会介绍何为段落),所以无法配置成功。必须开始进行 GC,然后才能配置 K。GC 的第一步骤是:假设一切都是垃圾。


图 2

7.      开始沿着根节点追踪

8.      追踪到根节点 A,A 是末稍节点,回到下一个根节点


图 3

9.      追踪到根节点 D

10.  透过根节点 D,追踪到 H


图 4

11.  透过 H,追踪到 J。J 是末稍节点,回到下一个根节点

12.  追踪到根节点 C


图 5

13.  透过根节点 C,追踪到 D。D 虽然非末稍节点,但是 D 已经被标记过了,所以 D 往下的节点都已经被标记过了。不用继续从 D 追踪下去,回到下一个根节点。

14.  追踪到根节点 F


图 6

15.  全都追踪完了,没被标记的对象就是垃圾,开始清扫。

16.  扫除 B,搬移 CD。由于 CD 被搬移,所以必须更新指向 CD 的根节点指标、并更新指向 D 的 C 指标。


图 7

17.  扫除 E,搬移 F。由于 F 被搬移,所以必须更新指向F的根节点指标。

18.  扫除 G,搬移 H。由于 H 被搬移,所以必须更新指向H的根节点指标、并更新指向 H 的 D 指标。


图 8

19.  扫除 I,搬移 J。由于 J 被搬移,所以必须更新指向 J 的 H 指标。

20.  更新 NextObjPtr 指标。


图 9

21.  终于完成了 GC。配置新对象 K。


图 10

缩并收集的另一种变形是复制收集 (copying collection) 作法是将活着的对象搬到一个全新的 heap,然后将旧的 heap 内的东西全都丢掉,变成下次的新 heap。

和只用一个 heap 的作法比起来,复制收集的方式有数个优点:因为所有的对象都会被复制到新的 heap,在配置新对象时变得很简单,不需要寻找最适合容纳的内存空位,直接就可以配置。而且,因为缩并到新的heap,可以具备不错的内存区域性。复制收集主要的缺点是:复制对象以及修改指针的代价太高,而且因为使用两个 heap,所以需要的内存空间比原来多出一倍。

世代收集

透过引进世代收集 (generational collection) 的技术,收集的成本是可以被有效地降低的 (或者说是分次摊还)。世代收集的方式将对象随着时间先后的次序分成多个世代,世代收集比起前述的各种方法都来得更复杂,但是已经被大多数的 GC 所采用了,因为它通常所耗费的时间比其其它方式更短 (毕竟不是整个 heap 都要被处理,不是全部的对象都需要被复制)。因为使用方式的差异,所以对象的生命长短不一,有些很长,有些则很短,而世代收集则利用了这一点特性。除了生命长短不同,对象的体积也不尽相同。将 heap 切割成不同的区域,不同区域放置不同特性的对象,不同的区域使用不同的频率来进行 GC,如此一来对于CPU 和内存的运用会更有效益。

heap 分成数个段落 (segment),从年老段落到年轻段落。活越久的对象,就会出现在越年老的段落中。段落的年龄次序不见得和地址次序相同,因为段落是视需要而取得的虚拟内存,而且 GC 算法也不需要世代之间保有位址次序。在 heap 加入新的区段之后,对象会被建立到此一新的区段。Rotor 和 .NET CLR 的作法总是将最新建立的段落当作最新的世代,一个世代只用一个段落。每个 heap 段落内的对象,可被视为相同年纪。Rotor 只使用两个世代,.NET CLR 则使用三个世代。

请注意:最老的对象「通常」可以在最低的地址处发现,但是不保证一定是如此,因为被钉住对象 (pinned objects) 会导致次序改变。所谓的被钉住对象,指的是有特殊理由而不能被搬移的对象。.NET 允许对象被钉住,但是 Java 则否。

如果纯粹采用世代收集的方法,对象一开始会被配置在最年轻的世代。经过一段时间之后依然存活的对象,会被升级到新的世代 (搬移到新的世代),这种技巧加诸于缩并收集可以带来很大的好处。年轻世代的存活率很低,年老世代的存活率很高,因为不同的世代被置于不同的位置,所以不同的世代可以采用不同的作法来进行 GC。年老世代不太适合使用压缩收集,因为存活率太高了,搬移的效益不高 (缝隙不大),但成本很高 (要搬移太多次)。但是,在最年轻的世代,缩并搬移的作法就相当适合。

Rotor 使用调适性世代 (adaptive generation) 的方式来进行 GC,它使用了两个世代,正因为两个世代,所以有时候也称为半空间 (semi-space)。除此之外,Rotor 也会特别隔离大型对象。当配置空间或内存满了,就会驱动 GC;当 heap 的空间所剩无几,就会沿着跟指标追踪,可以收集一个世代或同时收集两个世代的垃圾,可以视情况决定缩并与否。

为了帮助理解,下面以一个简单的范例来说明世代收集:

1.      程序执行的过程,产生了对象 ABCDE,它们归属于 0 世代。

2.      经过一段时间之后,CE 变成垃圾。对于 0 世代进行 GC 之后,只剩下 ABD,将 ABD 升级一个世代,成为 1世代。

3.      程序继续执行,产生了对象 FGHIJK,它们归属于 0世代。


图 11

4.      经过一段时间之后,HJ 变成垃圾。对于 0 世代进行 GC 之后,只剩下 FGIK,将 FGIK 升级一个世代,并入 1 世代。

5.      程序继续执行,B 变成垃圾,但未被清除,因为 B 是属于 1 世代。

6.      程序继续执行,产生了对象 LMNO,它们归属于 0 世代。经过一段时间之后,GLM 变成垃圾。现在的垃圾有 1 世代的 BG,以及 1 世代的 LM。


图 12

7.      对于 0 世代进行 GC,但是不对 1 世代进行 GC,因为 1 世代占用体积尚未达到世代限制。GC 之后,0 世代只剩下 NO,将 NO 升级一个世代,并入 1 世代。

8.      程序继续执行,产生了对象 PQRS,随后 0 世代的 ABGK 以及 1 世代的 PR 变成垃圾。

9.      由于 0 世代和 1 世代的体积都已经达到各自的世代限制,所以同时对两个世代进行 GC。1 世代剩下 DFINO,升级为 2 世代。0 世代剩下 QS,升级为 1 世代。


图 13

外部资源管理

事实上,Java 和 C# 程序员就算不懂 GC 的内部作法,影响并不大。程序员只要注意下面两件事,即可在大多数的情况下让 GC 发挥效率:

·                 不用的指标要及早设定为 null,以在下一次的 GC 中被清除。

·                 许多程序员制造了大量的短命对象,仍然浑然未觉。例如:Java 和 C# 的「字符串加法」,C# 语法允许自动地 box (也就是自动地把 value type 转成 reference type)。

倒也不是说程序员就可以因此高枕无忧。GC 可以让程序员免于注意内存管理细节,但是 GC 的触角并未伸及外部资源管理,毕竟外部资源的生命期超出 VM 的掌控,而是由 OS 来掌控。甚至,GC 的存在,会使得资源释放的问题益形复杂。所以,对于外部资源管理,程序员必须戒慎恐惧。

这里所谓的外部资源包括了档案、窗口代号 (window handle)、网络 socket……等。从程序员的观点来看,那些代表外部资源,或者使用外部资源的类别,必须要能取得或释放资源。以档案为例,用来代表外部档案资源的类别,必须能取得其档案代号 (file handle),必须能呼叫 close() 来关闭档案。资源的取得很简单,只要写在 constructor 内就行了;资源的释放却很麻烦,因为在 GC 插手的情况下,程序员不能主动去释放对象。

但是无论如何,系统还是得提供一个方法让我们释放外部资源,而对于 .NET 和 Java 来说,清除资源的过程称为终结 (finalization)。简单地说,终结就是:VM 呼叫对象的某 method 来释放外部资源。在 VM 发现对象变成垃圾之后,且对象内存被释放之前,这个空档正是对象被终结的好时机。

.NET 有个名为 Finalize() 的 ethod (Java 也有类似的 method),不需要传入参数和传出值,它做的正是终结的事。当 .NET CLR GC 运作时,如果发现某对象有提供 Finalize(),GC 就会呼叫它。被标示为垃圾,且必须被 CLR 终结的对象,会被纪录在终结队列 (finalization queue) 中。

请注意:不是所有的对象都必须被终结,只有那些有提供 Finalize() 来释放外部资源者,才需要被终结。如果没有外部资源,就不要提供 Finalize(),就不会被终结。当可终结的对象一被建立,立刻会被纪录在一个弱参考清单 (weak reference list) 中。GC 会监视着此清单,当所有指向某「可终结对象」的强参考 (strong reference) 被释放,GC 就会立刻将此对象的参考从弱参考清单中转送到终结队列 (finalization queue),对象持续活着。终结绪 (finalization thread) 每隔一阵子会一一造访队列中的对象,然后呼叫每个对象的终结方法 (finalization method)。如果在终结的过程中,该对象没有再度被强参考所参考到,此参考于是终于被释放,而对象也会依正常方式回收。

请注意:终结不是一个万无一失的作法,在许多情况下,程序员必须小心配合,以达到最佳的资源管理。GC 发生的时间是由算法和系统的负荷所决定,也因此,有时候我们有需要改用传统的处置方式 (disposal pattern),而不要完全依赖刚刚所提的终结机制。也就是说,程序员需要明白地呼叫 Dispose() 或 Close() 来「关闭」资源。

提醒各位,不管是 Java 或 .NET,在终结时,都有可能造成对象「死而复生」。这个议题很复杂,感兴趣的读者可以阅读 Weak Reference 相关的数据。

结论

除了上述的 GC 之外,Java 和 .NET 其实还有另一个全然不同的 GC,用来管理分布式运算 (distributed computing) 的对象生命周期,但是这不在本文讨论范围。

另外,执行时 JIT 编译所产生原生码 (native code),也会占用内存,毕竟新产生的原生码总得放在某个地方吧!尽管 metadata 必须一直处于随时可用的状态,method 却不然,method 可以随着需要而编译产生。每次需要执行某 method,就编译一次,却不管先前编译过与否,这会降低整体执行效率,但可以节省内存 (因为不需要储存先前的编译结果),且可能会有较佳的地域性 (locality)。

Rotor 所使用的 JIT 编译器在这两种作法之间取得折衷点,这就叫做程序代码丢弃 (code pitching),也算是 GC 的一种形式,只不过回收的不是数据,而是程序。当编译出来的程序代码超过 code heap 的最大容量,缓冲区内的全部内容就会被丢弃 (pitch),而堆栈内的全部返回位址都被改成 JIT 编译器的地址,这使得后续的所有 method 都会被重新编译。作法同 GC 一样,什么 method 的程序代码该被丢弃,考虑因素有许多,例如:原生码的体积、IL 转成原生码所膨胀的比率,或者需要耗费 JIT 的时间…等,这些都可以作为考虑。

不管是本地对象的 GC,远程对象的 GC,还是原生码的 GC,都是很复杂的主题。GC 固然复杂,但是这一切都是值得的,对于程序的可靠度和生产力提升都有帮助。如果你对此主题感兴趣,你可以去研究 Rotor和 JVM 的 Source Code,以及 Richard Jones 所著的 Garbage Collection: Algorithms for Automatic Dynamic Memory Management 一书。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值