哪个更快:堆栈分配或堆分配

Ada*_*dam 489 c++ memory heap performance stack

这个问题可能听起来相当简单,但这是我与另一位与我合作的开发人员的辩论.

我正在小心处理堆栈分配的东西,而不是堆分配它们.他正在跟我说话,看着我的肩膀并评论说这没有必要,因为他们的表现是明智的.

我一直认为堆栈的增长是恒定的时间,并且堆分配的性能取决于堆的当前复杂性(用于找到合适大小的孔)和解除分配(折叠孔以减少碎片,如如果我没有弄错的话,许多标准库实现在删除期间需要时间来完成此操作.

这让我觉得可能非常依赖于编译器.特别是对于这个项目,我使用Metrowerks编译器来实现PPC架构.对这种组合的洞察力将是最有帮助的,但总的来说,对于GCC和MSVC++,情况如何?堆分配不如堆栈分配高吗?没有区别吗?或者差异是如此微小,它变得毫无意义的微优化.

Tor*_*ing 482

堆栈分配要快得多,因为它真正做的就是移动堆栈指针.使用内存池,您可以从堆分配中获得可比较的性能,但这会带来轻微的复杂性和自身的麻烦.

此外,堆栈与堆不仅是性能考虑因素; 它还告诉你很多关于对象的预期寿命.

  • 更重要的是,堆栈总是很热,你得到的内存比任何远堆分配的内存更容易在缓存中 (204认同)
  • 在一些(主要是嵌入的,我所知道的)架构上,堆栈可以存储在快速片上存储器(例如SRAM)中.这可以产生巨大的差异! (45认同)
  • 因为堆栈实际上是一个堆栈.你不能释放堆栈使用的一块内存,除非它在它之上.没有管理,你推动或弹出它.另一方面,堆内存被管理:它向内核请求内存块,可能会拆分它们,合并它们,重用它们并释放它们.堆栈实际上是用于快速和短期分配. (36认同)
  • @Pacerier因为Stack比Heap小很多.如果要分配大数组,最好在堆上分配它们.如果你试图在Stack上分配一个大数组,它会给你一个Stack Overflow.以C++为例:int t [100000000]; 试试例如t [10000000] = 10; 然后cout << t [10000000]; 它应该给你一个堆栈溢出或只是不会工作,不会向你显示任何东西.但是如果你在堆上分配数组:int*t = new int [100000000]; 并且之后执行相同的操作,它将起作用,因为Heap具有这么大的数组所需的大小. (24认同)
  • @Pacerier最明显的原因是,在退出分配的块时,堆栈上的对象超出了范围. (7认同)
  • @Benoît你能解释为什么不把所有东西都存放在堆栈上?堆的重点是什么? (2认同)
  • @Benoît - 您的评论帮助我联系了很多想法。堆栈是从编译代码分配的内存;计算进行一次并在编译期间缓存。Heap 是运行时分配的内存;计算是在程序运行时进行的 - 在脚本运行之前不会缓存计算。脚本语言(例如 Javascript)不会被编译,当代码在浏览器中运行时,所有内存都分配给堆。在像 C++ 这样的语言中,数组从编译后的代码中将内存分配给堆栈,向量(运行时数组)将内存分配给堆。 (2认同)

Dan*_*ski 165

堆栈速度更快.在大多数情况下,例如在x86上,它实际上只在大多数体系结构上使用单个指令:

sub esp, 0x10
Run Code Online (Sandbox Code Playgroud)

(这会将堆栈指针向下移动0x10字节,从而"分配"这些字节以供变量使用.)

当然,堆栈的大小非常非常有限,因为您将很快发现是否过度使用堆栈分配或尝试进行递归:-)

此外,没有理由优化不可验证地需要它的代码的性能,例如通过分析证明."过早优化"通常会导致更多问题,而不是它的价值.

