堆空间&栈空间&&动态内存分配


远离书本/课堂太久,对计算机中的一些基本知识都不熟悉了,前些天一直都在想别的事情,没有时间(其实今天也没什么时间,因为明天就要考科目二了……)去学习/整理堆栈方面的知识点,今天花了些时间把之前搜集到的一些内容综合起来,自己摘录/识记/理解如下:

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

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

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

3、全局区(static):也叫静态数据内存空间,存储全局变量和静态变量,全局变量和静态变量的存储是放一块的,初始化的全局变量和静态变量放一块区域,没有初始化的在相邻的另一块区域,程序结束后由系统释放。

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

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


下面给出一幅我自己参考《深入理解计算机系统》画的示意图:

进程的虚拟地址空间
进程的虚拟地址空间
堆和栈的区别:
1、由以上描述可知,堆和栈在程序中的内存分配方式不同。
2、申请和响应不同:

(1)申请方式:

stack由系统自动分配,系统收回;heap需要程序员自己申请,C语言中用函数malloc分配空间,用free释放,C++用new分配,用delete释放。

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

  • 栈:只要栈的剩余空间大于所申请的空间,系统将为程序提供内存,否则将报异常提示栈溢出(stack overflow)。
  • 堆:首先应该知道操作系统有一个记录内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请的空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的delete或free语句就能够正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会将多余的那部分重新放入空闲链表中。
3、申请的大小限制不同:
  • 栈:在Windows下,栈是由高向低地址扩展的数据结构,是一块连续的内存区域,栈顶的地址和栈的最大容量是系统预先规定好的,能从栈获得的空间较小。{地址从高向低}
  • 堆:堆是由低向高地址扩展的数据结构,是不连续的内存区域,这是由于系统是由链表在存储/组织空闲内存地址,自然堆就是不连续的内存区域,且链表的遍历也是从低地址向高地址遍历的,堆的大小受限于计算机系统的有效虚拟内存空间,因此,堆获得的空间比较灵活,也比较大。
4、申请的效率不同:
  • 栈:栈由系统自动分配,速度快,但是程序员无法控制。
  • 堆:堆是由程序员自己分配,速度较慢,容易产生碎片,不过用起来方便。
5、堆和栈的存储内容不同:
  • 栈:在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令的地址,然后是函数的各个参数,在大多数的C编译器中,参数是从右往左入栈的,当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令。
  • 堆:一般是在堆的头部用一个字节存放堆的大小(便于内存空间的组织和释放),具体内容由程序员安排。

参考地址:栈空间和堆空间 – 殇的日志

***********************************************************

动态内存分配
前言

1.数组的元素存储于内存中连续的位置上。当一个数组被声明时,它所需要的内存在编译时就被分配[固定大小]。

2.但是我们也可以使用动态内存分配在运行时为它分配内存。

3.为什么使用动态内存分配?

1>当使用数组时,必须用一个常量来指定数组的长度。但是,有时候,数组的长度常常在运行时才知道。因此,在某些情况下,我们通常采取声明一个较大的数组,它可以容纳可能出现的最多元素。

2>该方法的优点是:简单。

3>它的缺点是:

Ø 这种声明在程序中引入了人为的限制,如果程序需要使用的元素数量超过了声明的长度,它就无法处理这种情况。要避免这种情况,最简单的方法就是把数组声明的更大一些。
Ø 如果程序实际需要的元素数量比较少时,巨型数组的绝大部分内存空间都被浪费了
Ø 如果输入的数据超过了数组的容纳范围时,程序必须以一种合理的方式作出响应

一、malloc 和 free

1.C函数库提供了两个函数,malloc和free,分别用于执行动态内存分配和释放。这些函数维护一个可用内存。

2.当一个程序另外需要一些内存时,它就调用malloc函数,malloc从内存池中提取一块合适的内存。并向该程序返回一个指向这块内存的指针。这块内存此时并没有以任何方式进行初始化。如果要对其进行初始化,要么自己动手进行初始化,要么使用calloc()函数。当一块以前分配的内存不在使用时,程序调用free函数把它归还给内存池供以后使用。

