Jamzy Wang

life is a struggle,be willing to do,be happy to bear~~~

C/C++内存管理详解

2012-03-23 21:49

原创声明:本作品采用知识共享署名-非商业性使用 3.0 版本许可协议进行许可,欢迎转载,演绎,但是必须保留本文的署名(包含链接),且不得用于商业目的。

在操作系统上运行的每一个进程(process)都会被分配一定的独立的虚拟地址空间(virtual address space),这些虚拟地址空间在程序运行过程中会被映射到物理地址(physical memory)空间中。在这里,“独立”的意思是这个虚拟地址空间是这个进程独占的,不和其他进程共享。

不同的操作系统为每个进程分配的虚拟地址空间大小是不同的,在以Linux为内核的操作系统上虚拟地址空间默认是4GB,其中内核空间占1G,用户态空间占3G。内核空间是不能被直接被程序读写的区域,读写这些区域会造成Segmentation Fault。本文将重点介绍用户态空间的使用和管理。

此处输入图片的描述 (图源)

内存区域划分

一个由C/C++编译的程序占用的内存分为以下几个部分:

栈区(stack):由编译器自动分配释放,存放函数的参数值、局部变量的值等,其操作方式类似于数据结构的栈(后进先出-LIFO)。

堆区(heap):一般是由程序员分配释放,若程序员不释放的话,程序结束时由OS回收,值得注意的是它与数据结构的堆是两回事,分配方式倒是类似于数据结构的链表。

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

文字常量区(.text):常量字符串就是放在这里,程序结束后由系统释放。

程序代码区(.data):存放函数体的二进制代码。

此处输入图片的描述 (图源)

在C/C++程序中,各种变量的内存分配规则如下:

1) 在函数体中定义的局部变量通常是在栈上

2) 用malloc, calloc, realloc等内存分配函数分配的内存就是在堆上

3) 在所有函数体外定义的全局变量,加了static修饰符后的变量不管其在程序中的哪个位置,它都会被存放在全局区(静态区)

4) 函数中的”string”这样的字符串存放在文字常量区。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
int g_var = 0; //全局初始化区  
char g_p1; //全局未初始化区  
void main()  
{  
    int b; //栈  
    char s[] = "abc"; //栈  
    char p2; //栈  
    char p3 = "123456"; //123456在常量区,p3在栈上  
    static int c = 0; //全局(静态)初始化区  
    p1 = (char )malloc(10); //分配得来得10字节的区域在堆区  
    p2 = (char )malloc(20); //分配得来得20字节的区域在堆区  
    strcpy(p1, "123456");  
} 

内存分配

这里重点阐释栈和堆的内存分配。

栈(Stack)

对栈而言,栈中的新加数据项放在其他数据的顶部,移除时你也只能移除最顶部的数据(不能越位获取)。

栈内存的分配主要有以下特点:

  • 和堆一样存储在计算机 RAM 中。

  • 在栈上创建变量的时候会扩展,并且会自动回收。

  • 相比堆而言在栈上分配要快的多。

  • 用数据结构中的栈实现。

  • 存储局部数据,返回地址,用做参数传递。

  • 当用栈过多时可导致栈溢出(无穷次(大量的)的递归调用,或者大量的内存分配)。

  • 在栈上的数据可以直接访问(不是非要使用指针访问)。

  • 如果你在编译之前精确的知道你需要分配数据的大小并且不是太大的时候,可以使用栈。

  • 当你程序启动时决定栈的容量上限。

何时在栈上分配内存?

那么什么时候选择在栈上分配内存呢?选择在栈上分配内存主要考虑两个因素:

1) 相对较小内存的变量,如1KB,10KB,100KB级别的内存

2) 相对较短的生存时间,如函数中的局部变量

当变量的应用场景不满足上述两个条件时,就需靠考虑在堆上分配内存。

栈内存的分配方法

在函数中定义一个局部变量即可:如

1
2
3
4
int function(int para) {
    int a = 0;//变量a在栈上分配
    return a;
}

堆(Heap)

对堆而言,数据项位置没有固定的顺序。你可以以任何顺序插入和删除,因为他们没有“顶部”数据这一概念。

堆内存的分配主要有以下特点:

  • 和栈一样存储在计算机RAM。

  • 在堆上的变量必须要手动释放,不存在作用域的问题。数据可用 delete, delete[] 或者 free 来释放。

  • 相比在栈上分配内存要慢。

  • 通过程序按需分配。

  • 大量的分配和释放可造成内存碎片。

  • 在 C++ 中,在堆上创建数的据使用指针访问,用 new 或者 malloc 分配内存。

  • 如果申请的缓冲区过大的话,可能申请失败。

  • 在运行期间你不知道会需要多大的数据或者你需要分配大量的内存的时候,建议你使用堆。

  • 可能造成内存泄露

何时在堆上分配内存?

那么什么时候选择在堆上分配内存呢?选择在栈上分配内存主要考虑三个因素:

1) 当需要分配一大块内存时,如一个很大的数组,一个很大的结构体,一个很大的类的实例。 一般当需要分配的内存达到几千个字节时,推荐在堆上分配,比如构建一个包含1000个整数的数组: int array = malloc(1000 sizeof(int));

