为什么不能修剪数组?


70

在MSDN文档站点上,该Array.Resize方法说明如下:

如果newSize大于旧数组的Length,则分配一个新数组,并将所有元素从旧数组复制到新数组。

如果newSize小于旧数组的长度,则分配一个新数组,并将元素从旧数组复制到新数组,直到填充新数组为止。旧数组中的其余元素将被忽略。

数组是一系列相邻的存储块。如果我们需要更大的数组,我知道我们无法向其添加内存,因为它旁边的内存可能已经被其他一些数据占用。因此,我们必须声明一个新的相邻存储块序列,并具有所需的更大大小,然后在其中复制条目并删除对旧空间的声明。

但是,为什么要创建一个较小的新数组?为什么阵列不能仅仅删除其对最后一个存储块的要求?那么它将是O(1)运算,而不是现在的O(n)。

它与在计算机体系结构或物理级别上如何组织数据有关吗?


2
传统上,使用Cmalloc和和realloc有时会realloc移动对象,有时却不会。有时这样做的原因之一是内存管理器为小内存块提供了一个单独的池。将大内存块减少到小内存块可能需要将其移动到单独的池中。但是我不能说.NET是否相同。

1
尽管这是推测,但在新的内存位置中分配空间仍然可以选择该位置,以便通过选择阵列“最适合”的新位置来最大程度地减少内存碎片。
Codor)2016年

1
@Codor这不是在数组的原始位置中创建更大的空洞(碎片)吗?
Zein Makki

5
C#中的堆与C或C ++堆绝对不相似。C#被垃圾收集。
doug65536 '16

2
装箱后,仅将单个字节存储在堆中。然后,它至少占用12个字节而不是1个字节。使ArrayList成为不受欢迎的类:)
Hans Passant

Answers:


22

要回答您的问题,它与内存管理系统的设计有关。

从理论上讲,如果您正在编写自己的内存系统,则可以完全将其设计为完全按照您所说的方式运行。

问题就变成了为什么它没有这样设计。答案是,内存管理系统在内存的有效使用与性能之间进行了权衡。

例如,大多数内存管理系统不会管理低至字节的内存。相反,它们将内存分成8 KB的块。造成这种情况的原因很多,其中大部分与性能有关。

部分原因与处理器移动内存的能力有关。例如,假设处理器一次复制8 KB数据要比复制4 KB好得多。然后,将数据存储在8 KB的块中会带来性能优势。那将是基于CPU架构的设计折衷。

还有算法性能的折衷。例如,通过研究大多数应用程序的行为,您发现应用程序有99%的时间分配大小为6 KB至8 KB的数据块。

如果内存系统允许您分配和释放4KB,则将剩下一个带有可用的4KB块的块,而99%的分配将无法使用。如果即使只需要4KB而不是过度分配给8 KB,它也将具有更高的可重用性。

考虑另一种设计。假设您有一个空闲内存位置列表,该列表可以是任意大小,并且请求分配2KB内存。一种方法是查看可用内存列表,然后找到至少2KB的内存,但是您要查看整个列表以找到最小的块,还是找到第一个足够大的块并使用那。

第一种方法效率更高,但速度较慢,第二种方法效率较低,但速度更快。

在具有“托管内存”的C#和Java这样的语言中,它变得更加有趣。在托管内存系统中,内存甚至没有被释放。它只是停止使用,垃圾收集器稍后(在某些情况下要晚得多)检测并释放。

有关不同的内存管理和分配的更多信息,您可能想在Wikipedia上查看此文章:

https://zh.wikipedia.org/wiki/内存管理


另外,如果系统允许您“释放”已分配块的末尾,那么您将得到更多“奇数块”,从而使“空闲列表”杂乱无章-例如,您分配了7k,将其修整为5k,您将剩下2k的免费块,可能无法满足大多数将来的分配要求。经过许多这样的“修剪”之后,您可能会有很多可用内存,但是它们全部都在很小的(2-3k)块中,并且几乎不可用。重新分配“修剪”将最小化这些剩余的块。
TripeHound

