C++与C#内存管理对比分析 (转载)
C#最大的一个改进其实就是对内存访问与管理方法的改进。在.NET中内存的管理是全权委托给垃圾回收器,由垃圾回收器来决定何时该释放内存空间。现在普遍采用两种技术来释放程序动态申请的系统内存:首先是以C++为代表的必须以手工方式使应用程序代码完成这些工作,让对象维护引用计数。然后是以.NET以及Java使用的垃圾回收器来完成内存释放工作。
在C++中让应用程序代码负责释放内存是低级、高性能的语言使用技术。这种技术非常有效,且可以让资源在不需要时就释放,因为这种技术可以直接访问内存,所以其最大的缺点是可能导致错误。而且如果程序员的记性不太好的话,也会常常忘记释放内存而导致内存泄漏。
在C#中内存的管理是依靠垃圾回收器,垃圾回收器是一个清理内存的程序。所有采用new关键字申请的动态内存空间都会分配到堆上,当.NET检测到给定过程的堆已经满时,需要清理时,就会调用垃圾回收器。垃圾回收器将采用垃圾回收算法将那些不再被引用的对象所占用的内存空间释放掉。显然由于程序员无法直接控制内存的释放,所开发出的软件性能和效率上一定会受到很大的影响。不过这种影响是随着计算机硬件技术的发展日益缩小的。
究竟是C++中直接由程序员管理内存好,还是像.NET中那样由单独一个程序来统一管理好呢?这个问题是公说公有理,婆说婆有理。但是我相信随着计算机硬件技术不断的发展、存储器空间越来越大、软件的复杂性和软件健壮性要求的不断提高,程序员直接管理内存的方式必将会退出历史舞台。当今的程序员不必再为该如何把程序分块放到容量有限的内存中运行而担心,因为这项任务已经交给了操作系统的虚拟内存来管理。相信不久将来人们也会习惯完全交由诸如垃圾回收器一类的专门程序来管理程序申请的内存空间。
在C++中内存的分配方式大致有三种:
(1) 从静态存储区域分配。内存在程序编译的时候已经分配完毕了。并且这块内存中的所有数据在程序的整个运行期间都始终存在的。例如:全局变量,static变量等等。
(2) 在栈上创建。在函数执行期间,无论什么时候到达一个特殊的执行点(左花括号)时,存储单元都可以在栈上被创建。出了执行点(右花括号),这个存储单元自动被释放。这些栈分配运算内置在处理器的指令集中,非常有效,并且不存在失败的危险,但是可供分配的内存容量很有限。
(3) 存储单元也可以从一块称为堆(也被称为自由存储单元)的地方分配,从堆(heap)上分配,亦成为动态分配。在C++中,程序在运行期间可以用malloc或者new申请任意数量的内存,程序员自己掌握释放内存的适当时机(使用free或者delete)。动态内存的生存期间是由程序员决定,使用非常灵活,但也最容易产生问题。
在C++中,我们必须非常小心第三种内存分配方式,因为内存的分配和释放都得由程序员来控制,一不小心就会出错。下面我就分析下在C++中,由于第三种内存分配方式而导致的一些常见的内存泄漏以及一系列的指针问题。
#include<iostream.h>
#include<string.h>
void GetMemory(char *p, int num)
{
p = new char[12];
}
void main()
{
char *str = NULL;
GetMemory(str,100);
strcpy(str,"hello");
}
注意到函数GetMemory(char *p,int num),中的第一个字符型指针参数。写程序的人的本意可能是希望通过此函数为str指针申请内存。但事实上却是str并不会得到所期望得到的内存,str依旧是NULL。因为函数GetMemory(char *p,int num)中所得到的只是指针str的一个副本使得p = str ,他们所存储的内容均是指向同一个内存的地址,但是由于p申请了新的内存,但str指针的值并没被改变,所以函数GetMemory并不能得到任何有用的东西。并且由于每执行一次GetMemory就会泄漏一块内存,因为没有使用free释放内存。
#include<iostream>
using namespace std;
class X
{
public:
int *ptrArray;
int size;
X(int *ptr , int size)
{
ptrArray = new int[size]; //(A)
for(int i=0;i<size;i++)ptrArray[i]=ptr[i];
}
};
int main()
{
int arrayData[100]={0};
X *bill = new X(arrayData,100); //(B)
delete bill; //(C)
return 0;
}
例如上面程序中的X类,程序员忘记了编写析构函数来释放在类中所动态申请内存空间。注意到程序第B行代码处,声明了一个X类的对象指针bill。然后在代码第A行处代码动态申请了一段内存空间,并且把数组arrayData中的数值都复制到类中。
接着在代码第C行处,我们删除了指针bill所指向的内存空间。但是类中第A行所申请的内存空间并不会被删除,所以将造成内存泄漏。
#include<iostream.h>
#include<string.h>
void main()
{
char *pointer = new char[100];
strcpy(pointer,"Hello , I am Howells !");
cout<<pointer<<endl;
cout<<"Before call delete function, the address of pointer is : "<<&pointer<<endl;
delete pointer; //(A)
cout<<"After call delete function, the address of pointer is : "<<&pointer<<endl;
//其间程序非常长,程序员也许忘记了p所指向的内存空间已经释放。
if(pointer != NULL)
{
strcpy(pointer, "world"); // (B)
}
}
程序运行结果:
Hello , I am Howells !
Before call delete function, the address of pointer is : 0x0013FF7C
After call delete function, the address of pointer is : 0x0013FF7C
Press any key to continue
注意到程序中第A行代码,虽然在第A行代码对pointer指针进行了delete操作,但是delete方法只是会释放掉该地址所指向的内存,而pointer指针仍然指向原来所指向的内存地址。通常由于程序比较长,程序员有时可能记不住pointer所指向的内存是否被释放掉了,所以我们会使用一个语句if(pointer != NULL)来进行测试,但是很遗憾的是,pointer并不是NULL,它指向一块不合法的内存单元。第B行的代码在VC++6.0是可行的,也就是说strcpy操作会将一块不属于自己的一块内存单元赋值,这非常可怕会对其他程序造成潜在的危害。
#include<iostream.h>
class A
{
public:
void Func(void){cout<<"A::func() " <<endl;}
int I;
};
A *Test(void)
{
A a;
return &a; / /(A)
}
void main()
{
A *p = NULL;
p = Test();
p->Func();
}
在Test函数中我们声明了一个对象a,然后返回对象a的地址。我们知道由于a是临时变量,所以当函数出了执行点(右花括号)时,a被退栈了,但是a所在的存储单元并没有被清除掉。所以仍然可以通过指针p来访问函数Func()。这显然将造成潜在的危险。
C#中动态内存分配方式
在C#中对内存的管理是依靠.NET 垃圾回收器来完成的,垃圾回收器为高速的分配服务提供了很好的内存使用机制。它可以恢复正在运行中的应用程序需要的内存。垃圾回收器负责清理内存,当.NET检测到给定过程的堆已满时,需要清理时,就需要调用垃圾回收器,下面我将详细介绍.NET的内存分配机制。和C++一样,在.NET中用户所申请的动态内存空间将被分配到堆上,不同的是在.NET上的堆是托管堆。自动内存管理是公共语言运行库在托管执行过程过程中提供的服务之一。公共语言运行库的垃圾回收器为应用程序管理内存的分配和释放。对开发人员而言,这就意味着在开发托管应用程序时不必编写执行内存管理任务的代码。
在C#中大致有三种不同的存储单元:
(1) Managed Heap:这是动态配置(Dynamic Allocation)的存储单元,由Gargage Collector在执行时自动管理,整个进程将公用一个Managed Heap。
(2) Call Stack:这是由.NET CLR在执行时自动管理的存储单元,每个Thread都有自己专门的Call Stack。每呼叫一次method,就会使得Call Stack上多一个Record Frame;方法执行完毕之后,此Record Frame会被丢弃。这一点与C++类似。
(3) Evaluation Stack:这是由.NET CLR在执行时自动管理的存储单元,每个Thread都有自己专门的Evaluation Stack。这个堆栈也叫做堆叠式虚拟机,既程序执行时的资料都是先放在堆叠中,再进行运算。
其三种存储单元的物理结构模型如下:
图1-1
下图是托管堆的简化模型。
图1-2
在C#中动态分配内存时,.NET是采用如下规则进行内存管理的。
(1) 堆被划分为代,以便只需查找堆的一小部分就能清除大多数垃圾。
(2) 同代中的对象大体上均为同龄。
(3) 代的编号越高,表示堆的这一片区域所包含的对象越老,这些对象就越有可能是稳定的。最老的对象位于最低的地址内,而新的对象则创建在增加的地址内。
(4) 新对象的分配指针标记了内存的已使用(已分配)内存区域和未使用(可用)内存区域之间的边界。
(5) 通过删除死对象并将活对象转移到堆的低地址末尾,堆周期性地进行压缩。这就扩展了在创建新对象的图表底部的未使用区域。
(6) 对象在内存中的顺序仍然是创建它们的顺序,以便于定位。
(7) 在堆中,对象之间永远不会有任何空隙。
(8) 只有某些可用空间是已提交的。需要时,操作系统会从“保留的”地址范围中分配更多的内存。
(9) 所有可进行垃圾回收的对象都分配在一个连续的地址空间范围内。
C#中动态内存回收机制
在C#中大致有三种垃圾回收机制:完全回收、部分回收、使代与写入屏障配合工作。
图1-3
1) 完全回收
在完全回收时,程序将停止执行,并且到托管堆中找到所有的根。这些根以各种形式出现,它们可以是堆栈上的指针或者指向堆中的全局变量。从根开始,我们访问每个对象,并沿途追溯包含在每个被访问对象内的每个对象指针,指针用于标记这些对象。一旦找出了不可达到的对象,我们就需要回收空间以便随后使用;在这里,回收器的目标是要将活的对象向上移动,并清除浪费的空间。在执行过程停止的情况下,回收器可以安全地移动所有这些对象,并修复所有指针,以便所有对象在新的位置上被正确链接。幸存的对象将被提升到下一代的编号(就是说,代的边界得到更新),并且执行过程可以恢复。
2) 部分回收
假设最近执行了一次完全回收,程序继续执行,在发生足够多的分配之后,内存管理系统决定是进行回收的时候了。假设我们非常的幸运,自从上一次回收以后,在我们运行的所有时间里,我们根本没有对任何较老的对象执行写操作,而只是对新分配的(第零代 (gen0))对象执行了写操作。因此,当执行垃圾回收的时候,只需要检查所有的根,如果有任何根指向旧对象,就忽略这些对象。而对于其他根(指向 gen0 的根)我们进行追溯所有指针。一旦我们发现有内部指针指回较老的对象,我们就忽略它。完成以后,我们就访问完gen0中的所有活的对象,但没有访问过任何老的对象(gen1,gen2对象)。接着就对gen0区域进行回收空间处理。
3) 使代与写入屏障配合工作
但事实上,部分回收算法的充分条件是不太可能的,因为总会有一些较老的对象肯定会发生更改。发生这种情况时,.NET使用另外一种辅助的数据结构来配合部分回收算法。card table的数据结构来记住脏对象的位置;牌桌中的每个位代表堆中的一个内存范围,比如说是 128 个字节。程序每次将对象写入某个地址时,写入屏障代码必须计算哪个 128 字节块被写入,然后在牌桌中设置相应的位。
如果我们正在执行一次 gen0 垃圾回收,我们可以使用上面讨论的算法(忽略指向较老代的任何指针),但一旦我们完成该操作,那么我们还必须查找位于牌桌中被标记为已修改的块中的每个对象中的每个对象指针。我们必须像对待根一样对待这些指针。如果我们同样地考虑这些指针,那么我们将准确无误地只回收 gen0 对象。
C#中动态分配内存注意事项
我们了解了.NET垃圾回收器的工作原理后,就可以针对它来制定出编写高效程序的准则:
(1) 最大程度地减少对象指针的写入次数,尤其是对较老对象的写入。
(2) 减少数据结构中的指针密度。第一,将有很多对象写入。第二,当回收该数据结构的时间到来时,您将使垃圾回收器追溯所有这些指针,如果需要,还要随着对象的到处移动全部更改这些指针。如果您的数据结构的生命周期很长,并且不会有很多更改,那么,当完全回收发生时(在 gen2 级别),回收器只需要访问所有这些指针。但如果您创建的此类结构的生命周期短暂(就是说,作为处理事务的一部分),那么您将支付比正常情况下大出很多的开销。
(3) 如果可以通过只增加少量的程序复杂性,则应该避免过多的动态内存临时分配。如在比较两个字符串的时候,应该避免使用String.Split。因为 String.Split 将创建一个字符串数组,这意味着原来在关键字字符串中的每个关键字都有一个新的字符串对象,再加上该数组也有一个对象。现在,您的两行比较函数就创建了数量非常多的临时对象。垃圾回收器突然因为您而负载大增,甚至使用最智能的回收方案也会有很多垃圾需要清理。最好编写一个根本不需要分配内存的比较函数。
(4) 尽量避免使用析构函数。一个带有析构函数的对象意味着它是需要终结的对象。垃圾回收器第一次遇到应死而未死但仍需要终结的对象时,它必须在这个时候放弃回收该对象的空间的尝试。而是将对象添加到需要终结的对象列表中,而且,回收器随后必须确保对象内的所有指针在终结完成之前仍然继续有效。这基本上等同于说,从回收器的观察角度来看,需要终结的每个对象都像是临时的根对象。回收完成后,终结线程将遍历需要终结的对象列表,并调用终结器。该操作完成时,对象再一次成为死对象,并且将以正常方式被自然回收。
在对C#以及C++的内存管理机制分析完毕以后,我们可以对比出它们间的优缺点如下:
(1) C#内存分配比C++更加有效率:因为不需要像传统分配器那样搜索可用的内存块;所有需要发生的操作只是需要移动在可用的和已分配的区域之间的边界。
(2) C#清理内存机制可以使得程序员无需为管理内存而单独编写在大多数时候都是重复的代码(内存紧缩)。
(3) 在相当出色的程序员编写的程序中没有任何操纵与内存相关的错误代码(通常非常难), 利用C++中程序员直接控制内存方式肯定比C#利用垃圾回收器更加有效。因为程序员通常更加清楚何时回收内存是最佳时刻。
(4) 由于C#中由垃圾回收器回收无用已分配的内存快,所以不会发生由于程序员疏忽而产生的内存泄漏。当然也可能会丢失一些资源,如忘记关闭与数据库的连接等。