为什么arrays不能被修剪?

在MSDN文档站点上,它提到有关Array.Resize方法的以下内容:

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

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

数组是一系列相邻的内存块。 如果我们需要一个更大的数组,我知道我们不能增加内存,因为它旁边的内存可能已经被其他一些数据所占用。 所以我们不得不要求所有更大尺寸的相邻存储块的新序列,复制我们的入口并删除旧空间的声明。

但为什么创build一个更小的新arrays? 为什么数组不能删除它的最后一个内存块的声明? 那么这将是一个O(1)操作,而不是O(n),就像现在一样。

这与数据在计算机体系结构或物理层面上的组织方式有关吗?

为了回答你的问题,它与内存pipe理系统的devise有关。

从理论上讲,如果你正在编写你自己的记忆体系统,你可以完全按照你所说的方式来devise它。

那么问题就变成了为什么不是这样devise的。 答案是内存pipe理系统在有效使用内存和性能之间进行了折衷。

例如,大多数内存pipe理系统不pipe理内存到字节。 相反,他们把内存分成8 KB大块。 这其中的大部分原因都是围绕着性能。

一些原因与处理器移动内存的方式有关。 例如,假设处理器在复制8 KB的数据的时候效果要好得多,而在复制4 KB的时候。 那么将数据存储在8 KB块中会有性能优势。 这将是一个基于CPU架构的devise权衡。

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

如果内存系统允许你分配和释放4KB,它将剩下一个空闲的4KB块,99%的分配将无法使用。 如果不是分配给8 KB,即使只需要4KB,也可以重复使用。

考虑另一种devise。 假设你有一个空闲的内存位置列表,可以是任意大小的,并且请求分配2KB的内存。 一种方法是查看空闲内存列表并find至less2KB大小的内存,但是您是否查看整个列表以查找最小的块,或者find第一个足够大并使用那。

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

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

欲了解更多信息不同的内存pipe理和分配,你可能想看看这篇文章在维基百科:

https://en.wikipedia.org/wiki/Memory_management

未使用的内存实际上并未使用。 任何堆实现的工作都是跟踪堆中的漏洞。 经理至less要知道洞的大小,并且需要跟踪他们的位置。 总是至less需要8个字节。

在.NET中,System.Object起着关键作用。 每个人都知道它做了什么,什么不是那么明显,以至于在一个物体被收集之后它会继续生存下去。 对象头中的两个额外字段(同步块和types句柄)然后变成前一个/下一个空闲块的前向和后向指针。 它也有一个最小的大小,在32位模式下是12个字节。 保证在收集对象后总是有足够的空间存储空闲块大小。

所以你现在可能会看到这个问题,减小数组的大小并不能保证创build一个足够大的空洞来适应这三个字段。 没有什么可以做,但抛出一个“不能这样做”的例外。 还取决于进程的位数。 完全太难看了。

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

你不能释放数组的一部分 – 你只能free()一个你从malloc()得到的指针,当你这样做的时候,你可以释放你所要求的全部分配。

所以实际上问题是保持分配内存的寄存器。 你不能只释放你已经分配的一部分,你必须完全释放它,否则你根本就没有释放它。 这意味着为了释放内存,您必须先移动数据。 我不知道.NET内存pipe理是否在这方面做了一些特殊的事情,但我认为这个规则也适用于CLR。

我想这是因为旧的数组没有被破坏。 如果它被引用到其他地方,它仍然存在,它仍然可以被访问。 这就是为什么新的数组被创build在一个新的内存位置。

例:

 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 

定义realloc的原因有两个原因:首先,它清楚地表明,不能保证调用较小大小的realloc将返回相同的指针。 如果你的程序做出这个假设,你的程序就坏掉了。 即使指针是99.99%的时间相同。 如果在大量空闲空间的中间有一个大的块,造成堆碎片,那么realloc可以自由移动,如果可能的话。

其次,有这样做的绝对必要的实现。 例如,MacOS X有一个实现,其中一个大内存块用于分配1到16字节的malloc块,另一个大内存块用于17到32字节的malloc块,一个用于33到48字节的malloc块等。这非常自然地说,任何停留在33到48字节范围内的大小改变都将返回相同的块,但是改变为32或49字节必须重新分配该块。

realloc的性能不能保证。 但实际上,人们并没有把尺寸做得小一点。 主要情况是:将内存分配到所需大小的估计上限,填充内存,然后调整为实际要求的更小的大小。 或者分配内存,然后在不再需要的时候调整它的大小。

只有.NET运行时的devise者才能告诉你他们的实际推理。 但我的猜测是,内存安全在.NET中是最重要的,维护内存安全性和可变数组长度是非常昂贵的,更不用说数组的代码会多么复杂。

考虑一下这个简单的例子:

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

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

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

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

不用说,这是非常昂贵的。 使数组长度不可改变会带来两个巨大的性能提升:

  • 由于长度不能改变,边界检查不需要同步。 这使得每个人的边界检查大大便宜。
  • …你可以省略边界检查,如果你能certificate这样做的安全。