3.这两个函数的原型如下,都在头文件stdlib.h中声明:

void *malloc(size_t size);
void free(void *pointer);

4.malloc的参数就是需要分配的内存字节数。如果内存池中的可用内存可以满足这个需求,malloc就返回一个指向被分配的内存块起始位置的指针。
1>malloc所分配的是一块连续的内存。例如:如果请求分配100个字节的内存,那么它实际分配的内存就是100个连续的字节,并不会分开位于两块或多块不同的内存。同时,malloc实际分配的内存有可能比你请求的稍微多一点。但是这是由编译器定义的。
2>如果内存池是空的,或者它的可用内存无法满足要求时。在该情况下,malloc()函数向操作系统请求,要求得到更多地内存,并在这块内存上执行分配任务。如果操作系统无法向malloc提供更多的内存,malloc就返回一个NULL指针。因此,要对每个从malloc返回的指针都进行检查,确保它并非NULL是非常重要的

5.free的参数必须要么是NULL,要么是一个先前从malloc、calloc或realloc返回的数值。想free传递一个NULL参数不会产生任何效果。

6.malloc并不知道请求的内存需要存储的是整型、浮点值、结构还是数组。malloc返回一个类型为void *的指针。在标准中表示一个void *类型的指针可以转换为其他任何类型的指针。因此,在使用前,要进行强制类型转换

二、calloc和realloc

1.还有两个内存分配函数,calloc和realloc。它们的原型如下:
void *calloc(size_t num_elements,size_t element_size);
void realloc(void *ptr,size_t new_size);

2.calloc也用于分配内存。malloc和calloc之间的区别是后者在返回指向内存的指针之前把它初始化为0。但如果程序只是想把一些值存储到数组中,那么这个初始化过程纯属浪费时间。

3.calloc和malloc之间另一个较小的区别是它们请求内存数量的方式不同。calloc的参数包括所需元素的数量和每个元素的字节数。根据这些值,它能够计算出总共需要分配的内存。

4.realloc函数用于修改一个原先已经分配的内存块大小。
1>使用这个函数,你可以使一块内存扩大或缩小。如果它用于扩大一个内存块,那么这块内存原先的内容依然保留,新增加的内存添加到原先内存块的后面,新内存并未以任何方法进行初始化。
2>如果它用于缩小一块内存块,该内存块尾部的部分内存便被拿掉,剩余部分内存的原先内容依然保留。
3>如果原先的内存块无法改变大小,realloc将分配另一块正确大小的内存,并把原先那块内存的内容复制到新的块上。因此,在使用realloc之后,就不能再使用指向旧内存的指针,而是应该该用realloc所返回的新指针。
4>如果realloc函数的第一个参数是NULL,那么它的行为就和malloc一模一样。

三、使用动态内存分配实例

1.使用malloc分配一块内存

int *pi;
/* ... */
pi = malloc(100);
if (pi == NULL){
    printf("Outof memory!n");
    exit(1);
}

符号NULL定义于stdio.h,它实际上是字符值常量0。
1>如果内存分配成功,那么得到了一个指向100个字节的指针。在整型为4个字节的机器上,这块内存将被当作25个整型元素的数组,因为pi是一个指向整型的指针。
2>但是,使用上面的程序可移植性较差:
可以使用如下的方法:

pi = malloc(25 *sizeof(int));

使用该方法,即使在整数长度不同的机器上,它也能获得正确的结果。
3>已经有了指针,如何使用这块内存呢。可以使用间接访问和指针运算来访问数组的不同整数位置,下面通过循环来给新分配的数组的每个元素都初始化为0。

int *pi2, i;
...
pi2 = pi;
for(i = 0; i < 25; i++)
    *pi2++ = 0;

我们也可以使用数组下标来进行操作:

int i;
...
for(i=0; i<25; i++)
    pi[i] = 0;
四、常见的动态内存错误

