C++中的堆栈、静态和堆

186

我搜索过,但是对这三个概念并没有很好的理解。什么时候需要使用动态分配(在堆中)?它的真正优势是什么?静态和栈的问题是什么?我能否编写一个完整的应用程序而不在堆中分配变量?

我听说其他语言包含“垃圾回收器”,因此您不必担心内存。垃圾回收器是做什么的?

自己操作内存能做什么事情,使用垃圾回收器不能做到?

曾经有人对我说过:

int * asafe=new int;

我有一个“指向指针”的指针。这是什么意思?它与以下内容不同:
asafe=new int;

?


之前有一个非常类似的问题:栈和堆是什么,它们在哪里? 那个问题有一些非常好的答案,应该能够解决你的疑惑。 - Scott Saad
可能是堆栈和堆在哪里?的重复问题。 - Swati Garg
9个回答

242

有一个类似的问题被问到了,但它没有涉及静态内存。

静态、堆和栈内存的概述:

  • 静态变量基本上是全局变量,即使您无法全局访问它。通常,它的地址在可执行文件本身中。整个程序只有一个副本。无论您进入多少次函数调用(或类)(以及多少线程!),该变量都指向同一内存位置。

  • 堆是一堆可以动态使用的内存。如果您想要4kb的对象,则动态分配器将在堆的空闲空间列表中查找4kb块,并将其分配给您。通常,动态内存分配器(malloc、new等)从内存末尾开始向后工作。

  • 解释堆栈如何增长和收缩有点超出了本回答的范围,但可以说您始终只能从末尾添加和删除。堆栈通常从高处开始,并向低地址增长。当堆栈在中间遇到动态分配器时,您就会耗尽内存(但请参阅物理内存与虚拟内存和碎片化)。多个线程将需要多个堆栈(进程通常为堆栈保留最小大小)。

何时使用每个内存:

  • 静态/全局变量对于你知道你总是需要的内存非常有用,而且你知道你永远不想释放它们。 (顺便说一句,嵌入式环境可以被认为只有静态内存…堆栈和堆是由第三种内存类型共享的已知地址空间的一部分:程序代码。当程序需要像链接列表这样的东西时,程序通常会从其静态内存中进行动态分配。但无论如何,静态内存本身(缓冲区)本身并没有“分配”,而是其他对象从缓冲区所占用的内存中分配出来的。在非嵌入式环境中也可以做到这一点,控制台游戏通常会避免使用内置的动态内存机制,而是使用预设大小的缓冲区来紧密控制分配过程,以此来控制分配过程。)

  • 堆栈变量对于你知道只要函数在作用域内(在堆栈某个地方),你希望变量保留非常有用。堆栈对于你在代码所在位置需要的变量很好,但是在该代码之外不需要。当你访问资源(如文件)并希望资源在离开该代码时自动消失时,它们也非常好用。

  • 堆分配(动态分配的内存)对于你想比上述更灵活非常有用。经常情况下,一个函数被调用以响应事件(用户单击“创建框”按钮)。适当的响应可能需要分配一个新对象(新的Box对象),该对象应在函数退出后仍然存在,因此不能在堆栈上。但是你不知道程序开始时需要多少个框,所以它不能是静态的。

垃圾回收

最近我听说垃圾回收器有多么好,因此也许一个异议的声音会很有帮助。

当性能不是特别重要时,垃圾回收是一种非常棒的机制。我听说GC变得越来越好并且更加复杂,但事实是,你可能被迫接受性能损失(取决于使用情况)。而且如果你懒惰,它仍然可能无法正常工作。在最好的情况下,垃圾回收器意识到当没有更多引用时,你的内存就会消失(参见引用计数)。但是,如果你有一个指向自身的对象(可能通过引用另一个对象来实现),那么单独的引用计数将无法指示可以删除内存。在这种情况下,GC需要查看整个引用关系图,并确定是否存在只被自身引用的孤岛。一时之间,我猜测这将是O(n^2)操作,但不管它是什么,如果你关心性能,它可能会变得很糟糕。(编辑:马丁·B 指出对于相当高效的算法,它是O(n),但如果你关心性能并且可以在没有垃圾回收的情况下进行常量时间的释放,则仍然是O(n)太多。)

