C++内存分析

时间:2023-03-08 17:29:12

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

栈:就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。

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

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

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

常量存储区:这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改,而且方法很多)

明确区分堆与栈在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、管理方式不同;

2、空间大小不同;

3、能否产生碎片不同;

4、生长方向不同;

5、分配方式不同;

6、分配效率不同;

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

空间大小:

一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。

但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M。

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

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

碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。

对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了.

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

分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由malloc函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:对了,还有一件事,如果有人把堆栈合起来说,那它的意思是栈,可不是堆。

通过代码理解内存分配:

 //main.cpp
int a = ; 全局初始化区
char *p1; 全局未初始化区
main()
{
int b;// 栈
char s[] = "abc"; //栈
har *p2; //栈
char *p3 = ""; //"123456/0"在常量区,p3在栈上。
static int c =; //全局(静态)初始化区
p1 = (char *)malloc();
p2 = (char *)malloc();
//分配得来得10和20字节的区域就在堆区。
strcpy(p1, ""); //123456/0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}

堆内存是什么?

堆内存是在应用程序内动态分配内存时常用的可用内存池。之所以使用 “堆内存” 这个术语,是因为动态内存分配的大多数实现均使用称为 的一种二进制树型数据结构。动态内存分配是在应用程序运行时显式分配和取消分配内存(或存储)的一种方法。在本文中,堆管理器 这个术语用于描述处理应用程序动态内存分配的软件。

堆管理器执行两种主要操作。第一种操作是分配。这种操作将保留给定大小的一个存储块,并返回所保留存储块的指针。存储块的惟一所有权将赋予应用程序,应用程序可以使用该存储块来实现它所需要的任何目的。第二种操作是取消分配。此操作会将之前已分配好的存储块返回给堆管理器。取消分配一个存储块时,块的所有权也将返还给堆管理器。此后,堆管理器可以使用该存储块继续分配。

堆管理器经常会提供的第三种操作是重新分配。这种操作会重新调整给定存储块的大小,并返回存储块的指针(可能经过更新)。重新分配操作并不属于严格需要的操作(因为可以通过基本的分配和取消分配操作予以实现),但堆管理器通常会提供这种操作。

在 IBM i® 上,共有两种不同类型的堆存储:单层存储和 teraspace 存储。应用程序使用的存储类型由创建程序时指定的程序属性决定。默认情况下使用的是单层存储。如果需要使用 teraspace 存储,必须使用特殊编译选项和程序创建选项。除此之外,也可以使用应用程序编程接口 (API) 在单层存储应用程序内分配和取消分配 teraspace 存储。如需进一步了解这两种类型的存储,请参阅 ILE 概念 手册中的 “Teraspace 和单层存储” 部分。

两种类型的堆存储之间存在某些重大差异。从堆中分配单层存储时,最多只能分配 16 MB 的内存。从堆中分配 teraspace 存储时,可以分配数 TB 大小的存储。必须使用十六字节的指针来寻址单层存储。对于支持八字节指针的语言(例如 C 和 C++),可以使用八字节指针寻址 teraspace 存储。单层存储堆的总大小限制为每作业 4 GB。teraspace 堆不存在这类限制,teraspace 存储堆的总大小仅受限于可用的系统存储数量。

如何分配或取消分配堆存储

尽管每种语言均有不同的分配和取消分配方法,但所有集成语言环境 (ILE) 语言均可使用堆内存。C 语言中使用的是 malloc() 和free() 函数,C++ 中使用的是 new 和 delete 操作符,而 RPG 中使用的是 %ALLOC 内置函数和 DEALLOC 操作。尽管 COBOL 和 CL 没有管理堆内存的内置函数,但 ILE 模型允许从任意 ILE 语言调用函数,因此这些语言可以调用 malloc() 和 free() 函数来管理堆内存。实际上,所有 ILE 语言均可调用 malloc() 和 free() 函数来管理堆内存。下面的几个示例展示了所有这些语言中对堆内存的分配、使用和取消分配。

示例 1 是使用 C 语言编写的。

示例 1 – C 中的堆内存

/* To compile: CRTBNDC PGM(EXAMPLE1) */

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h>
#include <string.h>
int main (int argc, char *argv[])
{
  /* allocate 16 bytes of storage from the heap */
  char *ptr = (char*)malloc(16);
  /* set the allocated storage */
  memcpy(ptr, "abcdefghijklmnop", 16);
  /* deallocate storage */
  free(ptr);
  return 0;
}

示例 2 是使用 C++ 语言编写的。

示例 2 – C++ 中的堆内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* To compile: CRTBNDCPP PGM(EXAMPLE2) */
#include <string.h>
int main (int argc, char *argv[])
{
  /* allocate 16 bytes of storage from the heap */
  char *ptr = new char[16];
  /* set the allocated storage */
  memcpy(ptr, "abcdefghijklmnop", 16);
  /* deallocate storage */
  delete [] ptr;
  return 0;
}

这些示例仅分配了极少的堆存储(16 字节)。通常情况下,所分配的存储块要比这大得多。举例来说,考虑这样一个事实,CL 变量的最大大小为 32767 字节。利用堆存储和 *PTR 变量,可以在 CL 中管理更大的存储块。

堆内存的常见问题

堆分配和取消分配必须由应用程序显式执行,因此可能会出现这些操作使用不当的问题。堆内存使用不当的常见场景包括:写入的数据量超过了所分配的存储量(内存写入越界),读取的数据量超过了已分配内存的大小(内存读取越界),写入存储或读取存储时使用的是此前已经取消分配的存储(重用已取消分配的内存),取消分配存储超过一次(重复取消分配),在内存不再使用时未能取消分配内存(内存泄漏)。

下面的示例程序展示了这些堆内存问题。

前两个场景涉及到了堆分配的大小。分配时将为堆管理器提供所需的存储大小,它将返回足够大的存储块,以满足所请求的大小。如果应用程序未能正确计算堆大小,那么有时会意外地读取或写入不属于当前分配存储的一部分堆存储。这可能会给应用程序带来问题。

示例 6 展示了 C++ 中的内存写入越界问题。堆仅分配了 16 字节的大小,但总计写入了 17 字节的数据。strcpy() 函数不仅会复制字符串的 16 个字节,而且还会追加一个零字节后缀(NULL 字符)。

示例 6 – C++ 中的内存写入越界

1
2
3
4
5
6
7
8
9
10
11
12
/* To compile: CRTBNDCPP PGM(EXAMPLE6) */
#include <string.h>
int main (int argc, char *argv[])
{
  char *ptr = new char[16];
  /* strcpy() copies the trailing NULL character */
  strcpy(ptr, "abcdefghijklmnop");  /* memory overwrite */
  delete [] ptr;
  return 0;
}

示例 7 展示了 C 语言中的内存写入越界。堆仅分配了 13 字节的大小,但总计写入了 14 字节的数据。strcpy() 函数不仅会复制字符串的 13 个字节,而且还会追加一个零字节后缀(NULL 字符)。

示例 7 – C 中的内存写入越界

1
2
3
4
5
6
7
8
9
10
11
12
13
/* To compile: CRTBNDC PGM(EXAMPLE7) */
#include <stdlib.h>
#include <string.h>
int main (int argc, char *argv[])
{
  char *ptr = (char*)malloc(13);
  /* strcpy() copies the trailing NULL character */
  strcpy(ptr, "abcdefghijklm");  /* memory overwrite */
  free(ptr);
  return 0;
}

下一个场景涉及过早地取消分配应用程序仍在使用的堆存储。逻辑错误可能允许应用程序引用已经取消分配并返回给堆管理器进行重用的堆存储。此类分配中引用的数据可能是所需数据,也可能不是所需数据,可能会导致应用程序中出现间歇性错误。

示例 9 是使用 C++ 编写的,展示了写入不再属于已分配存储的堆存储的情况。

示例 9 – 取消分配的内存重用

1
2
3
4
5
6
7
8
9
10
11
/* To compile: CRTBNDCPP PGM(EXAMPLE9) */
#include <string.h>
int main (int argc, char *argv[])
{
  char *ptr = new char[16];
  delete [] ptr;
  strcpy(ptr, "abc");  /* reuse of deallocated memory */
  return 0;
}

下一个场景涉及到多次取消分配相同的存储。

示例 10 是使用 C 语言编写的,展示了取消分配内存的重复调用。

示例 10 – 取消分配内存的重复调用

1
2
3
4
5
6
7
8
9
10
11
/* To compile: CRTBNDC PGM(EXAMPLE10) */
\#include <stdlib.h>
int main (int argc, char *argv[])
{
  char *ptr = (char*)malloc(16);
  free(ptr);
  free(ptr);  /* duplicate deallocation */
  return 0;
}

最后一个场景是未能对某些已经分配的堆内存执行取消分配。这就叫做内存泄露,因为内存泄漏 到了堆以外的地方,无法再供应用程序使用。尽管内存不再被引用,但堆管理器并不了解该情况,也无法重用内存。内存泄漏会造成严重的问题。大量内存泄漏会导致性能问题,还有可能导致应用程序耗尽内存。在长期运行的应用程序中,这种问题尤为严重。在某些时候,应用程序结束之后(在激活组终止时),操作系统会回收该应用程序的所有堆存储,因此内存泄漏不再成为问题。

不正确的堆使用会导致间歇性的应用程序故障、不当的应用程序行为,甚至损坏的数据。致使堆问题难以调试的一个特征是:堆错误往往不会造成直接后果。举例来说,在缓冲区写入越界的情况下,写入的数据超出了已分配内存缓冲区的结尾处。当引用写入越界、不再包含所需数据的内存时,问题征兆要到很晚的时候才会显现在应用程序中。

IBM i 堆内存管理器

IBM i 6.1 及更新版本中提供了三种堆内存管理器:默认内存管理器、Quick Pool 内存管理器和调试内存管理器。对给定应用程序有效的内存管理器由应用程序运行时的 QIBM_MALLOC_TYPE 环境变量的设置控制。6.1 版本中的 PTF 5761SS1-SI33945 提供了访问其他堆内存管理器的环境变量,IBM i 发布版的后续版本也包含此类环境变量。在添加环境变量访问的同时,还添加了调试内存管理器。在版本 5.4 和 6.1 中,还可以调用 API 进行支持设置,从而使用 Quick Pool 内存管理器。如需查看堆内存管理器支持的完整文档,请参阅 ILE C/C++ 运行时库函数 手册。

默认内存管理器

默认内存管理器是一种通用的内存管理器,它会尝试平衡性能和内存需求。它为绝大多数应用程序提供了充足的性能,同时会尝试最大程度地减少开销所需的额外内存量。默认内存管理器是大多数应用程序的最佳选择,默认情况下,内存管理器是启用的。如果IBM_MALLOC_TYPE 环境变量尚未设置,或者被设置为无法识别的值,则会使用默认的内存管理器。

Quick Pool 内存管理器

Quick Pool 内存管理器会将内存拆分为一系列池,以便提高发出大量较小的分配请求的应用程序的性能。在启用 Quick Pool 内存管理器时,将为处于给定分配大小范围内的分配请求分配池中固定大小的单元。这些请求的处理速度将快于大小超出此范围的请求。超出此范围的分配请求将按照与默认内存管理器相同的方式处理。

默认情况下不会启用 Quick Pool 内存管理器,但可以通过设置以下环境变量来启用它:

 QIBM_MALLOC_TYPE=QUICKPOOL

也可以在应用程序中使用 API 调用启用 Quick Pool 内存管理器。如需了解有关的更多信息,请参阅 ILE C/C++ 运行时库函数 手册。

调试内存管理器

调试内存管理器主要用于查找应用程序没有正确使用堆的情况。它并未针对性能而优化,可能会对应用程序的性能造成负面影响。然而,它对于确定不当的堆使用情况很有价值。

调试内存管理器检测到的内存问题会导致以下两种行为之一:

  • 如果在发生不当使用之时检测到问题,那么将会生成一条机器检查句柄 (MCH) 异常消息(通常是 MCH0601、MCH3402 或者 MCH6801)。在这种情况下,错误消息通常会停止应用程序。
  • 如果在不当使用已经发生之后才检测到问题,则会生成一条 C2M1212 消息。在这种情况下,消息通常不会停止应用程序。

调试内存管理器会通过两种方式检测内存问题:

  • 首先,使用限制访问内存页面。在每次分配之前和之后使用一个限制访问权限的内存页面。让每个内存块都与 16 字节的边界对齐,并尽可能地将它们放置在页面结尾处。由于仅允许在页面边界处保护内存,所以这样的对齐对于内存写入越界和内存读取越界的检测效果最好。在一个限制访问权限的内存页面中执行任何读取或写入操作时,会立即生成一条 MCH 异常。
  • 第二,它会在每次分配前后使用一个填充字节。在分配时,紧邻每次分配的内存之前的几个字节会初始化为预设的字节模式。在分配之时,如果分配的大小需要限制为 16 字节的倍数,那么紧邻所分配内存之后的填充字节也会初始化为预设的字节模式。在所分配的内存取消分配时,将验证填充字节,确保其仍然包含预期的预设字节模式。如果任何填充字节被修改,那么调试内存管理器会生成一条 C2M1212 消息,原因代码为 X’80000000′。

默认情况下不会启用调试内存管理器,但可以通过设置以下环境变量来启用它:

 QIBM_MALLOC_TYPE=DEBUG

调试堆内存的常见问题

上文列出了使用堆内存时的几种常见问题,还给出了一些示例程序,展示了各种堆问题。调试内存管理器允许检测多种堆内存常见问题,包括:内存写入越界、内存读取越界、重用已取消分配的内存和重复的取消分配。调试内存管理器不会检测内存泄漏问题。ILE 应用程序内的内存泄漏检测将在未来的文章中加以介绍。

在使用调试内存管理器运行程序时,将描述展示堆问题的每个示例程序的行为。

示例 6 展示了一个内存写入越界问题。其中展示了尝试超越大小为 16 字节的倍数的数据项末尾的一项写入操作。将该示例编译为一个单层存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH0601 消息。将该示例编译为一个 teraspace 存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH6801 消息。无论出现哪种情况,错误消息的细节都会指向执行内存写入越界的语句。示例展示了内存写入越界,但内存读取越界也会得到相同的结果。

示例 7 展示了一个内存写入越界问题。其中演示了未超越 16 字节边界的内存写入操作。将该示例被编译为一个单层存储程序或 teraspace 程序,并使用调试内存管理器运行该程序,这会得到一条 C2M1212 消息,原因代码为 X’80000000′。消息的细节将指向调用 free() 的语句。调试内存管理器无法检测到未超越 16 字节边界的内存读取越界。

示例 9 展示了写入不再属于已分配存储的堆存储的情况。将该示例编译为一个单层存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH3402 消息。将该示例编译为一个 teraspace 存储程序,并使用调试内存管理器运行此程序,在发生内存写入时,这会生成一条 MCH6801 消息。无论出现哪种情况,错误消息的细节都会指向执行内存写入的语句。示例展示了内存写入,但内存读取也会得到相同的结果。

示例 10 展示了取消分配内存的重复调用。将该示例编译为一个单层存储程序或 teraspace 程序,并使用调试内存管理器运行该程序,这会得到一条 C2M1212 消息。消息的细节将指向调用 free() 的语句。

如果未使用调试内存管理器,则无法检测到这些内存问题,同时还有可能导致间歇性的应用程序问题、不当的应用程序行为或损坏的数据。

破解堆的谜题

对于编写和维护 ILE 应用程序而言,了解堆内存是什么以及如何正确使用堆内存的能力极为重要。在明确认识常见堆问题的同时,利用调试内存管理器即可轻松检测到应用程序内的堆问题,迅速解决 IBM i ILE 应用程序内的堆内存问题。