2) 当希望变量的生存时间很长时(如全局可见)

3) 当希望变量能动态的改变大小时(如能动态增加大小的数组,链表或者预先不能估计大小)

堆内存的分配方法:

C中分配/释放函数:malloc, realloc, calloc and free

  • void malloc (size_t size):分配一个内存区块
1
2
sizesize of the memory block, in bytes.
size_tan unsigned integral type.

malloc只有一个参数,即需要分配的内存的字节数。malloc的返回值有两种:当分配成功时,malloc分配size大小的内存区块,返回指向这个内存块的首地址(指针)。返回的指针是void类型的,void将会被转化成希望的指针类型。当分配失败时,返回一个指向null的空指针,一般是内存区域不足时。

在实际程序中,通常通过如下方式调用 malloc 函数,比如申请一块长度为 n 的整数类型的内存:

1
int p = (int ) malloc(sizeof(int)  n);

malloc 函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。通常不同的操作系统或者编译环境下,相同的变量类型的字长可能不同,比如int变量在16位系统下是2个字节,在32位下是 4个字节;而 float变量在 16位系统下是 4个字节,在 32位下也是 4个字节。因此:

在malloc的“()”中使用sizeof运算符是良好的风格。

malloc返回值的类型是void ,所以在调用malloc时要显式地进行类型转换,将void 转换成所需要的指针类型。

由于malloc()可能返回一个空指针(当内存区域不足时),故必须检验malloc的返回值。

那么当malloc返回空指针,也就是内存耗尽时,函数逻辑中应该如何处理呢? 通常有三种方式处理“内存耗尽”问题。

(1)判断指针是否为 NULL,如果是则用return语句终止本函数

1
2
3
4
5
6
7
void function(void) {
    A a = new A;
    if(a == NULL) {
        return;
    }
    
}

(2)判断指针是否为 NULL,如果是则用exit(1)终止整个程序的运行。

1
2
3
4
5
6
7
8
void function(void) {
    A a = new A;
    if(a == NULL){
        cout <<  Memory Exhausted << endl;
        exit(1);
    }
    
}

(3)为new和malloc设置异常处理函数。

这里需要重点指出

malloc()分配的内存区域未初始化

  • void calloc (size_t num, size_t size):分配一个内存区块并初始化为0
1
2
3
num: Number of elements to allocate.
size: Size of each element.
size_t is an unsigned integral type.

calloc有两个参数,一个是需要分配的元素的个数,另一个是元素占用空间的字节数。和malloc类似,calloc的返回值也有两种,成功时返回指向内存区域首地址的指针,失败时返回一个指向null的空指针。和malloc不同的是

calloc分配的内存区域被初始化为0

  • void realloc (void ptr, size_t size):修改一个原先已经分配的内存块大小
1
2
3
ptr: Pointer to a memory block previously allocated with malloc, calloc or realloc.
size: New size for the memory block, in bytes.
size_t is an unsigned integral type.

realloc有两个参数,一个是指向已分配的内存区域,另一个是需要分配的新的内存区域的字节数。realloc在执行成功时返回执行新的内存区域的指针,这个指针可能和原来的内存区块的首地址一致(新的内存区块在原来旧的内存区块后面新增加了指定的大小),也可能是一个指向一块新的内存区域的指针当执行失败时,返回一个指向null的空指针。

1) 使用这个函数,你可以使一块内存扩大或缩小。如果它用于扩大一个内存块,那么这块内存原先的内容依然保留,新增加的内存添加到原先内存块的后面,新内存并未以任何方法进行初始化。

2) 如果它用于缩小一块内存块,该内存块尾部的部分内存便被拿掉,剩余部分内存的原先内容依然保留。

3)如果原先的内存块无法改变大小,realloc将分配另一块正确大小的内存,并把原先那块内存的内容复制到新的块上。因此,在使用realloc之后,就不能再使用指向旧内存的指针,而是应该该用realloc所返回的新指针。

4) 如果realloc函数的第一个参数是NULL,那么它的行为就和malloc一模一样。

  • void free (void ptr):释放已分配的内存区块
1
2
ptr: Pointer to a memory block previously allocated with malloc, calloc or realloc.
Return Value: none

free函数的参数是指向一个内存区块的指针,free函数没有返回值。

那么考虑一个问题,free对指针做了什么?

free只是把指针所指的内存给释放掉,但并没有把指针本身销毁掉。

在这里,把内存释放掉只是告诉内存分配管理器指针所指的这块内存区域将不再被使用,可以再次分配给其他变量,一般把这时候这块内存区域里的数据称为“垃圾数据”或者“脏数据”。那么,对于指针本身有什么变化呢?指针本身没有任何变化,还是原来的值(非NULL),只是该地址对应的内存数据成了脏数据,一般把这时候的指针称为“野指针”。

如果此时不把指针设置为NULL,会让人误以为p是个合法的指针。如果程序比较长,我们有时记不住p所指的内存是否已经被释放,在继续使用p之前,通常会用语句 if(p!=NULL) 进行防错处理。很遗憾,此时if语句起不到防错作用,因为即便p不是NULL指针,它也不指向合法的内存块。