4
尽管这是对malloc / realloc的恰当描述,但它并没有真正解释它在C#中的工作方式(这是OP的问题)。现在,MS.NET从堆的顶部进行分配(就像使用堆栈一样),并依靠堆压缩来“释放”内存。真正的原因是安全性和正确性,而不是堆分配模式。
a安

@Luaan我认为这暗示着Kjara问题的核心不仅仅是C#。在问题中,他提到了诸如“内存块”之类的广泛概念,以及原因是由于“计算机体系结构还是物理级别”。没错,.NET使用堆是正确的,尽管我认为这是一种内存分配模式。我还要指出,“部分解除分配”本可以建立在这种模式中。因此,对于为什么您无法在C#中做到这一点,我可以给出的唯一具体答案是,因为.NET并非旨在支持它。为什么不设计来支持它?我们只能推测。
路易斯·佩雷斯

这不是一个准确的答案。它在8KB值后面调用未指定的魔术,不存在。您可能正在谈论虚拟内存页面大小,在Itanium上为8KB。这与有效的内存访问没有多大关系,处理器缓存的作用要大得多。特别是第一个L1,其存储单元为64个字节。数据对齐是避免低效使用缓存的关键,细节在.NET中很好地隐藏了,它在StructLayoutAtttribute.Pack属性中四处寻找。
汉斯·帕桑

35

未使用的内存实际上并未被使用。任何堆实现的工作都是跟踪堆中的漏洞。至少,管理人员需要知道孔的大小,并需要跟踪其位置。那总是花费至少8个字节。

在.NET中,System.Object扮演着关键角色。每个人都知道它在做什么,什么都不是很明显,以至于在收集到一个对象之后它仍然存在。然后,对象标头中的两个额外字段(syncblock和type handle)变成指向上一个/下一个空闲块的后退和前进指针。它还具有最小大小,在32位模式下为12个字节。保证在收集对象后始终有足够的空间来存储可用的块大小。

因此,您现在可能会看到问题,减小数组的大小并不能保证创建的孔足以容纳这三个字段。它什么也做不了,只能抛出一个“不能做”的异常。还取决于过程的位数。完全太难看了。


1
“它什么也做不了,但是抛出了“不能做那个”异常”-好吧,在这种情况下,您可以定义它什么也不做,否则会更新存储数组大小的字段。毕竟,在这些情况下,您只是稍微减小了阵列的大小,因此很有可能将复制到其中的新内存分配实际上消耗的内存资源与旧内存一样多。我认为完整的答案还不止于此,但这取决于人们愿意多大程度地对系统设计进行整理,然后再使用调整功能对其进行重新整理。
史蒂夫·杰索普

3
不仅如此,数组没有“容量”字段。对于这样的次要功能,将该字段添加到每个数组对象中非常困难。数组的效率影响一切,除LinkedList之外的所有集合类型都在后台使用数组。
汉斯·帕桑

哦,所以如果你问了一小阵,如2元,不.NET给你的内存分配,这是正是为足够大,并在最后没有松弛,因此没有必要对大小字段从内存分配器的分离块有多大的概念?我说“复制到它的新内存分配实际上会消耗掉同样多的内存资源”是所有犯规的谎言吗?
史蒂夫·杰索普

6
@Steve Jessop:具有可变的数组大小会影响优化器的工作,这在预测循环是否可能跨越数组边界时依赖于长度的不变性。具有可变的大小意味着必须在每个循环的每次迭代中重新检查条件。这种性能下降将超过不更改大小而无需复制的任何优势(在某些情况下)。
Holger

@霍尔格:很公平,尽管如果这是真正的原因,那汉斯怎么说呢?我不会为闲杂的猜测而烦恼我们中的任何一个,因为可以将数据流分析的多少时间用于提升循环不变量:有些但绝不是全部。
史蒂夫·杰索普

21