1.使用动态内存分配的程序中,常常会出现很多错误。
1>对NULL指针进行解引用操作
2>对分配的内存进行操作时越过边界
3>释放并非动态分配的内存
4>试图释放一块动态分配的内存的一部分以及一块内存被释放之后被继续使用。
说明:
动态分配最常见的错误就是忘记检查所请求的内存是否成功分配
动态内存分配的第二大错误来源是操作内存时超出了分配内存的边界

2.当你使用free时,可能出现各种不同的错误。
1>传递给free的指针必须是一个从malloc、calloc或realloc函数返回的指针
2>传递给free函数一个指针,让它释放一块并非动态分配的内存可能导致程序立即终止或在晚些时候终止。
3>试图释放一块动态分配内存的一部分也有可能引起类似问题,比如:

/**
***Get 10 integers' memory space
**/
pi = malloc(10*sizeof(int));
...
/*
**仅释放后5个整数的内存空间,前面的5个数不释放
*/
free(pi + 5);

说明:
释放一块内存的一部分是不允许的。动态分配的内存必须整块一起释放。但是,realloc函数可以缩小一块动态分配的内存,有效地释放它尾部的部分内存。
4>不要访问已经被free函数释放了的内存。假定对一个指向动态分配的内存的指针进行了复制,而且这个指针的几份拷贝分散于程序各处。你无法保证当你使用其中一个指针时它所指向的内存是不是已被另一个指针释放。还要确保程序中所有使用这块内存的地方在这块内存释放之前停止对它的使用。
5>当动态分配的内存不再需要使用时,应该被释放,这样该内存空间就可以被重新分配使用。分配内存但在使用完毕后不释放将引起内存泄漏(memory leak)。

总结:

1.当数组被声明时,必须在编译时知道它的长度。动态内存分配允许程序为一个长度在运行时才知道的数组分配内存空间。

2.malloc和calloc函数都用于动态分配一块内存,并返回一个指定该块内存的指针。
1>malloc的参数就是需要分配的内存的字节数。
2>calloc的参数是需要分配的元素个数和每个元素的长度。calloc函数在返回前把内存初始化为零。malloc函数返回时内存并未以任何方式进行初始化。
3>调用realloc函数可以改变一块已经动态分配的内存的大小。增加内存块大小有时有可能采取的方法是把原来内存块上的所有数据复制到一个新的、更大的内存块上。当一个动态分配的内存块不再使用时,应该调用free函数把它归还给可用内存池,内存释放后就不能再被访问。

3.如果请求的内存分配失败,malloc、alloc和realloc函数返回的将是一个NULL指针。

4.错误的访问分配内存之外的区域所引起的后果类似越界访问一个数组,而且这个错误还能破坏可用内存池,导致程序失败。

5.如果一个指针不是从早先的malloc、calloc或realloc函数返回的,它是不能作为参数传递给free函数的(即,malloc|alloc|realloc和free是需要一一对应的)。

原文地址:动态内存分配 – 冀博

***********************************************************

后来在伯乐在线上有看到了一篇讲的非常经典的文章:什么是堆和栈,它们在哪儿?,忍不住也转载其内容放在下面了:

问题描述

编程语言书籍中经常解释值类型被创建在栈上,引用类型被创建在堆上,但是并没有本质上解释这堆和栈是什么。我仅有高级语言编程经验,没有看过对此更清晰的解释。我的意思是我理解什么是栈,但是它们到底是什么,在哪儿呢(站在实际的计算机物理内存的角度上看)?

    1. 在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?
    2. 它们的作用范围是什么?
    3. 它们的大小由什么决定?
    4. 哪个更快?
答案一

栈是为执行线程留出的内存空间。当函数被调用的时候,栈顶为局部变量和一些 bookkeeping 数据预留块。当函数执行完毕,块就没有用了,可能在下次的函数调用的时候再被使用。栈通常用后进先出(LIFO)的方式预留空间;因此最近的保留块(reserved block)通常最先被释放。这么做可以使跟踪堆栈变的简单;从栈中释放块(free block)只不过是指针的偏移而已。