我的经验法则:如果我知道我将在编译时需要一些数据,并且它的大小在几百字节之内,我会进行堆栈分配.否则我堆分配它.

  • 一条指令,通常由堆栈中的所有对象共享. (20认同)
  • 请记住这里的"隐藏"成本,尤其是第一次扩展堆栈时.这样做可能会导致页面错误,上下文切换到内核需要做一些工作来分配内存(或者在最坏的情况下从交换中加载). (14认同)
  • 明确指出,特别是关于可验证地需要它的观点.我不断惊讶于人们对表现的担忧是如何错位的. (9认同)
  • "释放"也非常简单,只需单个"离开"指令即可完成. (6认同)
  • 在某些情况下,您甚至可以分配0条指令。如果知道需要分配多少字节的信息,则编译器可以在分配其他堆栈变量的同时提前分配它们。在这种情况下,您根本不需支付任何费用! (2认同)

Max*_*ert 117

老实说,编写一个比较性能的程序是微不足道的:

#include <ctime>
#include <iostream>

namespace {
    class empty { }; // even empty classes take up 1 byte of space, minimum
}

int main()
{
    std::clock_t start = std::clock();
    for (int i = 0; i < 100000; ++i)
        empty e;
    std::clock_t duration = std::clock() - start;
    std::cout << "stack allocation took " << duration << " clock ticks\n";
    start = std::clock();
    for (int i = 0; i < 100000; ++i) {
        empty* e = new empty;
        delete e;
    };
    duration = std::clock() - start;
    std::cout << "heap allocation took " << duration << " clock ticks\n";
}
Run Code Online (Sandbox Code Playgroud)

据说愚蠢的一致性是小脑袋的大人物.显然优化编译器是许多程序员心目中的大地精.这个讨论过去是在答案的最底层,但人们显然无法阅读那么远,所以我将它移到这里以避免得到我已经回答过的问题.

优化编译器可能会注意到此代码不执行任何操作,并且可能会将其全部优化.做这样的事情是优化者的工作,与优化器作斗争是一个愚蠢的差事.

我建议在关闭优化的情况下编译此代码,因为没有好方法可以欺骗当前正在使用的每个优化器或将来使用的优化器.

任何打开优化器然后抱怨打架的人应该受到公众的嘲笑.

如果我关心纳秒精度,我就不会用std::clock().如果我想将结果作为博士论文发表,我会就此做出更大的贡献,我可能会比较GCC,Tendra/Ten15,LLVM,Watcom,Borland,Visual C++,Digital Mars,ICC和其他编译器.实际上,堆分配需要比堆栈分配长几百倍,而且我没有看到任何进一步调查问题的任何有用信息.

优化器的任务是摆脱我正在测试的代码.我没有看到任何理由告诉优化器运行然后尝试欺骗优化器而不是实际优化.但如果我看到这样做的价值,我会做以下一个或多个:

  1. 添加数据成员empty,并在循环中访问该数据成员; 但如果我只读取数据成员,优化器可以进行常量折叠并删除循环; 如果我只写入数据成员,优化器可能会跳过除循环的最后一次迭代之外的所有迭代.此外,问题不是"堆栈分配和数据访问与堆分配和数据访问".

  2. 声明e volatile,volatile通常编译不正确(PDF).

  3. 获取e循环内部的地址(并可能将其分配给extern在另一个文件中声明和定义的变量).但即使在这种情况下,编译器可能会注意到 - 至少在堆栈上 - e将始终在相同的内存地址分配,然后像上面的(1)那样进行常量折叠.我获得了循环的所有迭代,但实际上从未分配对象.

除了显而易见的,这个测试是有缺陷的,因为它测量分配和释放,原始问题没有询问释放.当然,在堆栈上分配的变量会在其作用域的末尾自动解除分配,因此不会调用delete(1)使数字倾斜(堆栈释放包含在堆栈分配的数字中,因此测量堆重新分配是公平的)和( 2)导致相当糟糕的内存泄漏,除非我们保留对新指针的引用并delete在我们进行时间测量后调用.

在我的机器上,在Windows上使用g ++ 3.4.4,对于任何小于100000分配的内容,我得到堆栈和堆分配的"0时钟滴答",即使这样,我得到堆栈分配的"0时钟滴答"和"15个时钟滴答" "用于堆分配.当我测量10,000,000个分配时,堆栈分配需要31个时钟周期,堆分配需要1562个时钟周期.


是的,优化编译器可能会忽略创建空对象.如果我理解正确,它甚至可能会忽略整个第一个循环.当我将迭代次数提高到10,000,000时,堆栈分配需要31个时钟周期,堆分配需要1562个时钟周期.我认为可以肯定地说,在没有告诉g ++来优化可执行文件的情况下,g ++并没有忽略构造函数.


