前面  
本来是想在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(这是在当前堆上进行的,该方法类似于内存碎片整理)等方法进行回收,一般来讲,尽量还是在回收后将对象紧凑放置。 
 
上面只是世代收集的思想,当然也可以分成更多世代,视设计而定。