1
2
3
4
5
6
7
8
char p = (char ) malloc(100);
strcpy(p, hello );
free(p); // p 所指的内存被释放,但是 p所指的地址仍然不变

if(p != NULL) // 没有起到防错作用
{
strcpy(p, world ); // 出错
}

C++中的分配/释放函数:new ,new [],delete, delete []

严格上讲,按照C++标准,new和delete并不是C++中的函数,而是C++中的关键字。

考虑一个问题,既然有了malloc/free为什么还要new/delete呢?

对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。

对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于 malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。

我们先看一看malloc/free和 new/delete如何实现对象的动态内存管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Obj {
    public :
        Obj(void){ cout << Initialization  << endl; }
        ~Obj(void){ cout <<  Destroy << endl; }
        void Initialize(void){ cout << Initialization  << endl; }
        void Destroy(void){ cout << Destroy  << endl; }
};
void UseMallocFree(void) {
    Obj a = (obj )malloc(sizeof(obj)); // 申请动态内存
    a->Initialize(); // 初始化
    // …
    a->Destroy(); // 清除工作
    free(a); // 释放内存
}

void UseNewDelete(void) {
    Obj a = new Obj; // 申请动态内存并且初始化
    // …
    delete a; // 清除并且释放内存
}

类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由 于malloc/free不能执行构造函数与析构函数,必须调用成员函数 Initialize和 Destroy来完成初始化与清除工作。函数 UseNewDelete则简单得多。

所以不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。

由于内部数据类型的“对象”没有构造与析构的过程,对它们而言malloc/free和 new/delete是等价的。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

如果用free释放“new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放“malloc申请的动态内存”,理论上讲程序不会出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

  • new、new []

语法:

1
2
3
p_var = new type_name;
p_var = new type(initializer);
p_var = new type [size];

用例:

1
2
3
int p_scalar = new int(5); //allocates an integer, set to 5. (same syntax as constructors)
int p_array = new int[5];  //allocates an array of 5 adjacent integers. (undefined values)
int cpp11_array = new int[5] {1, 2, 3, 4, 5};  //allocates an array of 5 adjacent integers initialized to {1, 2, 3, 4, 5}. (C++11 only)

new若执行成功,它会做3件事:

  1. 分配内存

  2. 调用构造函数初始化内存区域

  3. 返回内存区域的地址

new若执行失败,会抛出异常(an exception of type std::bad_alloc )

  • delete delete []: 执行delete:调用析构函数、释放内存

语法:

1
2
int p_var = nullptr;  // new pointer declared
p_var = new int;       // memory dynamically allocated

用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/ .......
other code
......../

delete p_var;          // memory freed up
p_var = nullptr;       // pointer changed to nullptr (null pointer)

int size = 10;
int p_var = nullptr;    // new pointer declared
p_var = new int [size];  // memory dynamically allocated

/ .......
other code
......../

delete [] p_var;         // memory freed up
p_var = nullptr;         // pointer changed to nullptr

在这里需要强调一点:

在某个作用域中执行的new得到的内存,必须在相同的作用域中调用delete释放该内存。

  • 由以上描述可知,堆和栈在程序中的内存分配方式不同。

  • 申请和响应不同

(1)申请方式:

stack由系统自动分配,系统收回;

heap需要程序员自己申请,C语言中用函数malloc分配空间,用free释放,C++用new分配,用delete释放。

(2)申请后系统的响应:

栈:只要栈的剩余空间大于所申请的空间,系统将为程序提供内存,否则将报异常提示栈溢出(stack overflow)。

堆:首先应该知道操作系统有一个记录内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请的空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete或free语句就能够正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会将多余的那部分重新放入空闲链表中。

  • 申请的大小限制不同

栈:栈是由高向低地址扩展的数据结构,是一块连续的内存区域,栈顶的地址和栈的最大容量是系统预先规定好的,能从栈获得的空间较小。

堆:堆是由低向高地址扩展的数据结构,是不连续的内存区域,这是由于系统是由链表在存储/组织空闲内存地址,自然堆就是不连续的内存区域,且链表的遍历也是从低地址向高地址遍历的,堆的大小受限于计算机系统的有效虚拟内存空间,因此,堆获得的空间比较灵活,也比较大。

  • 申请的效率不同

栈:栈由系统自动分配,速度快,但是程序员无法控制。

堆:堆是由程序员自己分配,速度较慢,容易产生碎片,不过用起来方便。

  • 堆和栈的存储内容不同

栈:在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令的地址,然后是函数的各个参数,在大多数的C编译器中,参数是从右往左入栈的,当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令。

堆:一般是在堆的头部用一个字节存放堆的大小(便于内存空间的组织和释放),具体内容由程序员安排。

  • 碎片问题

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

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

Ref

Anatomy of a Program in Memory

What a C programmer should know about memory

《程序员的自我修养——链接、装载与库》

《高质量 C++/C 编程指南》

The Descent to C

Guide to Advanced Programming in C

What and where are the stack and heap?

What’s the difference between a stack and a heap?

Comments