堆(heap)是为动态分配预留的内存空间。和栈不一样,从堆上分配和重新分配块没有固定模式;你可以在任何时候分配和释放它。这样使得跟踪哪部分堆已经被分配和被释放变的异常复杂;有许多定制的堆分配策略用来为不同的使用模式下调整堆的性能。

每一个线程都有一个栈,但是每一个应用程序通常都只有一个堆(尽管为不同类型分配内存使用多个堆的情况也是有的)。

直接回答你的问题: 1. 当线程创建的时候,操作系统(OS)为每一个系统级(system-level)的线程分配栈。通常情况下,操作系统通过调用语言的运行时(runtime)去为应用程序分配堆。 2. 栈附属于线程,因此当线程结束时栈被回收。堆通常通过运行时在应用程序启动时被分配,当应用程序(进程)退出时被回收。 3. 当线程被创建的时候,设置栈的大小。在应用程序启动的时候,设置堆的大小,但是可以在需要的时候扩展(分配器向操作系统申请更多的内存)。 4. 栈比堆要快,因为它存取模式使它可以轻松的分配和重新分配内存(指针/整型只是进行简单的递增或者递减运算),然而堆在分配和释放的时候有更多的复杂的 bookkeeping 参与。另外,在栈上的每个字节频繁的被复用也就意味着它可能映射到处理器缓存中,所以很快(译者注:局部性原理)。

答案二
Stack
    1. 和堆一样存储在计算机 RAM 中。
    2. 在栈上创建变量的时候会扩展,并且会自动回收。
    3. 相比堆而言在栈上分配要快的多。
    4. 用数据结构中的栈实现。
    5. 存储局部数据,返回地址,用做参数传递。
    6. 当用栈过多时可导致栈溢出(无穷次(大量的)的递归调用,或者大量的内存分配)。
    7. 在栈上的数据可以直接访问(不是非要使用指针访问)。
    8. 如果你在编译之前精确的知道你需要分配数据的大小并且不是太大的时候,可以使用栈。
    9. 当你程序启动时决定栈的容量上限。
Heap
    1. 和栈一样存储在计算机RAM。
    2. 在堆上的变量必须要手动释放,不存在作用域的问题。数据可用 delete, delete[] 或者 free 来释放。
    3. 相比在栈上分配内存要慢。
    4. 通过程序按需分配。
    5. 大量的分配和释放可造成内存碎片。
    6. 在 C++ 中,在堆上创建数的据使用指针访问,用 new 或者 malloc 分配内存。
    7. 如果申请的缓冲区过大的话,可能申请失败。
    8. 在运行期间你不知道会需要多大的数据或者你需要分配大量的内存的时候,建议你使用堆。
    9. 可能造成内存泄露。

举例:

int foo()
{
    char *pBuffer; //<--nothing allocated yet (excluding the pointer itself, which is allocated here on the stack).
    bool b = true; // Allocated on the stack.
    if(b)
    {
        //Create 500 bytes on the stack
        char buffer[500];

        //Create 500 bytes on the heap
        pBuffer = new char[500];

    }//<-- buffer is deallocated here, pBuffer is not
}//<--- oops there's a memory leak, I should have called delete[] pBuffer;
答案三

堆和栈是两种内存分配的两个统称。可能有很多种不同的实现方式,但是实现要符合几个基本的概念:

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

stack

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

heap

上面两个图片很好的描述了堆和栈分配内存的方式。

在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?

如前所述,堆和栈是一个统称,可以有很多的实现方式。计算机程序通常有一个栈叫做调用栈,用来存储当前函数调用相关的信息(比如:主调函数的地址,局部变量),因为函数调用之后需要返回给主调函数。栈通过扩展和收缩来承载信息。实际上,程序不是由运行时来控制的,它由编程语言、操作系统甚至是系统架构来决定。

堆是在任何内存中动态和随机分配的(内存的)统称;也就是无序的。内存通常由操作系统分配,通过应用程序调用 API 接口去实现分配。在管理动态分配内存上会有一些额外的开销,不过这由操作系统来处理。