实际上,运行时实际上最终会变成这样的东西:

 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中没有什么不同 – 但是同时它是完全安全的。

现在,让我们看看添加数组缩小你想要的方式的优点和缺点:

优点:

  • 在非常罕见的情况下,您希望将数组缩小,这意味着不需要复制数组来更改其长度。 但是,将来还是需要堆栈压缩,这需要大量的拷贝。
  • 如果将对象引用存储在数组中,则可能会从caching位置获得一些好处,前提是分配数组和项目恰好位于同一位置。 毋庸置疑,这比Pro#1更为罕见。

缺点:

  • 任何数组访问将变得非常昂贵,即使在紧密的循环中。 所以每个人都会使用unsafe代码,这样可以保证你的记忆安全。
  • 处理数组的每一段代码都必须期望数组的长度可以随时改变。 每个数组访问都需要一个try ... catch (IndexOutOfRangeException) ,每个迭代数组的人都需要能够处理不断变化的大小 – 曾经有人想知道为什么不能从List<T>添加或删除项目你正在迭代?
  • CLR团队的大量工作无法在另一个更重要的function上使用。

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

  • 分配总是从顶部开始,就像一个堆栈。 与malloc不同,这使分配几乎与堆栈分配一样便宜。
  • 由于分配模式,为了实际“释放”内存,必须在收集之后压缩堆。 这将移动对象,以便堆中的空闲空间被填充,从而使堆的“顶部”更低,从而允许您在堆中分配更多的对象,或者释放内存以供系统上的其他应用程序使用。
  • 为了帮助维护caching局部性(假设通常一起使用的对象也被分配到彼此靠近的地方,这是一个相当不错的假设),这可能涉及到移动堆空间上方的每个对象。 所以你可能已经保存了一个100字节数组的副本,但是你必须移动100MB的其他对象。

另外,正如Hans在他的回答中所解释的那样,只是因为数组较小并不一定意味着在相同数量的内存中有足够的空间用于更小的数组,因为对象头(请记住.NET是如何devise的内存安全?知道对象的正确types是运行时必须的)。 但是他没有指出的是,即使你有足够的内存, 你仍然需要移动数组 。 考虑一个简单的数组:

 ObjectHeader,1,2,3,4,5 

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

 OldObjectHeader;NewObjectHeader,1,2,3 

哎呀。 我们需要旧的对象头来保留自由空间列表,否则我们不能正确地压缩堆。 现在,可以这样做,旧的对象头将被移出数组,以避免复制,但这又是一个复杂的事情。 这真的是一个相当昂贵的function,诺诺将永远使用,真的。

而这一切仍然在托pipe的世界。 但.NET被devise为允许您在必要时下拉到不安全的代码 – 例如,与非托pipe代码进行互操作时。 现在,当您要将数据传递给本机应用程序时,您有两个select – 您可以固定pipe理的句柄,以防止收集和移动数据,或者复制数据。 如果你正在做一个简短的同步调用,固定是非常便宜的(虽然更危险 – 本机代码没有任何安全保证)。 这同样适用于像在image processing中那样在紧密循环中操作数据 – 复制数据显然不是一种select。 如果您允许Array.Resize更改现有数组,则会完全中断 – 因此, Array.Resize需要检查是否存在与要resize的数组关联的句柄,并在发生这种情况时引发exception。

更复杂的情况下,更难以推理(你将有很多的乐趣,跟踪只发生一次的错误,当发生这样的事情时, Array.Resize尝试调整一个刚刚发生的数组被固定在内存中)。

正如其他人所解释的那样,本地代码并不好。 虽然你不需要保持相同的安全保证(我不会把它作为一个好处,但是很好),但是你分配和pipe理内存的方式还是有一些复杂的。 调用realloc来制作一个10项数组5项? 那么它要么被复制,要么仍然是10个数组的大小,因为没有办法以合理的方式回收剩余的内存。

因此,要做一个快速的总结:你要求一个非常昂贵的function,这将是非常有限的(如果有的话)在一个极其罕见的情况下,并存在一个简单的解决方法(使你自己的数组类) 。 我没有看到通过“当然,让我们实现这个function!”的酒吧! 🙂

任何堆pipe理系统中都可能存在许多复杂的数据结构。 例如,他们可能会根据当前的大小来存储块。 如果块被允许“分裂,增长和缩小”,会增加很多复杂性。 (而且,它确实不会让事情变得更快。)

因此,实现永远安全的事情:它分配一个新的块,并根据需要移动值。 众所周知,“这个策略在任何系统上都能可靠运行”。 而且,它真的不会放慢速度。

在引擎盖下,数组被存储在连续的内存块中,但在许多语言中仍然是原始types。

为了回答你的问题,分配给一个数组的空间被认为是一个单独的块,并且当它是全局的时候,它们被存储在stack以备局部variables或者bss/data segments 。 AFAIK,当你访问像array[3]这样的array[3] ,在低层次上,OS会得到一个指向第一个元素的指针并跳转/跳转直到达到(以上例子的三倍)所需的块。 所以可能是一个架构决定,一旦声明了数组的大小就不能改变。

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