我一直在寻找您问题的答案,因为我发现这是一个非常有趣的问题。我发现这个答案有有趣的第一行:

您不能释放数组的一部分-您只能释放从中获取free()的指针,malloc()当您这样做时,您将释放所要求的所有分配。

因此,实际的问题是保留分配内存的寄存器。您不能只释放已分配的块的一部分,而必须完全释放它,或者根本不释放它。这意味着要释放该内存,您必须先移动数据。我不知道.NET内存管理在这方面是否做得特别,但是我认为该规则也适用于CLR。


1
请注意,此答案引用的是C答案,而不是C#答案。在C#中,没有在后台调用`malloc`。
MSalters

1
我不这么认为,malloc转到了CRT(C运行时),后者又不得不询问操作系统。我认为CLR直接适用于操作系统。
MSalters '16

2
是?如果要调用HeapAllocCLR(我希望CLR使用的标准Windows函数),则必须用某种语言编写该调用。鉴于Microsoft不维护C编译器(MSVC ++停留在20世纪,甚至不支持C99),因此逻辑选择是C ++。但是您甚至可以从不安全的C#代码进行该调用。
MSalters '16

10
这些评论不是很准确。当内存使用成为瓶颈时,std :: shared_pointer是C#代码击败C ++代码的最基本原因。摊销内存障碍的成本使其领先。CLR不使用HeapAlloc,仅使用VirtualAlloc。VS2015很好地支持C99,这是C ++ 11合规性所必需的。他们完全重写了前端,原始的前端仅设计为仅可使用256 KB内存进行编译。足以编译CLR了:)
汉斯·帕桑

2
汉斯在回答中很好地描述了.NET运行时通过此类操作所面临的(非常不同的)问题-有点类似于free,但并非如此。您可以释放一部分内存,但是您需要保留旧的对象引用,因为在收集对象时它会变成“空闲指针”。这意味着您无论如何都无法就地调整大小-即使有足够的可用空间容纳下一个对象标头,您仍然需要移动数组的开始,这意味着复制所有元素。分配一个新数组更好。
a安

6

我认为这是因为旧阵列没有被破坏。如果在其他地方引用它,它仍然存在,并且仍然可以访问。这就是在新的内存位置中创建新阵列的原因。

例:

int[] original = new int[] { 1, 2, 3, 4, 5, 6 };
int[] otherReference = original; // currently points to the same object

Array.Resize(ref original, 3);

Console.WriteLine("---- OTHER REFERENCE-----");

for (int i = 0; i < otherReference.Length; i++)
{
    Console.WriteLine(i);
}

Console.WriteLine("---- ORIGINAL -----");

for (int i = 0; i < original.Length; i++)
{
    Console.WriteLine(i);
}

印刷品:

---- OTHER REFERENCE-----
0
1
2
3
4
5
---- ORIGINAL -----
0
1
2

1
int[] oldRef = old将引用(即浅表副本)复制到数组。
CadentOrange

@CadentOrange正是我的意思。
Zein Makki

1
这不是我的问题“为什么不需要复制数组时为什么要首先复制数组?”的答案。它仅显示复制而不是修剪数组的结果。
卡扎拉

3
@ user3185569我知道。我的问题是为什么没有修整功能。
贾拉

1
@Kjara我认为您不理解答案的精神。它说:“您不能调整现有阵列的大小,因为其他人可能同时使用同一阵列,而这取决于掩护下的长度不变”。内存安全在.NET中很重要-如果仅通过精心设计就可以使缓冲区溢出,那么如何拥有内存安全Array.Resize?:)
六安'16

5

如此定义realloc的原因有两个:首先,它非常清楚地表明,不能保证以较小的大小调用realloc会返回相同的指针。如果您的程序做出了这样的假设,则您的程序将被破坏。即使指针在99.99%的时间内是相同的。如果在大量的空白空间中间有一个大的块右击,从而导致堆碎片,那么realloc可以自由地将其移开。

