C++内存管理

 

C++内存管理

       欢迎进入内存这片雷区。伟大的Bill Gates 曾经失言:

640K ought to be enough for everybody

— Bill Gates 1981

程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的解决办法就是发现所有潜伏的地雷并且排除它们,躲是躲不了的。本章的内容比一般教科书的要深入得多,读者需细心阅读,做到真正地通晓内存管理。

7.1内存分配方式

内存分配方式有三种:

(1)                             从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

(2)                             在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

(3)                             从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。

只有在堆上分配的内存才需要(也必须)我们进行释放,否则就会造成内存泄漏。
例如:

char a = 3;

char func(char b)

{

    char c = 5;

    char *d = new char[1];

    delete [] d; d = 0;

    return c;

}

此处,a为全局变量,从静态存储区域分配1字节给它;b、c、d为局部变量;b、c的内存在栈上分配;d的内存在堆上分配。d的内存需要在程序退出之前释放掉。

 


1.1 C++内存管理详解

1.1.1 内存分配方式

1.1.1.1 分配方式简介

  在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

 

  栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

 

  堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

 

  自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。

 

  全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

 

  常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

 

1.1.1.2 明确区分堆与栈

  在bbs上,堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。

 

  首先,我们举一个例子:

 

void f() { int* p=new int[5]; }

 

 

  这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,他在VC6下的汇编代码如下:

 

00401028 push 14h

 

0040102A call operator new (00401060)

 

0040102F add esp,4

 

00401032 mov dword ptr [ebp-8],eax

 

00401035 mov eax,dword ptr [ebp-8]

 

00401038 mov dword ptr [ebp-4],eax

 

 

  这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么?澳,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组,VC6就会根据相应的Cookie信息去进行释放内存的工作。

 

1.1.1.3 堆和栈究竟有什么区别?

  好了,我们回到我们的主题:堆和栈究竟有什么区别?

 

  主要的区别由以下几点:

 

  1、管理方式不同;

 

  2、空间大小不同;

 

  3、能否产生碎片不同;

 

  4、生长方向不同;

 

  5、分配方式不同;

 

  6、分配效率不同;

 

  管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

 

  空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:

 

  打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。

 

  注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。

 

  碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。

 

  生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

 

  分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

 

  分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

 

  从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。

 

  虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

 

无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

 

1.1.2 控制C++的内存分配

  在嵌入式系统中使用C++的一个常见问题是内存分配,即对new 和 delete 操作符的失控。

 

  具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

 

  这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new 和 delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

 

  作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

 

  但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。

 

  一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。

 

 

 

 


在嵌入式系统中使用C++的一个常见问题是内存分配,即对new 和 delete 操作符的失控。

  具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

  这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new 和 delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

  作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

  但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new 和delete来代替系统的内存分配符,并且一个类一个类的重载new 和delete。

  一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。

  重载全局的new 和delete 操作符

  可以很容易地重载new 和 delete 操作符,如下所示:

void * operator new(size_t size)
{
void *p = malloc(size);
return (p);
}
void operator delete(void *p);
{
free(p);
}

  这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() 和free()。

  也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。

class TestClass {
public:
void * operator new(size_t size);
void operator delete(void *p);
// .. other members here ...
};

void *TestClass::operator new(size_t size)
{
void *p = malloc(size); // Replace this with alternative allocator
return (p);
}
void TestClass::operator delete(void *p)
{
free(p); // Replace this with alternative de-allocator
}

所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

 

  为单个的类重载 new[ ] 和 delete[ ]

  必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。

  C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。

class TestClass {
public:
void * operator new[ ](size_t size);
void operator delete[ ](void *p);
// .. other members here ..
};
void *TestClass::operator new[ ](size_t size)
{
void *p = malloc(size);
return (p);
}
void TestClass::operator delete[ ](void *p)
{
free(p);
}
int main(void)
{
TestClass *p = new TestClass[10];

// ... etc ...

delete[ ] p;
}

  但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

 

 


  摘要:

  大家都知道变量的生存周期这个概念,可是有的时候变量生存周期已经结束,但是所分配的那块内存空间还是存在的。文章举了四个不同的例子,来说明这其中的原因。

  前言:

  前一段时间在一个好友的的博客论坛上看到他学习C++时遇到的一小点问题,是关于C++中最基本的内存分配方面的。其实对于内存这东西,除了知道我自己用的是256M的内存以外,我也不太清楚他到底里面是怎么工作的。看了大师们的讲解,我深有体会,把这些讲解整理出来,为所有C++的newbie们共勉!

  正文:

  事情是因为这样一小段程序而来的。

int main()
{
  int i=10;
  int *j=&i;
  if(!0)
  {
    int l=20;
    int *k=&l;
    j=k;
    k=0;
  }
  cout<<*j;
  return 0;
}

  不用编译器,大家想想执行过之后应该打印什么结果?我想大家的第一反应应该是打印出一个不确定的数。理由是在if语句里,我们定义了k这个变量,在if执行结束之后,这个变量k所占据的内存是被系统收回的,于是也就造成了变量j所指的结果非常不确定。当然,如果编译并且执行过后,我们发现事情并不是像我们想象的那样,程序最终的打印结果是20,并不是我们期待的一个不确定的数。下面就让我们分析一下原因吧!

  我们用debug的方式来一步一步的分析,在watch的窗口下输入里面所有的变量。