个人而言,当我听到有人说C++没有垃圾回收时,我的脑海中会认为这是C++的一个特性,但我可能是少数人。对于在C和C++中编程的人来说,最难学习的可能就是指针以及如何正确地处理它们的动态内存分配。其他一些语言,比如Python,如果没有垃圾回收,将会非常糟糕,因此我认为这取决于你想从语言中得到什么。如果你想要可靠的性能,那么没有垃圾回收的C++是我能想到的除Fortran之外唯一的选择。如果你想要易用性和训练轮(可以帮助你避免崩溃,而无需学习“正确”的内存管理),那么选择一个具有垃圾回收功能的语言。即使你知道如何很好地管理内存,它也会为你节省时间,让你可以优化其他代码。现在真的没有太多的性能惩罚了,但如果你真的需要可靠的性能(以及在底层发生了什么事情、何时发生的能力),那么我会坚持使用C++。我听说过的每个主要游戏引擎都是用C++(如果不是用C或汇编语言)。Python等脚本语言适合编写脚本,但不适合作为主要游戏引擎。


10
现如今,垃圾回收通常比手动释放内存更好,因为它在系统闲时进行,而不会在需要性能的时候立即释放内存。 - Georg Schölly
1
@gs: 有趣的观点。当然,您可以使用非GC进行延迟释放,因此又回到了易用性与微观管理能力之间的平衡。如果易用性让您有时间在其他地方进行优化,那么这将是一个不错的性能提升。我会进行微调。 - markets
5
仅仅是一个小注释 -- 垃圾回收并不具有O(n^2)的复杂度(那确实会对性能产生灾难性影响)。一个垃圾回收周期所花费的时间与堆大小成比例 -- 参见 http://www.hpl.hp.com/personal/Hans_Boehm/gc/complexity.html。 - Martin B
我认为当前的垃圾回收机制与手动内存管理相比,对性能的影响并不是很大。根据程序需求,使用垃圾回收机制创建和销毁许多堆对象可能比手动内存管理更快。手动内存管理仍然优于垃圾回收机制的领域在于前者占用的内存比后者少(有时差异很大)。 - Shivan Dragon
@Deduplicator: 确实如此,这正是C++的核心所在。一般来说,C++永远不会让你失去性能,并且总有可能回退到C甚至汇编语言。但是编写高效算法通常需要耗费大量时间,至少在C++中与其他语言一样。最近我更多地考虑开发者效率。C++对开发人员的时间并不是很有效率。也许这就是NPSF3000所思考的。 - markets
显示剩余17条评论

58
以下内容当然都不是非常精确的。当你阅读时请谨慎对待 :)
好吧,你所提到的三件事情是自动、静态和动态存储期,它们与对象存在的时间以及何时开始生命周期有关。
自动存储期
你使用自动存储期来处理短暂且小型的数据,这些数据仅在某个块范围内局部使用。
if(some condition) {
    int a[3]; // array a has automatic storage duration
    fill_it(a);
    print_it(a);
}

当我们退出块时,生命周期就结束了,而在对象定义时就开始了。它们是最简单的存储持续时间,比特别是动态存储持续时间要快得多。


静态存储持续时间

您可以为自由变量使用静态存储持续时间,如果它们的作用域允许这样的使用(命名空间作用域),并且对于需要扩展其生命周期跨越作用域退出的局部变量(局部作用域)以及需要被其类的所有对象共享的成员变量(类作用域)。它们的生命周期取决于它们所在的范围。它们可以具有命名空间作用域、局部作用域和类作用域。关于它们的真相是,一旦它们的生命开始,生命周期将在程序结束时结束。以下是两个示例:

// static storage duration. in global namespace scope
string globalA; 
int main() {
    foo();
    foo();
}

void foo() {
    // static storage duration. in local scope
    static string localA;
    localA += "ab"
    cout << localA;
}

该程序打印出ababab,因为localA在其块退出时并未被销毁。可以说,具有局部作用域的对象在控制流到达其定义处时开始生命周期。对于localA,它发生在函数体进入时。对于命名空间作用域中的对象,生命周期始于程序启动。对于类作用域的静态对象也是如此。
class A {
    static string classScopeA;
};