它们的作用范围是什么?

调用栈是一个低层次的概念,就程序而言,它和“作用范围”没什么关系。如果你反汇编一些代码,你就会看到指针引用堆栈部分。就高级语言而言,语言有它自己的范围规则。一旦函数返回,函数中的局部变量会直接直接释放。你的编程语言就是依据这个工作的。

在堆中,也很难去定义。作用范围是由操作系统限定的,但是你的编程语言可能增加它自己的一些规则,去限定堆在应用程序中的范围。体系架构和操作系统是使用虚拟地址的,然后由处理器翻译到实际的物理地址中,还有页面错误等等。它们记录那个页面属于那个应用程序。不过你不用关心这些,因为你仅仅在你的编程语言中分配和释放内存,和一些错误检查(出现分配失败和释放失败的原因)。

它们的大小由什么决定?

依旧,依赖于语言,编译器,操作系统和架构。栈通常提前分配好了,因为栈必须是连续的内存块。语言的编译器或者操作系统决定它的大小。不要在栈上存储大块数据,这样可以保证有足够的空间不会溢出,除非出现了无限递归的情况(额,栈溢出了)或者其它不常见了编程决议。

堆是任何可以动态分配的内存的统称。这要看你怎么看待它了,它的大小是变动的。在现代处理器中和操作系统的工作方式是高度抽象的,因此你在正常情况下不需要担心它实际的大小,除非你必须要使用你还没有分配的内存或者已经释放了的内存。

哪个更快一些?

栈更快因为所有的空闲内存都是连续的,因此不需要对空闲内存块通过列表来维护。只是一个简单的指向当前栈顶的指针。编译器通常用一个专门的、快速的寄存器来实现。更重要的一点事是,随后的栈上操作通常集中在一个内存块的附近,这样的话有利于处理器的高速访问(译者注:局部性原理)。

答案四

你问题的答案是依赖于实现的,根据不同的编译器和处理器架构而不同。下面简单的解释一下:

    1. 栈和堆都是用来从底层操作系统中获取内存的。
    2. 在多线程环境下每一个线程都可以有他自己完全的独立的栈,但是他们共享堆。并行存取被堆控制而不是栈。
堆:
    1. 堆包含一个链表来维护已用和空闲的内存块。在堆上新分配(用 new 或者 malloc)内存是从空闲的内存块中找到一些满足要求的合适块。这个操作会更新堆中的块链表。这些元信息也存储在堆上,经常在每个块的头部一个很小区域。
    2. 堆的增加新快通常从地地址向高地址扩展。因此你可以认为堆随着内存分配而不断的增加大小。如果申请的内存大小很小的话,通常从底层操作系统中得到比申请大小要多的内存。
    3. 申请和释放许多小的块可能会产生如下状态:在已用块之间存在很多小的空闲块。进而申请大块内存失败,虽然空闲块的总和足够,但是空闲的小块是零散的,不能满足申请的大小,。这叫做“堆碎片”。
    4. 当旁边有空闲块的已用块被释放时,新的空闲块可能会与相邻的空闲块合并为一个大的空闲块,这样可以有效的减少“堆碎片”的产生。