其次,在某些实现中绝对需要这样做。例如,MacOS X具有一种实现方式,其中一个大内存块用于分配1到16字节的malloc块,另一个大内存块用于17到32字节的malloc块,一个大内存块用于33到48字节的malloc块,等等。这很自然地使任何大小变化(保持在33到48字节范围内)都返回相同的块,但是更改为32或49字节必须重新分配该块。

无法保证realloc的性能。但是实际上,人们并没有使尺寸变小。主要情况是:将内存分配给所需大小的估计上限,将其填满,然后将其大小调整为实际小得多的所需大小。或分配内存,然后在不再需要时将其大小调整为很小的大小。


5

.NET运行时的设计者只能告诉您他们的实际推理。但是我的猜测是,内存安全在.NET中至关重要,并且维持内存安全和可变数组长度都非常昂贵,更不用说数组中任何代码的复杂程度了。

考虑简单的情况:

var fun = 0;
for (var i = 0; i < array.Length; i++)
{
  fun ^= array[i];
}

为了维护内存安全,每个 array访问都必须经过边界检查,同​​时确保边界检查不会被其他线程破坏(.NET运行时比C编译器有更严格的保证)。

因此,您需要一个线程安全的操作,该操作从数组中读取数据,同时检查边界。CPU上没有这样的指令,因此您唯一的选择是某种同步原语。您的代码变成:

var fun = 0;
for (var i = 0; i < array.Length; i++)
{
  lock (array)
  {
    if (i >= array.Length) throw new IndexOutOfBoundsException(...);

    fun ^= array[i];
  }
}

不用说,这太贵了。使数组长度不变,将为您带来两个巨大的性能提升:

  • 由于长度不能更改,因此边界检查不需要同步。这使得每个单独的边界检查便宜得多。
  • ...,如果可以证明这样做的安全性,则可以省略边界检查。

实际上,运行时实际所做的最终类似于以下内容:

var fun = 0;
var len = array.Length; // Provably safe

for (var i = 0; i < len; i++)
{
  // Provably safe, no bounds checking needed
  fun ^= array[i];
}

您最终会陷入一个紧密的循环,这与C语言中的情况没什么不同-但同时,它是完全安全的。

现在,让我们看一下添加数组以所需方式缩小的利弊:

优点:

  • 在极少数情况下,您想使数组更小,这意味着不需要复制数组即可更改其长度。但是,将来仍然需要进行堆压缩,这涉及大量复制。
  • 如果将对象引用存储在数组中,如果数组和项目的分配恰好位于同一位置,则可以从缓存局部性中获得一些好处。不用说,这甚至比Pro#1稀有。

缺点:

  • 即使在紧密的循环中,任何数组访问都将变得非常昂贵。所以每个人都会使用unsafe代码来代替代码,从而确保了内存的安全性。
  • 处理数组的每一段代码都必须期望数组的长度可以随时更改。每个单个数组访问都需要一个try ... catch (IndexOutOfRangeException),并且每个在数组上进行迭代的人都需要能够处理不断变化的大小-想知道为什么您不能List<T>在迭代中添加或删除项吗?
  • 对于CLR团队来说,大量的工作无法用在另一个更重要的功能上。

有一些实现细节,使它的好处更少。最重要的是,.NET堆与malloc/free模式无关。如果我们排除LOH,则当前MS.NET堆的行为将完全不同:

  • 分配总是从顶部开始,就像在堆栈中一样。与相比,这使分配几乎与堆栈分配一样便宜malloc
  • 由于分配模式,要实际上“释放”内存,必须压缩在执行收集后堆。这将移动对象,以便填充堆中的可用空间,从而降低堆的“顶部”,从而使您可以在堆中分配更多对象,或者仅释放内存以供系统上的其他应用程序使用。
  • 为了帮助保持缓存的局部性(假设经常一起使用的对象也彼此靠近分配,这是一个很好的假设),这可能涉及将堆中释放空间上方的每个对象向下移动。因此,您可能已经为自己保存了100字节数组的副本,但是无论如何您都必须移动100 MiB其他对象。

