数组和 CLR——一种非常特殊的关系





5.00/5 (4投票s)
前段时间我写了一篇关于字符串和 CLR 之间“特殊关系”的文章,结果发现数组和 CLR 之间有着更深层次的关系
前段时间我写了一篇关于字符串和 CLR 之间存在的“特殊关系”的文章,结果发现数组和 CLR 之间有着更深层次的关系
顺便说一句,如果你喜欢阅读 CLR 内部原理,你可能会发现这些其他帖子很有趣
公共语言运行时 (CLR) 的基础
数组是 CLR 的一个基本组成部分,以至于它们被包含在 ECMA 规范中,以明确表明 运行时 必须实现它们
此外,有几个 IL(中间语言)指令专门处理数组
newarr
<etype>- 创建一个元素类型为 etype 的新数组。
ldelem.ref
- 将索引处的元素作为 O 加载到堆栈顶部。O 的类型与推送到 CIL 堆栈上的数组的元素类型相同。
stelem
<typeTok>- 用堆栈上的值替换索引处的数组元素(还有
stelem.i
、stelem.i1
、stelem.i2
、stelem.r4
等)
- 用堆栈上的值替换索引处的数组元素(还有
ldlen
- 将数组的长度(类型为本机无符号整数)压入堆栈。
这是有道理的,因为数组是许多其他数据类型的构建块,您希望它们在像 C# 这样的现代高级语言中可用、定义良好且高效。没有数组,您就无法拥有列表、字典、队列、堆栈、树等,它们都构建在数组之上,以类型安全的方式提供对连续内存片段的低级访问。
内存和类型安全
这种 内存 和 类型安全 很重要,因为没有它,.NET 就不能被称为“托管运行时”,你将不得不处理在更低级语言中编写代码时遇到的那种问题。
更具体地说,当您使用数组时,CLR 提供以下保护(来自 BOTR“CLR 简介”页面中 内存和类型安全 部分)
虽然 GC 对于确保内存安全是必要的,但它是不够的。GC 不会阻止程序 索引超出数组末尾 或访问对象末尾的字段(如果您使用基地址和偏移量计算字段的地址,则可能发生)。但是,如果我们确实阻止了这些情况,那么我们确实可以使程序员无法创建内存不安全的程序。
虽然公共中间语言 (CIL) 确实有可以获取和设置任意内存(从而违反内存安全)的操作符,但它也有 以下内存安全操作符,并且 CLR 强烈鼓励在大多数编程中使用它们
- 字段获取操作符 (LDFLD, STFLD, LDFLDA),通过名称获取(读取)、设置和获取字段的地址。
- 数组获取操作符 (LDELEM, STELEM, LDELEMA),通过索引获取、设置和获取数组元素的地址。所有数组都包含一个指定其长度的标签。这有助于在每次访问之前进行自动边界检查。
此外,在同一 BOTR 页面中 可验证代码——强制内存和类型安全 部分也提到
实际上,所需的运行时检查数量非常少。它们包括以下操作
- 将基类型指针转换为派生类型指针(相反的方向可以静态检查)
- 数组边界检查(正如我们为内存安全所看到的那样)
- 将 指针数组 中的元素分配给新的(指针)值。这种特殊检查是必需的,因为 CLR 数组具有宽松的转换规则(稍后会详细介绍)
但是,你不能免费获得这种保护,需要付出代价
请注意,执行这些检查的需求对运行时提出了要求。特别是
- GC 堆中的所有内存都必须标记其类型(以便可以实现转换操作符)。此类型信息必须在运行时可用,并且必须足够丰富以确定转换是否有效(例如,运行时需要知道继承层次结构)。实际上,GC 堆上每个对象的第一个字段都指向表示其类型的运行时数据结构。
- 所有数组也必须具有其大小(用于边界检查)。
- 数组必须具有其元素类型的完整类型信息。
实现细节
事实证明,数组内部实现的大部分内容最好用 魔术 来形容,Stack Overflow 上 Marc Gravell 的评论很好地总结了这一点
数组基本上是巫术。因为它们早于泛型,但必须允许即时类型创建(即使在 .NET 1.0 中),所以它们是使用技巧、黑客和手法来实现的。
没错,数组在泛型出现之前就已经参数化(即泛型)了。这意味着你可以在很久以前创建 int[]
和 string[]
等数组,而 List<int>
或 List<string>
只有在 .NET 2.0 中才可能编写。
特殊助手类
所有这些 魔法 或 手法 都通过以下 2 件事实现
- CLR 打破所有常规类型安全规则
- 一个特殊的数组辅助类,名为
SZArrayHelper
但首先是为什么,为什么需要所有这些技巧?来自 .NET 数组、IList<T>、泛型算法以及 STL 呢?
当我们设计泛型集合类时,困扰我的一件事是如何编写一个既适用于数组又适用于集合的泛型算法。为了推动泛型编程,我们当然必须使数组和泛型集合尽可能无缝。感觉应该有一个简单的解决方案来解决这个问题,这意味着你不必编写两次相同的代码,一次接受 IList<T>,另一次接受 T[]。我想到的解决方案是数组需要实现我们的泛型 IList。我们在 V1 中让数组实现了非泛型 IList,这很简单,因为 IList 缺乏强类型以及我们所有数组的基类 (System.Array)。我们需要以强类型的方式为 IList<T> 做同样的事情。
但这只针对常见情况,即“一维”数组
不过这里有一些限制,**我们不想支持多维数组,因为 IList<T> 只提供一维访问**。此外,具有非零下限的数组相当奇怪,可能与 IList<T> 不太兼容,因为大多数人可能会从 0 迭代到 IList 的 Count 属性返回的值。因此,**我们没有让 System.Array 实现 IList<T>,而是让 T[] 实现 IList<T>**。这里,T[] 表示下限为 0 的一维数组(在内部通常称为 SZArray,但我认为 Brad 曾经想公开推广“向量”这个术语),并且元素类型是 T。因此 Int32[] 实现了 IList<Int32>,而 String[] 实现了 IList<String>。
此外,来自 数组源代码 的此评论进一步阐明了原因
//----------------------------------------------------------------------------------
// Calls to (IList<T>)(array).Meth are actually implemented by SZArrayHelper.Meth<T>
// This workaround exists for two reasons:
//
// - For working set reasons, we don't want insert these methods in the array
// hierachy in the normal way.
// - For platform and devtime reasons, we still want to use the C# compiler to
// generate the method bodies.
//
// (Though it's questionable whether any devtime was saved.)
//
// ....
//----------------------------------------------------------------------------------
因此,这是为了 方便 和 效率 而完成的,因为他们不希望 System.Array
的每个实例都携带 IEnumerable<T>
和 IList<T>
实现的所有代码。
此映射通过调用 GetActualImplementationForArrayGenericIListOrIReadOnlyListMethod(..) 进行,该方法荣获 CoreCLR 源代码中最佳方法名称奖!它负责连接 SZArrayHelper 类中相应的方法,即 IList<T>.Count
-> SZArrayHelper.Count<T>
,如果该方法是 IEnumerator<T>
接口的一部分,则使用 SZGenericArrayEnumerator<T>。
但这有可能导致安全漏洞,因为它打破了正常的 C# 类型系统保证,特别是在 this
指针方面。为了说明这个问题,这里是 Count
属性 的源代码,请注意对 JitHelpers.UnsafeCast<T[]>
的调用
internal int get_Count<T>()
{
//! Warning: "this" is an array, not an SZArrayHelper. See comments above
//! or you may introduce a security hole!
T[] _this = JitHelpers.UnsafeCast<T[]>(this);
return _this.Length;
}
天哪,它必须重新映射 this
才能在正确的对象上调用 Length
!
如果这些评论还不够,在 类顶部 有一个措辞非常强烈的评论,进一步阐明了风险!
通常,所有这些魔法都对你隐藏,但偶尔也会泄露出来。例如,如果你运行下面的代码,SZArrayHelper
将出现在 NotSupportedException
属性的 StackTrace
和 TargetSize
中
try {
int[] someInts = { 1, 2, 3, 4 };
IList<int> collection = someInts;
// Throws NotSupportedException 'Collection is read-only'
collection.Clear();
} catch (NotSupportedException nsEx) {
Console.WriteLine("{0} - {1}", nsEx.TargetSite.DeclaringType, nsEx.TargetSite);
Console.WriteLine(nsEx.StackTrace);
}
删除边界检查
运行时还以更传统的方式提供对数组的支持,其中第一种方式与性能有关。数组边界检查在提供 内存安全 方面做得很好,但它们有成本,因此在可能的情况下,JIT 会删除它知道是冗余的任何检查。
它通过计算 for
循环访问的值 范围 并将其与数组的实际长度进行比较来实现此目的。如果它确定 从未 尝试访问超出数组允许边界的项,则运行时检查将被删除。
有关更多信息,下面的链接将带您到 JIT 源代码中处理此问题的区域
- JIT 尝试删除范围检查
- RangeCheck::OptimizeRangeCheck(..)
- 进而调用 RangeCheck::GetRange(..)
- 还调用 Compiler::optRemoveRangeCheck(..) 以实际移除范围检查
- 非常信息丰富的源代码注释 解释了范围检查移除逻辑
如果你真的有兴趣,可以看看我整理的 这个 gist,它探讨了边界检查被“移除”和“未移除”的场景。
分配数组
运行时帮助的另一个任务是使用手写汇编代码分配数组,因此方法尽可能优化,请参阅
运行时对数组的处理方式不同
最后,由于数组与 CLR 紧密相关,因此在许多地方它们都被视为 特殊情况。例如,在 CoreCLR 源代码中 搜索 'IsArray()' 会返回 60 多个结果,其中包括
- 数组的方法表构建方式不同
- 当您对数组调用
ToString()
时,您会得到特殊格式,例如 'System.Int32[]' 或 'MyClass[,]';
所以是的,可以说数组和 CLR 有着 非常特殊的关系
延伸阅读
一如既往,这里还有一些链接供您欣赏!!
- C# 数组规范
- .NET 类型内部——从 Microsoft CLR 的角度——数组
- CLR 内部——调查内存问题
- .NET 对象的内部和 SOS 的使用
- .NET 数组的内存布局
- .NET 数组的内存布局 (x64)
- NGen 误解
- 为什么 .NET 中的多维数组比普通数组慢?
- C# 中的数组如何部分实现 IList<T>?
- IList<T>、IEnumerable<T> 和 ICollection<T> 的 TypeDependencyAttribute("System.SZArrayHelper") 的目的是什么?
- 'yield return' 返回什么类型的类
- 在共享源 CLI (SSCLI) 中实现的 SZArrayHelper
数组源代码引用
帖子 数组和 CLR——一种非常特殊的关系 最早出现在我的博客 性能就是特性! 上