堆内存分配(例如堆中的标记)是否会带来内存开销?

9

特别针对使用最新的Visual Studio C++编译器在Windows上编写的C++代码,我想了解 堆(heap) 的实现:

假设我正在使用发布版编译器,并且不担心内存碎片/填充问题,那么在 堆(heap) 上分配内存是否会有内存开销?如果有,每次分配可能会有多少字节的开销?在64位代码中比32位更大吗?

我并不是很了解现代的 堆(heap) 实现,但我想知道每次分配时是否会在 堆(heap) 中写入标记,或者是否维护某种表格(如文件分配表)。

关于一个相关的问题(因为我主要考虑像'map'这样的标准库功能),Microsoft标准库实现是否会使用自己的分配器(例如树节点)以优化 堆(heap) 的使用?


2
通常在分配数组时,一些字节会存储在开头,并分配未来删除的大小。对于大型分配,开销几乎为零。如果您每次分配4个字节,它很可能会使内存翻倍。 - Luchian Grigore
3个回答

8

是的,绝对如此。

每个分配的内存块都具有一个常量开销的“头”,以及一个小的可变部分(通常在末尾)。这取决于使用的确切C运行时库。过去,我实验性地发现每个分配大约需要32-64字节。可变部分用于处理对齐 - 每个内存块将对齐到一些漂亮均匀的2^n基址,通常是8或16字节。

我不熟悉std :: map或类似方法的内部设计,但我非常怀疑它们在那里有特殊的优化。

您可以通过以下方法轻松测试开销:

char *a, *b;

a = new char;
b = new char;

ptrdiff_t diff = a - b;

cout << "a=" << a << " b=" << b << " diff=" << diff;

[注意,上面的a-b表达式会引发未定义行为,因为减去一个分配的内存块的地址和另一个地址的地址是未定义的行为。这是为了应对那些没有线性内存地址的机器,例如分段内存或“不同类型的数据基于其类型存储在不同位置”。上述内容肯定适用于任何不使用多个堆数据段的分段内存模型的x86操作系统 - 这意味着它肯定适用于Windows和Linux的32位和64位模式]。

您可能希望使用不同类型运行它 - 只需记住差异在于“类型的数量”,因此如果将其设置为int * a,* b将以“四字节单位”为单位。您可以使用reinterpret_cast<char *>(a) - reinterpret_cast<char *>(b);

[差异可能为负数,并且如果您在循环中运行此代码(而不删除ab),则可能会发现突然跳跃,其中一大块内存已耗尽,运行时库分配了另一个大块]


1
只要您不在多个线程中运行它,或者在调用new之间调用了一些在堆上分配内存的函数,那么它应该是没问题的。当然,这适用于使用一个大块内存来提供不同分配的堆...也可能有不这样做的分配器。此外,如果在到达此代码时,您已经对底层堆进行了大量的alloc/free调用,则它可能会重用来自这些free调用的内存块-这些内存块可能当然是无序的。 - Mats Petersson
如果您有更好的方法来确定两个独立分配之间的距离,请随时提出建议。我知道这篇文章很旧了,但是您可以将指针转换为std::uintptr_t(这是我偏爱C风格转换的情况之一)。 - MikeMB
@MikeMB:嗯,是的,但它成为未定义行为的原因不是“它会爆炸并导致第三次世界大战爆发”,而是“实际结果未定义”——这是因为例如分段内存系统,没有明确定义的方法来确定位于不同段中的两个位置之间的字节数距离(x86保护模式分段与段的基地址之间没有直接联系,基地址在描述符表中,这对用户模式代码不可用)。 - Mats Petersson
在这样的系统中,uintptr_t ai = (uintptr_t)a; uintptr_t bi = (uintptr_t)b; 只会显示大约32GB的差异,因为 segAsegB 相差8,存储在 uintptr_t 的上半部分,而0存储在下半部分。同样的效果也可以在基于GPU的系统中找到,该系统具有“本地”和“全局”内存段。即使您可以将它们转换为整数,但是您不能有意义地获取不同地址空间(段)中指针之间的差异。[使用 reinterpret_cast<uintptr_t>(a) 将是我的选择]。 - Mats Petersson
@MatsPetersson:也许我误解了您的意思,但是:如果您在一个带有分段内存的平台上,那么无论您是否将指针强制转换为'char *'或 'uintptr_t',您都不会得到可靠的答案。然而,当您在一个具有平面内存空间的系统上时,使用'char *'版本仍然属于未定义行为,而 'uintptr_t'则可以给您一个明确定义的答案。 - MikeMB
显示剩余7条评论

4
Visual C++在分配的缓冲区边界附近嵌入控制信息(链接/大小和可能的某些校验和)。这也有助于在内存分配和释放期间捕获一些缓冲区溢出。
此外,你应该记住,malloc()需要返回适合所有基本类型(char、int、long long、double、void*、void(*)())的指针对齐,并且对齐通常是最大类型的大小,因此可能是8或甚至16个字节。如果你分配一个单独的字节,仅对齐就会丢失7到15个字节。我不确定operator new是否具有相同的行为,但很可能是这种情况。
这应该给你一个概念。精确的内存浪费只能从文档(如果有)或测试中确定。语言标准没有以任何术语定义它。

2
是的。所有实用的动态内存分配器都有一个最小粒度1。例如,如果粒度为16字节,并且您仅请求1字节,则仍然分配整个16字节。如果您请求17字节,则分配32字节大小的块等等...
还有一个(相关的)对齐问题。2 相当多的分配器似乎是大小映射和空闲列表的组合 - 它们将潜在的分配大小拆分为“桶”,并为每个桶保留单独的空闲列表。看看Doug Lea's malloc。还有许多其他具有各种权衡的分配技术,但这超出了此处的范围...
通常为8或16字节。如果分配器使用自由列表,则必须在每个空闲插槽内编码两个指针,因此一个空闲插槽不能小于8个字节(在32位上)或16个字节(在16位上)。例如,如果分配器尝试分割一个8字节的插槽以满足4字节的请求,则剩余的4字节将没有足够的空间来编码自由列表指针。
例如,如果你的平台上的long long是8字节,那么即使分配器的内部数据结构可以处理比这更小的块,实际分配较小的块也可能会把下一个8字节的分配推到非对齐的内存地址。

感谢您提供的信息和malloc链接,@Branko,这似乎是一个非常好的堆管理设计基础介绍。 - Coder_Dan

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