int i=10;  //i is 10 and &i is 0x0012ff7c
int *j=&i; //*j is 10 and &j is 0x0012ff7c
      //显然可以看出此时两个变量指的是同一地址
if(!0)
{
  int l=20; //l is 20 and &l is 0x0012ff74

  /*地址0x0012ff7c—0x0012ff75被占据。要说明的是,
  这个数值很有可能因为电脑硬件的不同而不同。*/

  int *k=&l; //*k is 20 and &k is 0x0012ff74

  //变量k与l指向同一地址。

  j=k;  //j is 0x0012ff74 and *j is 20

  /*指针间的赋值,这个语句的意思是把k指向的地址负值给j。
  此时这两个变量指向的是同一个地址,都是0x0012ff74,而那
  块地址存放的是20,所以也就有*j是20的原因。*/
}

cout<<*j; //*j is 20 and j is 0x0012ff74

/*此时同时可以看到k的地址是0x00000000,说明k这个变量
已经被自动销毁,所以地址指零。但是j所指的并不是k,而
是k所指的那段地址0x0012ff74,而由于此时j的生存周期还
没有结束(j是在if意外定义的),所以j指向的这块地址并
没有被收回,也就保存下来20这个数了。*/

  至此,我们分析完了程序的全过程的内存分配情况,最终结果是这样的。(图1)



  我们同时也可以在Memory里面看看这个地址的具体内容。我们可以看到是14,这是十六进制的数,化成十进制,正好是20。(图2)



  现在大家应该对上面那个程序的执行过程有一个大概地了解了吧!不过这个还不是我们想要得到的结果,我们需要的是打印一个不确定的结果。有了以上的分析,我们开始新的程序,让他打印出我们想要的东西。

  对于上面的程序,我们需要改动的是令变量j指向一个地址被释放的位置。于是就有了下面的程序。

int * foo()
{
  int l=20;
  return &l;
}

int main()
{
  int i=10;
  int *j=&i;
  j = foo();
  cout<<*j;
  return 0;
}

  编译器很“聪明”,编译后会给出一个警告。原话是“returning address of local variable or temporary”,指向的是上面程序的第四行,也就是return &l;这条语句。那句英文的意思也不用我再多解释了,相信大家都能看得明白。

  执行的结果,在debug下,是20;在release下,结果是4198795。显然那部分内存被释放掉了。这是因为在debug的程序里面,执行完函数foo,并没有立即释放掉l的那个地址(目前我不清楚这句话说得是否精确)。在这个程序的release版本中,显然程序释放了那部分的地址,所以指向了一个不确定的数。


  这里还要说一件事情,就是在第一个程序当中,无论是debug版本还是release版本。执行完那个if语句以后,系统都是不会真正的把l清除掉,l只是k的一个别名。上面的程序是这样写的,用了*j=&i这样一句负值语句,而别名在MSDN中的解释与引用是相同的,所以也可以这样理解,int i=10; int &j=i;与上面的相同。不要去想上面这些程序了,大家再看看下面这个。

void f1( int *& j)
{
  int l=20;
  int *k=&l;
  j=k;
  k=0;
}
void any_function_use_local_variables()
{
  int a,b,c;
  a=b=c=100;
}

int main()
{
  int i=10;
  int *j=&i;
  f1(j);
  cout<<*j;
  any_function_use_local_variables();
  cout<<*j;
  return 0;
}

  请大家自己编译、执行,看看结果是什么,然后结合上面的两个例子,想想是为什么。下面再给大家一个小例子,可能会有助于理解内存的概念。

  程序的过程是试图去增加i,使之超过最大的整数。有一种情况是这个值被“卷回来”变成一个负数,在我的机器上程序的打印结果是-2147483648,这个结果可能因为硬件的不同而不同。

int main()
{
  int i=1;
  while(0<i) i++;
  cout<<i;
  return 0;
}

  结束。


内存地址的分配决定了很多东西,至少寻址方式和它密切相关,既然发现8086在这个时间段内出现,那它一定和这个神秘的数字有足够强的联系,而寻址方式直接体现在指令格式里面,在这个方向上必定能找出一些蛛丝马迹。

 

但640K不是2的次幂,怎么会出现这种现象呢?

 

我翻了一下8086/8088的寻址方式,才恍然大悟,8088有20根地址线,理论上是支持1MB的寻址,但是,8088是典型的16位机,问题就出在这里了,16位决定了ALU能运算的地址范围也不过是16位,换句话说,它只能直接计算并访问64K的内存空间,但是另外一个东西也引起了我的注意……

 

8088有一个叫做段寄存器的地方,CS,SS,DS和ES分别对应代码段,堆栈段,数据段和附加数据段,问题的研究更近一步。

 

在不改变以上四个寄存器的前提下,每个段能访问的内存长度都是64K,比如一个程序,可能在0~34K是程序部分,但是占用的空间是0000H~FFFFH,这是一个64K,接着程序的数据段(也可能是堆栈段)就要从10000H开始了,一直到1FFFFH,又是64K,而操控具体哪个64K,就是四个寄存器的功劳了。

 

用公式来表示:

一个操作数的物理地址=EA+DS * 10H

其中,EA是所谓的有效地址,EA=f(OST,BX,DI…)

f是某种寻址方式

 

DS的作用不言而喻,而这种偏移可以通过错位接线实现,就绕过了ALU。

 

注:以上说的是较大的程序,小程序,DS=SS=CS即可。

你可能感兴趣的:(C++内存管理)