string A::classScopeA;

A a, b; &a.classScopeA == &b.classScopeA == &A::classScopeA;

正如您所看到的,classScopeA 不绑定于其类的特定对象,而是绑定于类本身。上述三个名称的地址相同,并且都表示同一对象。有关何时以及如何初始化静态对象的特殊规则,但现在不必担心。这就是术语“静态初始化顺序混乱”的含义。

动态存储期

最后一个存储期是动态的。如果您想让对象在另一个岛上存活,并想要放置引用它们的指针,则使用该存储期。如果您的对象很大,并且想要创建仅在运行时已知大小的数组,则也可以使用该存储期。由于此灵活性,具有动态存储期的对象管理起来复杂且缓慢。具有该动态存储期的对象在适当的 new 运算符调用发生时开始生命周期。

int main() {
    // the object that s points to has dynamic storage 
    // duration
    string *s = new string;
    // pass a pointer pointing to the object around. 
    // the object itself isn't touched
    foo(s);
    delete s;
}

void foo(string *s) {
    cout << s->size();
}

只有在调用delete后,它们的生命周期才会结束。如果您忘记了,这些对象的生命周期将永远不会结束。定义用户声明构造函数的类对象不会调用其析构函数。具有动态存储期的对象需要手动处理其生命周期和相关内存资源。已经存在的库可简化使用它们的过程。使用智能指针可以为特定对象建立显式垃圾回收:

int main() {
    shared_ptr<string> s(new string);
    foo(s);
}

void foo(shared_ptr<string> s) {
    cout << s->size();
}

如果最后一个指向对象的指针超出作用域,你不必担心调用delete:shared_ptr会为您执行此操作。 shared_ptr本身具有自动存储期限,因此它的生命周期是自动管理的,允许它检查是否应在其析构函数中删除指向的动态对象。有关shared_ptr的参考,请参阅boost文档:http://www.boost.org/doc/libs/1_37_0/libs/smart_ptr/shared_ptr.htm。请注意保留HTML标记。

44

这里简短地概括了:

  • 静态变量(类)
    生命周期 = 程序运行时间 (1)
    可见性 = 由访问修饰符(private/protected/public)决定

  • 静态变量(全局范围)
    生命周期 = 程序运行时间 (1)
    可见性 = 它所实例化的编译单元 (2)

  • 堆变量
    生命周期 = 由你定义(从new到delete)
    可见性 = 由你定义(无论你将指针分配给什么)

  • 栈变量
    可见性 = 从声明到退出作用域
    生命周期 = 从声明到声明作用域退出


(1) 更准确地说:从编译单元(即 C/C++ 文件)初始化到去初始化。编译单元的初始化顺序不被标准定义。

(2) 注意:如果在头文件中实例化静态变量,每个编译单元都会有自己的副本。


5
主要区别在于速度和大小。

堆栈

分配速度极快,时间复杂度为O(1),因为它是在设置堆栈帧时分配的,所以基本上是免费的。缺点是如果堆栈空间不足,就会遇到麻烦。您可以调整堆栈大小,但是据我所知,您只有约2MB可用。而且,一旦退出函数,堆栈中的所有内容都会被清除。因此,稍后引用它可能会出现问题。(指向堆栈分配对象的指针会导致错误。)

分配速度极慢。但是,您有GB可供使用,并且可以指向它们。

垃圾回收器

垃圾回收器是后台运行并释放内存的一些代码。在堆上分配内存时很容易忘记释放它,这称为内存泄漏。随着时间的推移,应用程序消耗的内存不断增加,直到崩溃。定期释放您不再需要的内存的垃圾回收器有助于消除这类错误。当然,这是有代价的,因为垃圾回收器会使事情变慢。

