C++,C#的⽐较
最近在⼯作,⾯试官问了我如题这个问题,我觉得这是个好问题,然⽽⾃⼰答得并不好,在⽹上搜罗了好多资料,整理如下:
C#与C++内存管理的⽐较
1、总述
C#最⼤的⼀个改进其实就是对内存访问与管理⽅法的改进。在.NET中内存的管理是全权委托给垃圾回收器,由垃圾回收器来决定何时该释放内存空间。现在普遍采⽤两种技术来释放程序动态申请的系统内存:⾸先是以C++为代表的必须以⼿⼯⽅式使应⽤程序代码完成这些⼯作,让对象维护引⽤计数。然后是以.NET 以及Java使⽤的垃圾回收器来完成内存释放⼯作。
在C++中让应⽤程序代码负责释放内存是低级、⾼性能的语⾔使⽤技术。这种技术⾮常有效,且可以让资源在不需要时就释放,因为这种技术可以直接访问内存,所以其最⼤的缺点是可能导致错误。⽽且如果程序员的记性不太好的话,也会常常忘记释放内存⽽导致内存泄漏。
在C#中内存的管理是依靠垃圾回收器,垃圾回收器是⼀个清理内存的程序。所有采⽤new关键字申请的动态内存空间都会分配到堆上,当.NET检测到给定过程的堆已经满时,需要清理时,就会调⽤垃圾回收
器。垃圾回收器将采⽤垃圾回收算法将那些不再被引⽤的对象所占⽤的内存空间释放掉。显然由于程序员⽆法直接控制内存的释放,所开发出的软件性能和效率上⼀定会受到很⼤的影响。不过这种影响是随着计算机硬件技术的发展⽇益缩⼩的。
究竟是C++中直接由程序员管理内存好,还是像.NET中那样由单独⼀个程序来统⼀管理好呢?这个问题是公说公有理,婆说婆有理。但是我相信随着计算机硬件技术不断的发展、存储器空间越来越⼤、软件的复杂性和软件健壮性要求的不断提⾼,程序员直接管理内存的⽅式必将会退出历史舞台。当今的程序员不必再为该如何把程序分块放到容量有限的内存中运⾏⽽担⼼,因为这项任务已经交给了操作系统的虚拟内存来管理。相信不久将来⼈们也会习惯完全交由诸如垃圾回收器⼀类的专门程序来管理程序申请的内存空间。
2.1 C++中内存的分配⽅式
在C++中内存的分配⽅式⼤致有三种:
(1)    从静态存储区域分配。内存在程序编译的时候已经分配完毕了。并且这块内存中的所有数据在程序的整个运⾏期间都始终存在的。例如:全局变
量,static变量等等。
(2)    在栈上创建。在函数执⾏期间,⽆论什么时候到达⼀个特殊的执⾏点(左花括号)时,存储单元都可以在栈上被创建。出了执⾏点(右花括号),这个存储单元⾃动被释放。这些栈分配运算内置在处理器的指令集中,⾮常有效,并且不存在失败的危险,但是可供分配的内存容量很有限。
(3)    存储单元也可以从⼀块称为堆(也被称为⾃由存储单元)的地⽅分配,从堆(heap)上分配,亦成为动态分配。在C++中,程序在运⾏期间可以⽤malloc 或者new申请任意数量的内存,程序员⾃⼰掌握释放内存的适当时机(使⽤free或者delete)。动态内存的⽣存期间是由程序员决定,使⽤⾮常灵活,但也最容易产⽣问题
2.2 C++⽤户管理内存常出现的问题
在C++中,我们必须⾮常⼩⼼第三种内存分配⽅式,因为内存的分配和释放都得由程序员来控制,⼀不⼩⼼就会出错。下⾯我就分析下在C++中,由于第三种内存分配⽅式⽽导致的⼀些常见的内存泄漏以及⼀系列的指针问题。
<pre name="code" class="cpp"><span >#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");
}</span>
注意到函数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⾏所申请的内存空间并不会被删除,所以将造成内存泄漏。
3 C#中内存管理机制
3.1 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)    所有可进⾏垃圾回收的对象都分配在⼀个连续的地址空间范围内。
3.2 C#中动态内存回收机制
在C#中⼤致有三种垃圾回收机制:完全回收、部分回收、使代与写⼊屏障配合⼯作。
图1-3
1)      完全回收
在完全回收时,程序将停⽌执⾏,并且到托管堆中到所有的根。这些根以各种形式出现,它们可以是堆栈上的指针或者指向堆中的全局变量。从根开始,我们访问每个对象,并沿途追溯包含在每个被访问对象内的每个对象指针,指针⽤于标记这些对象。⼀旦出了不可达到的对象,我们就需要回收空间以便随后使⽤;在这⾥,回收器的⽬标是要将活的对象向上移动,并清除浪费的空间。在执⾏过程停⽌的情况下,回收器可以安全地移动所有这些对象,并修复所有指针,以便所有对象在新的位置
上被正确链接。幸存的对象将被提升到下⼀代的编号(就是说,代的边界得到更新),并且执⾏过程可以恢复。
2)      部分回收
假设最近执⾏了⼀次完全回收,程序继续执⾏,在发⽣⾜够多的分配之后,内存管理系统决定是进⾏回收的时候了。假设我们⾮常的幸运,⾃从上⼀次回收以后,在我们运⾏的所有时间⾥,我们根本没有对任何较⽼的对象执⾏写操作,⽽只是对新分配的(第零代 (gen0))对象执⾏了写操作。因此,当执⾏垃圾回收的时候,只需要检查所有的根,如果有任何根指向旧对象,就忽略这些对象。⽽对于其他根(指向 gen0 的根)我们进⾏追溯所有指针。⼀旦我们发现有内部指针指回较⽼的对象,我们就忽略它。完成以后,我们就访问完gen0中的所有活的对象,但没有访问过任何⽼的对象(gen1,gen2对象)。接着就对gen0区域进⾏回收空间处理。
3)      使代与写⼊屏障配合⼯作
但事实上,部分回收算法的充分条件是不太可能的,因为总会有⼀些较⽼的对象肯定会发⽣更改。发⽣这种情况时,.NET使⽤另外⼀种辅助的数据结构来配合部分回收算法。card table的数据结构来记住脏对象的位置;牌桌中的每个位代表堆中的⼀个内存范围,⽐如说是 128 个字节。程序每次将对象写⼊某个地址时,写⼊屏障代码必须计算哪个 128 字节块被写⼊,然后在牌桌中设置相应的位。
如果我们正在执⾏⼀次 gen0 垃圾回收,我们可以使⽤上⾯讨论的算法(忽略指向较⽼代的任何指针),但⼀旦我们完成该操作,那么我们还必须查位于牌桌中被标记为已修改的块中的每个对象中的每个对象指针。我们必须像对待根⼀样对待这些指针。如果我们同样地考虑这些指针,那么我们将准确⽆误地只回收 gen0 对象。
3.3 C#中动态分配内存注意事项
我们了解了.NET垃圾回收器的⼯作原理后,就可以针对它来制定出编写⾼效程序的准则:
(1)    最⼤程度地减少对象指针的写⼊次数,尤其是对较⽼对象的写⼊。
(2)    减少数据结构中的指针密度。第⼀,将有很多对象写⼊。第⼆,当回收该数据结构的时间到来时,您将使垃圾回收器追溯所有这些指针,如果需要,还要随着对象的到处移动全部更改这些指针。如果您的数据结构的⽣命周期很长,并且不会有很多更改,那么,当完全回收发⽣时(在 gen2 级别),回收器只需要访问所有这些指针。但如果您创建的此类结构的⽣命周期短暂(就是说,作为处理事务的⼀部分),那么您将⽀付⽐正常情况下⼤出很多的开销。
(3)    如果可以通过只增加少量的程序复杂性,则应该避免过多的动态内存临时分配。如在⽐较两个字符串的时候,应该避免使⽤String.Split。因
为 String.Split 将创建⼀个字符串数组,这意味着原来在关键字字符串中的每个关键字都有⼀个新的字符串对象,再加上该数组也有⼀个对象。现在,您的两⾏⽐较函数就创建了数量⾮常多的临时对象。垃圾回收器突然因为您⽽负载⼤增,甚⾄使⽤最智能的回收⽅案也会有很多垃圾需要清理。最好编写⼀个根本不需要分配内存的⽐较函数。
(4)    尽量避免使⽤析构函数。⼀个带有析构函数的对象意味着它是需要终结的对象。垃圾回收器第⼀次遇到应死⽽未死但仍需要终结的对象时,它必须在这个时候放弃回收该对象的空间的尝试。⽽是将对象添加到需要终结的对象列表中,⽽且,回收器随后必须确保对象内的所有指针在终结完成之前仍然继续有效。这基本上等同于说,从回收器的观察⾓度来看,需要终结的每个对象都像是临时的根对象。回收完成后,终结线程将遍历需要终结的对象列表,并调⽤终结器。该操作完成时,对象再⼀次成为死对象,并且将以正常⽅式被⾃然回收。
4 与C++内存优缺点对⽐总结
在对C#以及C++的内存管理机制分析完毕以后,我们可以对⽐出它们间的优缺点如下:
(1)    C#内存分配⽐C++更加有效率:因为不需要像传统分配器那样搜索可⽤的内存块;所有需要发⽣的操作只是需要移动在可⽤的和已分配的区域之间的边界。
(2)    C#清理内存机制可以使得程序员⽆需为管理内存⽽单独编写在⼤多数时候都是重复的代码(内存紧缩)。
(3)    在相当出⾊的程序员编写的程序中没有任何操纵与内存相关的错误代码(通常⾮常难), 利⽤C++中程序员直接控制内存⽅式肯定⽐C#利⽤垃圾回收器更加有效。因为程序员通常更加清楚何时回收内存是最佳时刻。
(4)    由于C#中由垃圾回收器回收⽆⽤已分配的内存快,所以不会发⽣由于程序员疏忽⽽产⽣的内存泄漏。当然也可能会丢失⼀些资源,如忘记关闭与数据库的连接等。
5 C++内存管理常见错误及对策
发⽣内存错误是件⾮常⿇烦的事情。编译器不能⾃动发现这些错误,通常是在程序运⾏时才能捕捉到。⽽这些错误⼤多没有明显的症状,时隐时现,增加了改错的难度。有时⽤户怒⽓冲冲地把你来,程序却没有发⽣任何问题,你⼀⾛,错误⼜发作了。 常见的内存错误及其对策如下:
A. 内存分配未成功,却使⽤了它。
  编程新⼿常犯这种错误,因为他们没有意识到内存分配会不成功。常⽤解决办法是,在使⽤内存之前检查指针是否为null。如果指针p 是函数的参数,那么在函数的⼊⼝处⽤assert(p!=null)进⾏检查。如
