前面
本来是想在CPP相关的文里面讨论一下内存管理的事情,不过想了一想,还是放到编译相关的文章里面谈会更合适一些。
这次关于CG的讨论只关注一些基本原理以及算法,不涉及到并行GC等高级话题(水平不够Hold不住)。
正文
作用域(RAII的一个应用) 在C++中,我们知道一个对象的生存周期是与它的作用域有关的。例如:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #include <stdio.h> struct Info{ int a; Info(int va) :a(va) { printf ("Hello, Info %d.\n" , this ->a); } ~Info() { printf ("Bye, Info %d.\n" , this ->a); } }; int main (int argc, char ** argv) { { Info info (1 ) ; { Info i (2 ) ; } } return 0 ; }
这就在一定程度上给我们一个方案:用一个对象包裹分配的内存地址,当该对象出了它作用域之后自动调用free/delete,如下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 #include <stdio.h> struct Info{ int a; Info(int va) :a(va) { printf ("Hello, Info %d.\n" , this ->a); } ~Info() { printf ("Bye, Info %d.\n" , this ->a); } void say () { printf ("this is %d\n" , this ->a); } }; template <typename T>struct SmartPtr{ T* ptr; SmartPtr(T* p) :ptr(p) {} ~SmartPtr() { delete ptr; } }; int main (int argc, char ** argv) { { SmartPtr<Info> i1(new Info(1 )); { SmartPtr<Info> i2(new Info(2 )); i2.ptr->say(); } } return 0 ; }
这当然是不足以解决问题的——如果分配的内存希望能够在出了作用域之后继续存在怎么办?况且依旧无法对上面的代码注释中提到的情况作出应对。
引用计数 引用计数其实是很常见的,比如在操作系统中需要记录某内核对象的引用次数,只有当该对象的计数为0的时候才能销毁该对象;再比如COM对象的引用计数。示例:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 #include <stdio.h> class Info{ public : int a; Info(int va) :a(va) { printf ("Hello, Info %d.\n" , this ->a); } ~Info() { printf ("Bye, Info %d.\n" , this ->a); } void say () { printf ("this is %d\n" , this ->a); } }; template <typename T>class SmartPtr{ public : struct refStruct { T* ptr; int refc; }* sptr; SmartPtr() :sptr(0 ) { } SmartPtr(T* p) :sptr(0 ) { if (sptr == NULL ) { sptr = new refStruct; printf ("new refStruct here\n" ); sptr->ptr = p; sptr->refc = 1 ; } } ~SmartPtr() { if (sptr != NULL ) { --sptr->refc; if (sptr->refc <= 0 ) { delete this ->sptr->ptr; this ->sptr->ptr = NULL ; printf ("dead refStruct here\n" ); delete this ->sptr; this ->sptr = NULL ; } } } SmartPtr<T>& operator =(SmartPtr<T>& sp) { if (this != &sp) { this ->sptr = sp.sptr; this ->sptr->refc++; } return * this ; } int refCount () { if (this ->sptr) { return this ->sptr->refc; } } }; int main (int argc, char ** argv) { { SmartPtr<Info> i1(new Info(1 )); printf ("i1 ref count: %d\n" ,i1.refCount()); { SmartPtr<Info> i2(new Info(2 )); printf ("i2 ref count: %d\n" , i1.refCount()); SmartPtr<Info> i3; i3 = i1; printf ("i1 ref count: %d\n" , i1.refCount()); printf ("i3 ref count: %d\n" , i3.refCount()); } printf ("i1 ref count: %d\n" , i1.refCount()); } return 0 ; }
其实上面的例子也就是shared_ptr的原理了。注意到引用计数字段并没有直接放在Smartptr对象中——因为计数只与原始指针有关,一旦直接放入Smartptr对象中,赋值的时候就会产生副本,计数递减就只是对副本的操作,从而使得内存泄漏。
附上面代码的结果:
僵局:循环引用 仅从结果来看,引用计数工作的很好。
然而确实如此么?请注意这样一个事实:上面的对象是没有包含其他对象的指针的!
我们将代码改动一下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 #include <stdio.h> template <typename T>class SmartPtr{ public : struct refStruct { T* ptr; int refc; }* sptr; SmartPtr() :sptr(0 ) { } SmartPtr(T* p) :sptr(0 ) { if (sptr == NULL ) { sptr = new refStruct; printf ("new refStruct here\n" ); sptr->ptr = p; sptr->refc = 1 ; } } ~SmartPtr() { if (sptr != NULL ) { --sptr->refc; if (sptr->refc <= 0 ) { delete this ->sptr->ptr; this ->sptr->ptr = NULL ; printf ("dead refStruct here\n" ); delete this ->sptr; this ->sptr = NULL ; } } } SmartPtr<T>& operator =(const SmartPtr<T>& sp) { if (this != &sp) { this ->sptr = sp.sptr; this ->sptr->refc++; } return * this ; } int refCount () { if (this ->sptr) { return this ->sptr->refc; } } }; class Info{ public : int a; SmartPtr<Info> link; Info(int va) :a(va) { printf ("Hello, Info %d.\n" , this ->a); } ~Info() { printf ("Bye, Info %d.\n" , this ->a); } void Say () { printf ("this is %d\n" , this ->a); } void LinkTo (const SmartPtr<Info>& link) { this ->link = link; } }; int main (int argc, char ** argv) { int * r1 = NULL ; int * r2 = NULL ; { SmartPtr<Info> i1(new Info(1 )); r1 = &i1.sptr->refc; printf ("i1 ref count: %d\n" ,i1.refCount()); { SmartPtr<Info> i2(new Info(2 )); r2 = &i2.sptr->refc; printf ("i2 ref count: %d\n" , i2.refCount()); i1.sptr->ptr->LinkTo(i2); i2.sptr->ptr->LinkTo(i1); printf ("i1 ref count: %d\n" , i1.refCount()); printf ("i2 ref count: %d\n" , i2.refCount()); } printf ("i1 ref count: %d\n" , i1.refCount()); } printf ("Final: ref i1 = %d, ref i2 = %d\n" , * r1, * r2); return 0 ; }
附上上面的代码执行结果:
发现引用计数不再发挥效用了!!
图示会更加清楚:
注意,这里在Info对象中添加的link字段。实际上,该字段可以认为是与Java/C#/Python中的引用一致的东西。前面的文章里面有提到过引用和指针之间是不同的,在这里将会有实际的体会:引用的概念比指针要更加高级与抽象。
如何破解这个僵局呢?
破局 破局的要点还是在上面那张图中:link->连线->方向+节点->有向图!
如果我们能够在GC之前把回边砍掉,从而恢复正确的引用计数,那么就可以正确完成垃圾回收了!
这里说明一下,上面的那张图实际上不是很正确:为了与计数对应而删掉了有向边——事实上有向边是没有删掉的(因为计数的减少并非有向边去掉导致的)。
在图中找出一个回边并不是个难事——遍历扫描一下就可以了。发现回边之后更改边两端的节点的引用计数即可。
然而在对一个图进行扫描的时候需要知道起点,而很多时候,分配的内存相互引用形成的图并非是一个连通图,于是这就引出了另外一个概念:Root集合。
Root集合与Mark-Sweep 所谓Root集合,其实就是GC回收时,扫描开始的“点”。例如:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 SmartPtr<Info> g_i1(new Info(123 )); SmartPtr<Info> g_i2(new Info(456 )); void addLinkTo (SmartPtr<Info>& ptr, int num) { ptr.sptr->ptr->link = SmartPtr<Info>(new Info(num)); } int main (int argc, char ** argv) { { addLinkTo(g_i1,789 ); SmartPtr<Info> i1(new Info(1 )); { SmartPtr<Info> i2(new Info(2 )); addLinkTo(i1,11 ); } } addLinkTo(g_i1,1234 ); ... }
不难发现,Root集合一般而言,是由全局变量,静态变量,寄存器值,方法参数,或者是由程序入口所在域及其子域中的变量等构成的。
前面我们已经知道每次加一个Link,就可以在相应的节点上加上一条有向边,那么上面的代码可以对应下面的图,表号对应代码中的注释:
注意内容为789
的那个节点。这个节点是可以确定属于垃圾的。通过以上的描述,也不难发现Root集合应当包含的节点。
当我们根据Root集合进行扫描的时候,把可达节点做上标记(Mark)并收集起来,然后将不可达节点删除(Sweep)掉,就可以完成GC过程了——这就是Mark-Sweep算法的原理。
至于Root节点们,当它们出了自己的作用域之后必然将会被删除,到那时,剩下的所有节点都不再是可达节点,从而会被全部回收(当然对于全局变量,程序一旦结束,OS也会回收这些内存的)。
更进一步:三色标记模型 注意,截至到上一个部分,我们都是在对图的结构稳定情况下进行处理的,也就是要求当GC进行的时候,整个程序都应当停下来,不再对内存进行分配,直到GC过程处理完毕,也就是通常所说的“Stop The World”。
对于实时性要求不高的场合,这不失为一种有效的解决方法,然而对于交互式系统和实时系统这是无法接受的——至少,对于一个自动驾驶系统,如果该减速停车的时候来了一次GC,后果不堪设想。
这就需要另外一系列行之有效的办法来将这个时间碎片化,分散出去。引用计数可以在一定程度上做到这些,但还不够,于是,增量收集登场了。
在这个过程中,增量/并行算法将“无用垃圾信息收集穿插于程序执行过程中,避免对程序的长时间打断”。通常,由于应用程序本身总是在改变图的结构,因此将程序视为“改变器”;而GC模块,则视为“回收器”。
三色标记模型作为对Mark-Sweep的拓展补充,在增量标记中发挥了重要的作用。
在前面介绍LCA的文章 中已经对染色法有过一定的介绍了,这里依旧是分阶段进行染色的,不过略有不同。
对于未访问过的对象,标记为白色;
对于已经访问过,但是其子对象还没有访问过的对象,标记为灰色;
对于连子对象也访问过的对象,标记为黑色。
显然Sweep阶段完成之后,白色节点是属于不可达节点的,应当删除。但就上面描述而言,似乎没有什么特别的地方。而三色标记模型则明确了两个要点:
黑色节点是不能够直接指向白色节点的。
所有的灰色节点都处于一个待处理的数据结构中,也就是说,灰色节点属于一个中间态。
应该明确这样的一点:标记颜色的目的,是为了保留当前结点的状态 。
很显然,当改变器对图中的一个黑色结点进行更改的时候(注意,出现了黑色节点也就说明了Mark过程是正在进行(或者已经Sweep了)),例如进行了一个LinkTo的操作,这个操作使得黑色节点变成了灰色节点。这个更改必须被及时、高效地的通知给收集器,通常情况下,这需要使用到“写障碍”或者是“读障碍”,他们基本都是利用OS,对相应的内存加上“读/写”锁来完成的。
另一种流派,复制收集(Copying GC) 相对于Mark-Sweep而言,复制收集更加易于理解,但一样要进行一次扫描阶段。
复制收集的主要做法是首先为应用程序分配一个大内存,然后将其一分为两个Pool。
初始化阶段任选一块内存作为Pool,命名为SrcPool,另一个命名为DestPool,应用程序的所有内存申请均从SrcPool中划分。
当SrcPool中没有足够的可用空间时,触发GC。
GC的扫描阶段标记完毕可达节点。
GC的复制阶段将SrcPool中所有标记的可达节点复制到DestPool,复制完毕之后,交换两个Pool的引用。
剩下的就是以上过程的迭代了。很显然,两个Pool起到了内存池的作用,并且内存经过了整理压缩,一定程度上解决了内存碎片的问题,并提高内存页的命中率;但这种方式的一个不好的地方在于它至少要浪费一半的内存空间;更有甚者,如果采用DFS(无论是否显式栈),还是基于额外队列的BFS,都需要另外的空间。
由于Copying GC也是有着扫描这一过程,因此三色标记模型实际也是可以在这上面采用的。
Cheney算法 为了解决额外空间的问题,Cheney算法利用复制对象过程中的特点(对象复制过后是紧凑的),采用destPool中的scan到next中间部分的记录作为BFS的队列(scan与next是两个指针),从而解决了这一问题。
现在使用四个对象来简单说明一下这个算法:
首先是R对象作为Root被首先复制到DestPool中。R中保存了对象C与D的引用,这两个引用依旧指向SrcPool。
于是复制C和D到DestPool中,同时更改R中两个引用的指向,使其正确。
此时D中的引用仍旧指向SrcPool。
复制对象E到DestPool中,更改对象D中对E的引用的指向。
图中的S与N代表了scan指针与next指针。当scan最终等于next的时候,对该Root的处理结束。
引用与指针的区别再一次体现了出来:这里的引用包含了指向SrcPool的指针,被以此为标识来说明引用的对象是否还处于SrcPool中。
在编译课程中的GC就使用了该算法,当然实际过程会相对麻烦一些。
世代收集 分代的思想是基于这样的事实的:
总是有那么一部分内存是变化不大的;
当一块内存在一次或者多次GC中存活下来,那么它继续存活的几率会比较高;
新分配的对象总是会马上死亡,无法经过第二次GC。
于是可以按照“代”将堆划分出来,比如将堆为老中青三代:
新分配的对象总是从青年堆中分配,GC总是首先在青年堆中进行;
将青年堆中存活的对象复制到中年堆中;
当中年堆满的时候进行一次在中年堆上的GC,存活下来的对象复制到老年堆中;
当老年堆满的时候,进行一次老年队上的GC,此时可以用Mark-Sweep或者Copy-Compact(这是在当前堆上进行的,该方法类似于内存碎片整理)等方法进行回收,一般来讲,尽量还是在回收后将对象紧凑放置。
上面只是世代收集的思想,当然也可以分成更多世代,视设计而定。