3
静态和栈的问题是什么?
“静态”分配的问题在于分配在编译时完成:您无法使用它来分配一些变量数量的数据,其数量直到运行时才知道。
在“栈”上分配的问题在于,分配在进行分配的子例程返回后立即被销毁。
我能写一个完整的应用程序而不在堆中分配变量吗?
也许可以,但不能是非平凡、正常、大型的应用程序(但所谓的“嵌入式”程序可能使用C++的子集而不使用堆)。
垃圾回收器是做什么的?
它会持续观察您的数据(“标记和清除”),以检测您的应用程序何时不再引用它。这对应用程序很方便,因为应用程序不需要处理数据的释放...但垃圾回收器可能会计算成本高昂。
垃圾收集器不是C ++编程的常见功能。
通过自己操作内存,您可以做些什么,使用此垃圾回收器无法做到?
学习C++的确定性内存回收机制:
- 'static':永远不会被释放 - 'stack':变量“超出范围”时立即释放 - 'heap':当指针被删除时(应用程序明确删除或在某些子例程内隐式删除)

1

当堆栈太“深”并且溢出可用于堆栈分配的内存时,堆栈内存分配(函数变量、局部变量)可能会出现问题。堆是为需要从多个线程或整个程序生命周期中访问的对象而设计的。您可以编写一个完整的程序,而不使用堆。

如果没有垃圾收集器,您很容易泄漏内存,但是您也可以控制何时释放对象和内存。当Java运行GC并且我有一个实时进程时,我遇到了问题,因为GC是一个独占线程(没有其他东西可以运行)。因此,如果性能至关重要并且您可以保证没有泄漏的对象,则不使用GC非常有帮助。否则,当应用程序消耗内存并且您必须跟踪泄漏源时,它只会让您讨厌生活。


1
如果你的程序在开始时不知道要分配多少内存(因此不能使用栈变量),会怎么样呢?比如说链表,链表的大小可能会增长,却无法预先知道它的大小。因此,当你不知道会插入多少元素时,将链表分配到堆上是有意义的。

0
在某些情况下,GC的优点可能会成为其他情况下的烦恼;依赖GC会鼓励人们不去过多考虑它。理论上,GC会等待“空闲”时期或者绝对必要时才进行垃圾回收,但这会占用带宽并导致应用程序响应延迟。
但是你不必“不去考虑它”。就像在多线程应用程序中的其他所有事物一样,当你可以让步时,你就可以让步。例如,在.Net中,可以请求进行GC;通过这样做,你可以拥有更频繁、更短的GC,而不是较少但运行时间更长的GC,并分散与此开销相关的延迟。
但这会破坏GC的主要吸引力,即“由于它是自动的,所以鼓励人们不必过多考虑它”。
如果你在GC普及之前首次接触编程,并且习惯于使用malloc/free和new/delete,那么你可能会发现GC有点烦人和/或不信任(就像对“优化”不信任一样,因为它曾经历过波折的历史)。许多应用程序容忍随机延迟。但对于不能容忍随机延迟的应用程序,常见的反应是避免使用GC环境,并朝着纯非托管代码(或者可怕的、濒临消亡的汇编语言)的方向发展。
我之前有一位实习生,是个聪明的孩子,他一直在使用GC,甚至在用非托管的C/C++编程时也拒绝遵循malloc/free new/delete模型,因为“现代编程语言不应该这样做”。你知道吗?对于小型、短时间运行的应用程序,你确实可以这样做,但对于长时间运行的高性能应用程序,就不行了。

0

栈是编译器分配的内存,每当我们编译程序时,默认情况下编译器会从操作系统中分配一些内存(我们可以在IDE的编译器设置中更改设置),而操作系统是提供内存的来源,它取决于系统上可用的内存和其他许多因素。至于栈内存,当我们声明一个变量时,它们被复制(作为形式参数)并推送到栈上,它们默认遵循一些命名约定,如Visual Studios中的CDECL。 例如:中缀表示法: c=a+b; 栈的推送是从右到左进行的,将b推入栈中,然后是运算符、a和这些变量的结果即c。 前缀表示法: =+cab 在这里,所有变量都首先被推入栈中(从右到左),然后进行操作。 编译器分配的这种内存是固定的。因此,假设我们的应用程序分配了1MB的内存,假设变量使用了700kb的内存(除非它们是动态分配的本地变量,否则所有本地变量都会被推送到栈上),那么剩余的324kb内存将被分配给堆。 这个栈的生命周期较短,当函数的范围结束时,这些栈就会被清除。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接