果是⽤malloc或new来申请内存,应该⽤if(p==null) 或if(p!=null)进⾏防错处理。
B. 内存分配虽然成功,但是尚未初始化就引⽤它。
  犯这种错误主要有两个起因:⼀是没有初始化的观念;⼆是误以为内存的缺省初值全为零,导致引⽤初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统⼀的标准,尽管有些时候为零值,我们宁可信其⽆不可信其有。所以⽆论⽤何种⽅式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌⿇烦。
C. 内存分配成功并且已经初始化,但操作越过了内存的边界。
  例如在使⽤数组时经常发⽣下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。D.忘记了释放内存,造成内存泄露。
  含有这种错误的函数每被调⽤⼀次就丢失⼀块内存。刚开始时系统的内存充⾜,你看不到错误。终有⼀次程序突然死掉,系统出现提⽰:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使⽤次数⼀定要相同,否则肯定有错误(new/delete同理)。E. 释放了内存却继续使⽤它。 
  有三种情况:
  (1)程序中的对象调⽤关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局⾯。
  (2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引⽤”,因为该内存在函数体结束时被⾃动销毁。
  (3)使⽤free或delete释放了内存后,没有将指针设置为null。导致产⽣“野指针”。
  【规则1】⽤malloc或new申请内存之后,应该⽴即检查指针值是否为null。防⽌使⽤指针值为null的内存。
  【规则2】不要忘记为数组和动态内存赋初值。防⽌将未被初始化的内存作为右值使⽤。
  【规则3】避免数组或指针的下标越界,特别要当⼼发⽣“多1”或者“少1”操作。
  【规则4】动态内存的申请与释放必须配对,防⽌内存泄漏。
  【规则5】⽤free或delete释放了内存之后,⽴即将指针设置为null,防⽌产⽣“野指针”。 

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。