此外,正如汉斯在回答中很好地解释的那样,由于对象标头(请记住,.NET是如何为内存安全性吗?运行时必须知道对象的正确类型)。但是他没有指出的是,即使您确实有足够的内存,您仍然需要移动该数组。考虑一个简单的数组:

ObjectHeader,1,2,3,4,5

现在,我们删除最后两个项目:

OldObjectHeader;NewObjectHeader,1,2,3

哎呀。我们需要旧的对象标头来保留可用空间列表,否则我们将无法正确压缩堆。现在,可以完成将旧对象标头移到数组之外以避免复制的操作,但这又是一个麻烦。实际上,对于noöne永远不会使用的某些功能来说,这是相当昂贵的功能。

而这一切仍在托管的世界中。但是.NET的设计允许您在必要时使用不安全的代码-例如,在与非托管代码进行互操作时。现在,当您要将数据传递到本机应用程序时,有两个选择-固定托管句柄以防止其被收集和移动,或者复制数据。如果您正在进行简短的同步调用,则固定非常便宜(尽管更危险-本机代码没有任何安全保证)。例如,像在图像处理中那样,在紧密的循环中处理数据也是如此-复制数据显然不是一种选择。如果您允许Array.Resize更改现有阵列,则将完全中断-因此Array.Resize,则需要检查是否要与数组关联的句柄要调整大小,并在发生这种情况时引发异常。

更多的复杂性,难以想象的原因(跟踪仅偶尔发生一次的错误会很有趣,因为这种错误恰好会发生在Array.Resize尝试重新调整数组大小的过程中,而这种错误现在恰好固定了记忆)。

正如其他人所解释的那样,本机代码并没有更好。尽管您不需要维护相同的安全性保证(我不会真正从中受益,但是,哦,好吧),但是分配和管理内存的方式仍然存在一些复杂性。叫做realloc一个10个项目的5个项目的数组?好吧,要么复制它,要么仍然要保留10个项目的大小,因为无法以任何合理的方式回收剩余的内存。

因此,做一个简短的总结:您要的是一个非常昂贵的功能,在极其罕见的情况下,它的好处(如果有的话)非常有限,并且存在一个简单的解决方法(制作您自己的数组类) 。我看不出有没有通过“确定,让我们实现此功能!”的标准。:)


+1个不错的答案。不久前,我在Java空间(stackoverflow.com/a/35847639/5851520)回答了类似的问题,但是您的细节很酷。
SusanW '16

3

在任何堆管理系统中,可能都有许多复杂的数据结构在“幕后”运行。例如,它们可以根据其当前大小存储块。这将增加一个很多的并发症,如果块被允许“分裂,生长和萎缩。” (而且,这真的不会使事情“更快”。)

因此,实现总是做一件安全的事情:它分配一个新块,并根据需要移动值。众所周知,“该策略将在任何系统上始终可靠地工作”。而且,它根本不会减慢速度。


2

在幕后,数组存储在连续的内存块中,但在许多语言中仍是原始类型。

为了回答您的问题,分配给数组的空间被视为单个块,并在stack局部变量或bss/data segments全局变量的情况下进行存储。AFAIK,当您array[3]在较低级别访问类似的数组时,OS将为您提供指向第一个元素的指针,并跳转/跳过直到达到所需的块(在上述示例中为三次)。因此,可能是一项体系结构决定,即数组大小一旦声明就不能更改。

类似地,操作系统在访问所需索引之前无法知道它是否为数组的有效索引。当它尝试通过该jumping过程之后到达内存块来访问请求的索引,并发现到达的内存块不是数组的一部分时,它将抛出一个Exception


2
操作系统如何确定存储块是否不属于阵列?它是否不仅需要知道数组的起点,还需要知道数组的大小?在这种情况下,更改大小的存储值(据我从您的回答中可以理解是位于堆栈上)将是所有要做的事情,对吗?
卡扎拉
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.