栈:
    1. 栈经常与 sp 寄存器(译者注:”stack pointer”,了解汇编的朋友应该都知道)一起工作,最初 sp 指向栈顶(栈的高地址)。
    2. CPU 用 push 指令来将数据压栈,用 pop 指令来弹栈。当用 push 压栈时,sp 值减少(向低地址扩展)。当用 pop 弹栈时,sp 值增大。存储和获取数据都是 CPU 寄存器的值。
    3. 当函数被调用时,CPU使用特定的指令把当前的 IP (译者注:“instruction pointer”,是一个寄存器,用来记录 CPU 指令的位置)压栈。即执行代码的地址。CPU 接下来将调用函数地址赋给 IP ,进行调用。当函数返回时,旧的 IP 被弹栈,CPU 继续去函数调用之前的代码。
    4. 当进入函数时,sp 向下扩展,扩展到确保为函数的局部变量留足够大小的空间。如果函数中有一个 32-bit 的局部变量会在栈中留够四字节的空间。当函数返回时,sp 通过返回原来的位置来释放空间。
    5. 如果函数有参数的话,在函数调用之前,会将参数压栈。函数中的代码通过 sp 的当前位置来定位参数并访问它们。
    6. 函数嵌套调用和使用魔法一样,每一次新调用的函数都会分配函数参数,返回值地址、局部变量空间、嵌套调用的活动记录都要被压入栈中。函数返回时,按照正确方式的撤销。
    7. 栈要受到内存块的限制,不断的函数嵌套/为局部变量分配太多的空间,可能会导致栈溢出。当栈中的内存区域都已经被使用完之后继续向下写(低地址),会触发一个 CPU 异常。这个异常接下会通过语言的运行时转成各种类型的栈溢出异常。(译者注:“不同语言的异常提示不同,因此通过语言运行时来转换”我想他表达的是这个含义)
*函数的分配可以用堆来代替栈吗?

不可以的,函数的活动记录(即局部或者自动变量)被分配在栈上, 这样做不但存储了这些变量,而且可以用来嵌套函数的追踪。
堆的管理依赖于运行时环境,C 使用 malloc ,C++ 使用 new ,但是很多语言有垃圾回收机制。
栈是更低层次的特性与处理器架构紧密的结合到一起。当堆不够时可以扩展空间,这不难做到,因为可以有库函数可以调用。但是,扩展栈通常来说是不可能的,因为在栈溢出的时候,执行线程就被操作系统关闭了,这已经太晚了。

译者注

关于堆栈的这个帖子,对我来说,收获非常多。我之前看过一些资料,自己写代码的时候也常常思考。就这方面,也和祥子(我的大学舍友,现在北京邮电读研,技术牛人)探讨过多次了。但是终究是一个一个的知识点,这个帖子看完之后,豁然开朗,把知识点终于连接成了一个网。这种感觉,经历过的一定懂得,期间的兴奋不言而喻。

这个帖子跟帖者不少,我选了评分最高的四个。这四个之间也有一些是重复的观点。个人钟爱第四个回答者,我看的时候,瞬间高潮了,有木有?不过需要一些汇编语言、操作系统、计算机组成原理的的基础,知道那几个寄存器是干什么的,要知道计算机的流水线指令工作机制,保护/恢复现场等概念。三个回复者都涉及到了操作系统中虚拟内存;在比较速度的时候,大家一定要在脑中对“局部性原理”和计算机高速缓存有一个概念。

如果你把这篇文章看懂了,我相信你收获的不只是堆和栈,你会理解的更多!

兴奋之余,有几点还是要强调的,翻译没有逐字逐词翻译,大部分是通过我个人的知识积累和对回帖者的意图揣测而来的。请大家不要咬文嚼字,逐个推敲,我们的目的在于技术交流,不是么?达到这一目的就够了。

下面是一些不确定点:

我没有听过 bookkeeping data 这种说法,故没有翻译。从上下文理解来看,可以想成是用来寄存器值?函数参数?返回地址?如果有了解具体含义的朋友,烦请告知。

栈和堆栈是一回事,英文表达是 stack,堆是 heap。

调用栈的概念,我是第一次听说,不太熟悉。大家可以去查查资料研究一下。

以上,送给大家,本文结束。

原文地址:什么是堆和栈,它们在哪儿? – 博客 – 伯乐在线


《 “堆空间&栈空间&&动态内存分配” 》 有 5 条评论

  1. 楼主你好,关于问题一我还有点疑问不知道您能够帮忙指点下。按您的意思,java的堆栈概念就是操作系统的堆栈概念吗,两者是等价的吗?因为您提到“当线程创建的时候,操作系统(OS)为每一个系统级(system-level)的线程分配栈。”,java的线程不是操作系统线程的封装吗,这样表述岂不是说java的线程栈也是系统线程栈的封装

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注