原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-3-the-layout-of-a-managed-array-3/
原文做者:Sergey
译文做者:杰哥很忙c++
托管对象本质1-布局
托管对象本质2-对象头布局和锁成本
托管对象本质3-托管数组结构
托管对象本质4-字段布局git
数组是每一个应用程序的基本构建基块之一。即便你不是天天直接使用数组,你也能够将他们做为库包的一部分间接使用。github
C# 一直都有数组结构,数组结构也是惟一一个相似泛型并且类型安全的数据结构。如今你可能没有那么频繁的直接使用他们,可是为了提高性能,都有可能会从一些更高级的数据结构(好比List<T>
)切换回数组。c#
数组和CLR有着很是特殊的关系,可是今天咱们将从用户的角度来探讨它们。咱们将讨论如下内容:
* 探索一个最奇怪的 C# 功能,称为数组协方差
* 讨论数组的内部结构
* 探索一些性能技巧,咱们能够这样作,从数组中挤压更多的性能数组
C# 语言中最奇怪的特征之一是数组协方差:可以将T类型的数组赋值给object类型或任何其余T类型的基类的数组的能力。缓存
string[] strings = new[] { "1", "2" }; object[] objects = strings;
这个转换并彻底是类型安全的。若是objects变量仅用于读取数据,那么一切正常。可是,若是尝试修改数组时,若是参数的类型不兼容,则可能会失败。安全
objects[0] = 42; //运行时错误
关于这个特性,.NET 社区中有一个众所周知的笑话:C# 做者在一开始很是努力地将 Java 生态系统的各个方面复制到 CLR 世界,因此他们也复制了语言设计问题。微信
可是我并不认为这是缘由:)数据结构
在 90 年代后期,CLR 并无泛型,对吗?在这种状况下,语言用户如何编写处理任意数据类型数组的可重用代码?例如,如何编写将任意数组转储到控制台的函数?
一种方法是定义接收object[]
的函数,并经过将数组复制到对象数组来强制每一个调用方手动转换数组。这是可行的,但效率很低。另外一种解决方案是容许从任何引用类型的数组转换为object[]
,即保留 Derived[]
到 Base[]
的 IS-A
关系,其中派生类从基类继承。在值类型的数组中,转换不起做用,但至少能够实现一些通用性。
第一个 CLR 版本中缺乏泛型,迫使设计人员削弱类型系统。可是这个决定(我想)是通过深思熟虑的,而不只仅是来自Java生态系统的模仿。
数组协方差在编译时在类型系统中打开一个洞,但这并不意味着类型错误会使应用程序崩溃(C++中的相似"错误"将致使"未定义行为")。CLR 将确保类型安全,但检查会在运行时进行。为此,CLR 必须存储数组元素的类型,并在用户尝试更改数组实例时进行检查。幸运的是,此检查仅对引用类型的数组是必需的,由于struct是密封的(sealed
),所以不支持继承。
译者补充:struct因为是值类型,咱们能够查看它的IL语言,能够看到struct前会有
sealed
关键字。
尽管不一样值类型(如 int
到 byte
)之间存在隐式转换,但 int[]
和 byte[]
之间没有隐式或显式转换。数组协方差转换是引用转换,它不会更改转换对象的布局,并保留要转换对象的引用标识。
在旧版本的 CLR 中,引用数组和值类型具备不一样的布局。引用类型的数组具备对每一个实例中元素的类型句柄的引用:
这在最新版本的 CLR 中已更改,如今元素类型存储在方法表中:
有关布局的详细信息,请参阅 CoreClr 代码库中的如下代码段:
// Get the element type for the array, this works whether the element // type is stored in the array or not inline TypeHandle GetArrayElementTypeHandle() const;
TypeHandle GetArrayElementTypeHandle() { LIMITED_METHOD_CONTRACT; return GetMethodTable()->GetApproxArrayElementTypeHandle(); }
TypeHandle GetApproxArrayElementTypeHandle() { LIMITED_METHOD_DAC_CONTRACT; _ASSERTE(IsArray()); return TypeHandle::FromTAddr(m_ElementTypeHnd); }
union { PerInstInfo_t m_pPerInstInfo; TADDR m_ElementTypeHnd; TADDR m_pMultipurposeSlot1; };
我不肯定数组布局是何时改变的,但彷佛在速度和(托管)内存之间有一个权衡。因为内存局部性,初始实现(当类型句柄存储在每一个数组实例中时)访问应该更快,但确定有不可忽略的内存开销。当时,全部引用类型的数组都有共享方法表。但如今状况不同了:每一个引用类型的数组都有本身的方法表,该表指向相同的 EEClass ,指针指向元素类型句柄的指针。
也许CLR团队的人能够解释一下.
咱们知道 CLR 如何存储数组的元素类型,如今咱们能够探索 CoreClr 代码库,看看实现类型检查。
首先,咱们须要找到检查发生的位置。数组是 CLR 的一种很是特殊的类型,IDE 中没有"转到声明"按钮来"反编译"数组并显示源代码。可是咱们知道,检查发生在索引器setter中,它与一组IL指令StElem*
相对应:
* StElem.i4 用于整型数组
* StElem 用于任意值类型数组
* StElem.ref 用于引用类型数组
了解指令后,咱们能够轻松地在代码库中找到实现。据我所知,实如今了jithelpers.cpp中。下面是方法JIT_Stelem_Ref_Portable
稍微简化的版本:
/****************************************************************************/ /* assigns 'val to 'array[idx], after doing all the proper checks */ HCIMPL3(void, JIT_Stelem_Ref_Portable, PtrArray* array, unsigned idx, Object *val) { FCALL_CONTRACT; if (!array) { // ST: explicit check that the array is not null FCThrowVoid(kNullReferenceException); } if (idx >= array->GetNumComponents()) { // ST: bounds check FCThrowVoid(kIndexOutOfRangeException); } if (val) { MethodTable *valMT = val->GetMethodTable(); // ST: getting type of an array element TypeHandle arrayElemTH = array->GetArrayElementTypeHandle(); // ST: g_pObjectClass is a pointer to EEClass instance of the System.Object // ST: if the element is object than the operation is successful. if (arrayElemTH != TypeHandle(valMT) && arrayElemTH != TypeHandle(g_pObjectClass)) { // ST: need to check that the value is compatible with the element type TypeHandle::CastResult result = ObjIsInstanceOfNoGC(val, arrayElemTH); if (result != TypeHandle::CanCast) { // ST: ArrayStoreCheck throws ArrayTypeMismatchException if the types are incompatible if (HCCALL2(ArrayStoreCheck, (Object**)&val, (PtrArray**)&array) != NULL) { return; } } } HCCALL2(JIT_WriteBarrier, (Object **)&array->m_Array[idx], val); } else { // no need to go through write-barrier for NULL ClearObjectReference(&array->m_Array[idx]); } }
如今咱们知道,CLR 确实在底层确保引用类型数组的类型安全。对数组实例的每一个"写入"都有一个附加检查,若是数组在热路径上中使用,则该检查不可忽略。但在得出错误的结论以前,让咱们先看看这个检查的性能消耗程度。
译者补充:热路径指的是那些会被频繁调用的代码块。
为了不检查,咱们能够更改 CLR,或者使用一个众所周知的技巧:将对象包装到结构中
public struct ObjectWrapper { public readonly object Instance; public ObjectWrapper(object instance) { Instance = instance; } }
比较 object[] 和 ObjectWrapper[]的时间
private const int ArraySize = 100_000; private object[] _objects = new object[ArraySize]; private ObjectWrapper[] _wrappers = new ObjectWrapper[ArraySize]; private object _objectInstance = new object(); private ObjectWrapper _wrapperInstanace = new ObjectWrapper(new object()); [Benchmark] public void WithCheck() { for (int i = 0; i < _objects.Length; i++) { _objects[i] = _objectInstance; } } [Benchmark] public void WithoutCheck() { for (int i = 0; i < _objects.Length; i++) { _wrappers[i] = _wrapperInstanace; } }
结果以下:
Method | 平均值 | 错误 | 标准差 |
---|---|---|---|
WithCheck | 807.7 us | 15.871 us | 27.797 us |
WithoutCheck | 442.7 us | 9.371 us | 8.765 us |
不要被"几乎 2 倍"的性能差别所迷惑。即便在最坏的状况下,分配 100K 元素也不到一毫秒。性能表现很是好。但在现实世界中,这种差别是显而易见的。
许多性能关键的 .NET 应用程序使用对象池。池容许重用托管实例,而无需每次都建立新实例。此方法下降了内存压力,并可能对应用程序性能产生很是合理的影响。
能够基于并发数据结构(如ConcurrentQueue)或基于简单数组实现对象池。下面是 Roslyn 代码库中对象池实现的代码段:
internal class ObjectPool<T> where T : class { [DebuggerDisplay("{Value,nq}")] private struct Element { internal T Value; } // Storage for the pool objects. The first item is stored in a dedicated field because we // expect to be able to satisfy most requests from it. private T _firstItem; private readonly Element[] _items; // other members ommitted for brievity }
该实现管理一个缓存项数组,但池化并非直接使用 T[]
,而是将 T 包装到结构元素中,以免在运行时进行检查。
前段时间,我在应用程序中修复了一个对象池,使得解析阶段的性能提高了的 30%。这不是因为我在这里描述的技巧,是与池的并发访问相关。但关键是,对象池可能位于应用程序的热路径上,甚至像上面提到的小性能改进也可能对总体性能产生明显影响。
微信扫一扫二维码关注订阅号杰哥技术分享
出处:http://www.javashuo.com/article/p-xvddyhvn-bd.html 做者:杰哥很忙 本文使用「CC BY 4.0」创做共享协议。欢迎转载,请在明显位置给出出处及连接。