自从我写这篇文章以来的几年里,Stack Overflow的偏好就是从优化版本中发布性能.总的来说,我认为这是正确的.但是,我仍然认为,当您实际上不希望优化代码时,要求编译器优化代码是愚蠢的.这让我觉得非常类似于为代客泊车支付额外费用,但拒绝交出钥匙.在这种特殊情况下,我不希望优化器运行.

使用略微修改的基准测试版本(以解决原始程序每次在循环中没有在堆栈上分配某些内容的有效点)并进行编译而不进行优化但链接到发布库(以解决我们不知道的有效点)我想包括链接到调试库导致的任何减速:

#include <cstdio>
#include <chrono>

namespace {
    void on_stack()
    {
        int i;
    }

    void on_heap()
    {
        int* i = new int;
        delete i;
    }
}

int main()
{
    auto begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_stack();
    auto end = std::chrono::system_clock::now();

    std::printf("on_stack took %f seconds\n", std::chrono::duration<double>(end - begin).count());

    begin = std::chrono::system_clock::now();
    for (int i = 0; i < 1000000000; ++i)
        on_heap();
    end = std::chrono::system_clock::now();

    std::printf("on_heap took %f seconds\n", std::chrono::duration<double>(end - begin).count());
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

显示:

on_stack took 2.070003 seconds
on_heap took 57.980081 seconds
Run Code Online (Sandbox Code Playgroud)

在使用命令行编译时在我的系统上cl foo.cc /Od /MT /EHsc.

您可能不同意我获取非优化构建的方法.这很好:随意修改基准测试.当我打开优化时,我得到:

on_stack took 0.000000 seconds
on_heap took 51.608723 seconds
Run Code Online (Sandbox Code Playgroud)

不是因为堆栈分配实际上是瞬时的,而是因为任何半合适的编译器都可以注意到on_stack它没有做任何有用的事情并且可以被优化掉.我的Linux笔记本电脑上的GCC也注意到on_heap它没有做任何有用的事情,并且也将其优化掉:

on_stack took 0.000003 seconds
on_heap took 0.000002 seconds
Run Code Online (Sandbox Code Playgroud)

  • 摆脱这样的代码是优化器的工作.是否有充分的理由打开优化器然后阻止它实际优化?我编写了答案,使事情变得更加清晰:如果您喜欢与优化器对抗,请准备好了解智能编译器的编写方式. (7认同)
  • 在没有优化的Windows(调试版本)上,它将使用比非调试堆慢得多的调试堆.我认为根本不"欺骗"优化器并不是一个坏主意.编译器编写者很聪明,但编译器不是AI. (4认同)
  • 我已经很晚了,但是这里也值得一提的是堆分配通过内核请求内存,因此性能的提升也很大程度上取决于内核的效率.在Linux上使用此代码(Linux 3.10.7-gentoo#2 SMP Wed Sep 4 18:58:21 MDT 2013 x86_64),修改HR计时器,并在每个循环中使用1亿次迭代产生这样的性能:`堆栈分配花了0.15354秒,堆分配花了0.834044秒`设置了`-O0`,使得Linux堆分配在我的特定机器上的速度只有5.5左右. (3认同)
  • 此外,您应该在主函数的最开始添加一个"校准"循环,以便了解每个循环周期的时间,并调整其他循环以确保您的示例运行一些时间,而不是你正在使用的固定常数. (2认同)
  • 我也很高兴增加每个选项循环运行的次数(加上指示g ++不优化?)已经取得了显着的成果.所以现在我们有很多事实可以说堆栈更快.谢谢你的努力! (2认同)
  • 微基准测试很难。您不能只是禁用优化,因为这会给您带来不切实际的代码生成:例如,将循环计数器保留在内存中,因此您会因存储转发延迟而在每 6 个时钟周期进行 1 次迭代。您肯定希望优化器优化您未测量的所有内容,并强制它执行您实际想要测量的工作。例如,将您的目标函数放在一个单独的文件中并禁用链接时优化,或者在函数上使用“[noinline]”。您可能需要 `volatile`。你通常必须检查 asm 以确保你得到了你想要的。 (2认同)

Fur*_*der 30

我在Xbox 360氙处理器上学习堆栈与堆分配的一个有趣的事情,也可能适用于其他多核系统,是在堆上分配导致关键部分被输入以停止所有其他核心,以便alloc不会没有冲突.因此,在紧密循环中,堆栈分配是固定大小的阵列的方法,因为它可以防止停顿.

如果您正在为多核/多进程编码,这可能是另一个要考虑的加速,因为您的堆栈分配只能由运行您的作用域函数的核心查看,并且不会影响任何其他核心/ CPU.

  • 这是堆分配器(特别差)实现的结果.更好的堆分配器不需要在每次分配时获得锁定. (14认同)
  • 大多数多核机器都是如此,而不仅仅是Xenon.甚至Cell也必须这样做,因为你可能在PPU核心上运行两个硬件线程. (4认同)

Chr*_*ung 18

您可以为特定大小的对象编写特殊的堆分配器,这些对象非常高效.但是,通用堆分配器不是特别高效.

我同意TorbjörnGyllebring关于物体的预期寿命.好点子!

  • 这有时被称为slab分配。 (2认同)

Fra*_*kHB 9

特定于 C++ 语言的问题

首先,C++ 没有要求所谓的“堆栈”或“堆”分配。如果您谈论的是块作用域中的自动对象,它们甚至不会被“分配”。(顺便说一句,C 中的自动存储持续时间与“分配”绝对不同;后者在 C++ 中是“动态”的。)动态分配的内存在free store 上,不一定在“堆”上,尽管后者通常是(默认)实现

虽然按照抽象机器语义规则,自动对象仍然占用内存,但当一个符合 C++ 的实现可以证明这无关紧要时(当它不改变程序的可观察行为时),允许忽略这个事实。此权限由ISO C++ 中的 as-if 规则授予,这也是启用常用优化的一般条款(在 ISO C 中也有几乎相同的规则)。除了 as-if 规则,ISO C++ 还必须复制省略规则允许省略特定的对象创建。因此省略了涉及的构造函数和析构函数调用。结果,与源代码隐含的幼稚抽象语义相比,这些构造函数和析构函数中的自动对象(如果有)也被消除了。

另一方面,免费商店分配绝对是设计上的“分配”。在 ISO C++ 规则下,这样的分配可以通过调用分配函数来实现。然而,从 ISO C++14 开始,有一个新的(non-as-if)规则允许::operator new在特定情况下合并全局分配函数(即)调用。因此,动态分配操作的部分也可以像自动对象的情况一样是无操作的。

分配函数分配内存资源。可以使用分配器基于分配进一步分配对象。对于自动对象,它们是直接呈现的——虽然底层内存可以被访问,并且可以用来为其他对象提供内存(通过放置new),但这作为自由存储没有多大意义,因为没有办法移动其他地方的资源。

所有其他问题都超出了 C++ 的范围。尽管如此,它们仍然很重要。

关于 C++ 的实现

C++ 不公开具体化的活动记录或某种一流的延续(例如著名的call/cc),无法直接操作活动记录框架 - 实现需要将自动对象放置到其中。一旦没有(不可移植的)与底层实现(“本机”不可移植代码,例如内联汇编代码)的互操作,框架的底层分配的省略就变得非常简单。例如,当被调用的函数被内联时,帧可以有效地合并到其他帧中,因此无法显示什么是“分配”。

但是,一旦尊重互操作,事情就会变得复杂。C++ 的典型实现将公开 ISA(指令集体系结构)上的互操作能力,其中一些调用约定作为与本机(ISA 级机器)代码共享的二进制边界。这将显着增加成本,特别是在维护堆栈指针时,堆栈指针通常由 ISA 级寄存器直接保存(可能需要访问特定的机器指令)。堆栈指针指示(当前活动的)函数调用的顶部帧的边界。当进入函数调用时,需要一个新的帧,并且堆栈指针被添加或减去(取决于 ISA 的约定)一个不小于所需帧大小的值。然后称该帧已分配当操作后的堆栈指针。函数的参数也可以传递到堆栈帧上,这取决于用于调用的调用约定。框架可以保存 C++ 源代码指定的自动对象(可能包括参数)的内存。在这种实现的意义上,这些对象是“分配的”。当控件退出函数调用时,不再需要该帧,通常通过将堆栈指针恢复到调用前的状态(根据调用约定之前保存的)来释放它。这可以看作是“解除分配”。这些操作使活动记录有效地成为一个 LIFO 数据结构,因此它通常被称为“ (调用)堆栈”。

由于大多数 C++ 实现(尤其是那些针对 ISA 级本机代码并使用汇编语言作为其直接输出的实现)使用类似的策略,因此这种令人困惑的“分配”方案很受欢迎。这种分配(以及释放)确实会花费机器周期,并且当(非优化)调用频繁发生时,它可能会很昂贵,即使现代 CPU 微体系结构可以通过硬件为通用代码模式(例如使用实现 /指令中的堆栈引擎)。PUSHPOP

但无论如何,总的来说,堆栈帧分配的成本确实比调用操作自由存储的分配函数(除非它完全优化掉)要少得多,它本身可以有数百个(如果不是数百万个) :-) 操作来维护堆栈指针和其他状态。分配函数通常基于托管环境提供的 API(例如操作系统提供的运行时)。与为函数调用保存自动对象的目的不同,这种分配是通用的,因此它们不会像堆栈那样具有帧结构。传统上,它们从称为(或多个堆)的池存储中分配空间。与“栈”不同,这里的“堆”概念并不表示正在使用的数据结构;它源自几十年前的早期语言实现。(顺便说一句,调用堆栈通常在程序或线程启动时由环境从堆中分配固定或用户指定的大小。)用例的性质使得从堆中分配和释放要复杂得多(比推或弹出堆栈帧),并且几乎不可能由硬件直接优化。

对内存访问的影响

通常的堆栈分配总是将新帧放在顶部,因此它具有相当好的局部性。这对缓存友好。OTOH,在免费存储中随机分配的内存没有这样的属性。从 ISO C++17 开始,有由<memory>. 这种接口的直接目的是允许连续分配的结果在内存中紧密地排列在一起。这承认这样一个事实,即该策略通常有利于当代实现的性能,例如对现代体系结构中的缓存友好。不过,这与access的性能有关,而不是allocation

并发

对内存的并发访问的预期可能会在堆栈和堆之间产生不同的影响。在 C++ 实现中,调用堆栈通常由一个执行线程独占所有。OTOH,堆通常在进程中的线程之间共享。对于此类堆,分配和解除分配功能必须保护共享的内部管理数据结构免受数据竞争的影响。因此,由于内部同步操作,堆分配和释放可能会有额外的开销。

空间效率

由于用例和内部数据结构的性质,堆可能会受到内部内存碎片的影响,而堆栈则不会。这对内存分配的性能没有直接影响,但在具有虚拟内存的系统中,低空间效率可能会降低内存访问的整体性能。当 HDD 用作物理内存的交换时,这尤其糟糕。它可能会导致相当长的延迟 - 有时是数十亿个周期。

堆栈分配的限制

虽然实际上栈分配在性能上往往比堆分配要好,但这并不意味着栈分配总是可以代替堆分配。

首先,无法使用 ISO C++ 以可移植的方式在运行时指定大小的堆栈上分配空间。alloca和 G++ 的 VLA(可变长度数组)之类的实现提供了扩展,但有理由避免它们。(IIRC,Linux 源最近删除了 VLA 的使用。)(另请注意,ISO C99 确实强制要求了 VLA,但 ISO C11 将支持变为可选。)

其次,没有可靠且便携的方法来检测堆栈空间耗尽。这通常称为堆栈溢出(嗯,本网站的词源),但可能更准确地说,堆栈溢出。实际上,这通常会导致无效的内存访问,然后程序的状态就会被破坏(……或者更糟的是,一个安全漏洞)。实际上,ISO C++ 没有“堆栈”的概念,并且在资源耗尽时使其成为未定义的行为。应谨慎为自动对象留出多少空间。

如果堆栈空间用完,则堆栈中分配的对象过多,这可能是由于函数的主动调用过多或自动对象使用不当造成的。这种情况可能表明存在错误,例如没有正确退出条件的递归函数调用。

然而,有时需要深度递归调用。在需要支持未绑定活动调用的语言的实现中(其中调用深度仅受总内存限制),不可能像典型的 C++ 实现那样直接使用(当代)本机调用堆栈作为目标语言激活记录。为了解决这个问题,需要构建活动记录的替代方法。例如,SML/NJ在堆上显式分配帧并使用仙人掌堆栈。此类活动记录帧的复杂分配通常不如调用堆栈帧快。但是,如果在保证适当的尾递归的情况下进一步实现这些语言,对象语言中的直接堆栈分配(即语言中的“对象”不存储为引用,而是可以一对一映射到非共享C++对象的本机原始值)甚至更复杂,更多一般的性能惩罚。在使用 C++ 实现此类语言时,很难估计性能影响。

  • 这个答案的得分应该比现在高得多。事实上,[没有堆栈](https://miro.medium.com/max/640/0*9Wll0yjE36YG3vl0.jpg)。 (2认同)

Mar*_*rkR 8

我不认为堆栈分配和堆分配通常是可互换的.我也希望它们的性能足以满足一般用途.

我强烈推荐小件物品,无论哪个更适合分配范围.对于大型项目,堆可能是必需的.

在具有多个线程的32位操作系统上,堆栈通常相当有限(尽管通常至少为几mb),因为地址空间需要被分割,迟早一个线程堆栈将运行到另一个线程堆栈中.在单线程系统(无论如何都是Linux glibc单线程)上,限制要少得多,因为堆栈可以增长和增长.

在64位操作系统上,有足够的地址空间使线程堆栈非常大.


Win*_*mer 6

通常,堆栈分配只包括从堆栈指针寄存器中减去.这比搜索堆快得多.

有时堆栈分配需要添加一个或多个虚拟内存页面.添加新的归零内存页面不需要从磁盘读取页面,因此通常这比搜索堆快得多(特别是如果堆的一部分也被分页).在极少数情况下,你可以构造这样一个例子,在堆已经在RAM中的部分堆中恰好可以使用足够的空间,但是为堆栈分配新页面必须等待其他页面被写出来到磁盘.在这种罕见的情况下,堆更快.

  • 这是一个例子.调用程序要求分配37个字节.库函数查找至少40个字节的块.空闲列表中的第一个块有16个字节.空闲列表中的第二个块有12个字节.第三个块有44个字节.图书馆在此时停止搜索. (4认同)

小智 6

除了堆分配的数量级性能优势之外,堆栈分配对于长时间运行的服务器应用程序更为可取.即使是最好的托管堆最终也会变得如此分散,以至于应用程序性能会下降.


lar*_*ivi 5

堆分配与堆栈分配的最大问题可能是,一般情况下堆分配是无界操作,因此在计时有问题的情况下不能使用它。

对于其他时间不是问题的应用程序,它可能并不那么重要,但如果堆分配很多,这将影响执行速度。始终尝试将堆栈用于短期内存和经常分配的内存(例如在循环中),并尽可能长时间使用 - 在应用程序启动期间进行堆分配。


yog*_*man 5

栈的容量有限,而堆则不然。进程或线程的典型堆栈约为 8K。大小一旦分配就无法更改。

堆栈变量遵循作用域规则,而堆变量则不然。如果指令指针超出函数范围,则与该函数关联的所有新变量都会消失。

最重要的是,您无法提前预测整个函数调用链。因此,仅仅分配 200 字节就可能会引发堆栈溢出。如果您正在编写库而不是应用程序,这一点尤其重要。


MSa*_*ers 5

这不仅仅是堆栈分配更快。使用堆栈变量也能带来很多好处。他们有更好的参考局部性。最后,解除分配也便宜得多。


wjl*_*wjl 5

正如其他人所说,堆栈分配通常要快得多。

但是,如果复制对象的成本很高,那么如果不小心的话,在堆栈上分配可能会导致稍后使用对象时性能受到巨大影响。

例如,如果您在堆栈上分配某些内容,然后将其放入容器中,那么最好在堆上分配并将指针存储在容器中(例如使用 std::shared_ptr<>)。如果您按值传递或返回对象以及其他类似的场景,情况也是如此。

关键是,尽管在许多情况下堆栈分配通常比堆分配更好,但有时如果您在不最适合计算模型时不遗余力地进行堆栈分配,则可能会导致比它解决的问题更多的问题。