zoukankan      html  css  js  c++  java
  • 托管对象本质-第三部分-托管数组结构



    托管对象本质-第三部分-托管数组结构

    原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-3-the-layout-of-a-managed-array-3/
    原文作者:Sergey
    译文作者:杰哥很忙

    目录

    托管对象本质1-布局
    托管对象本质2-对象头布局和锁成本
    托管对象本质3-托管数组结构
    托管对象本质4-字段布局

    数组是每个应用程序的基本构建基块之一。即使你不是每天直接使用数组,你也可以将他们作为库包的一部分间接使用。

    C# 一直都有数组结构,数组结构也是唯一一个类似泛型而且类型安全的数据结构。现在你可能没有那么频繁的直接使用他们,但是为了提升性能,都有可能会从一些更高级的数据结构(比如List<T>)切换回数组。

    数组和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关键字。

    尽管不同值类型(如 intbyte)之间存在隐式转换,但 int[]byte[]之间没有隐式或显式转换。数组协方差转换是引用转换,它不会更改转换对象的布局,并保留要转换对象的引用标识。

    在旧版本的 CLR 中,引用数组和值类型具有不同的布局。引用类型的数组具有对每个实例中元素的类型句柄的引用:

    20200205210313.png

    这在最新版本的 CLR 中已更改,现在元素类型存储在方法表中:

    20200205210333.png

    有关布局的详细信息,请参阅 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%。这不是由于我在这里描述的技巧,是与池的并发访问相关。但关键是,对象池可能位于应用程序的热路径上,甚至像上面提到的小性能改进也可能对整体性能产生明显影响。


    20191127212134.png
    微信扫一扫二维码关注订阅号杰哥技术分享
    出处:https://www.cnblogs.com/Jack-Blog/p/12266538.html
    作者:杰哥很忙
    本文使用「CC BY 4.0」创作共享协议。欢迎转载,请在明显位置给出出处及链接。

  • 相关阅读:
    使用gunicorn部署flask项目
    加密算法详解
    elasticsearch安装
    elk下载链接
    mysql允许远程连接
    工作流源代码分析
    查看账户的访问token
    Kube-proxy组件
    创建服务账户/查询访问token
    K8s概念2
  • 原文地址:https://www.cnblogs.com/Jack-Blog/p/12266538.html
Copyright © 2011-2022 走看看