数组是C#中最基础的存储结构之一,很多的存储结构其底层的实现中都是基于数组实现的,如:List、Queue、Stack、Dictionary、Heap等等,如果大家读过这些类型的底层实现源码,其实就可以发现,这些存储结构都是在其内部维护了一个或多个数组。本文重点来学习一下数组存储结构的实现逻辑。
  首先,我们来看看数组的定义:静态数组是由相同类型的元素线性排列的数据结构,在计算机上会分配一段连续的内存,对元素进行顺序存储。从以上的描述中,我们可以发现几个征:"相同类型、连续内存、顺序存储",这样的结构特性,可以能做到基于下标,对数组进行 O(1) 时间复杂度的快速随机访问。
  那么数组为什么可以做到快速随机访问?我们可以先来简单的说明一下,"存储数组时,会事先分配一段连续的内存空间,将数组元素依次存入内存。因为数组元素的类型都是一样的,所以每个元素占用的空间大小也是一样的,这样我们就很容易用“数组的开始地址 +index* 元素大小”的计算方式,快速定位到指定索引位置的元素,这也是数组基于下标随机访问的复杂度为 O(1) 的原因。"这样的描述可能是绝大部分同学都有所接触到的内容,并且也能让大家大致的了解到其存储原理,但是C#数组的存储结构是如何具体实现的呢?
  本文将从一个数组的基础操作开始,逐步来推导数组的在C#基础操作、数组在CoreCLR的维护策略,数组在C++的内存分配等阶段具体是如何实现的。
  首先,我们先来看一个简单的数组定义、初始化、赋值、取值的过程。
1int[] myIntArray =newint[5] {1,2,3,4,5 };23for (int j =0; j <10; j++ )4        {5            Console.WriteLine("Element[{0}] = {1}", j, myIntArray[j]);6         }

  这个过程中具体的实现逻辑什么样的呢,对于C#数组在内存的存储方式、数组的Cpoy、动态数组的扩容机制是什么样的呢?在C#中Array充当数组的基类,用于创建、处理、搜索数组并对数组进行排序,但是只有系统和编译器才能显式从 Array 类派生。接下来我们就来了解一下Array底层源码实现。对于数组的初始化,我们使用以上示例中的int[]进行介绍。在C#中所有的数组类型都集成自抽象类Array,在对int[]初始化的过程中,都会使用array的CreateInstance()方法,该方法存在多个重载,主要区别为用于创建一维、二维、三维等不同维数的数组结构,以下我们来看一下对于一维数据的创建代码。

1publicstaticunsafe Array CreateInstance(Type elementType,int length)2        {3             RuntimeType? t = elementType.UnderlyingSystemTypeas RuntimeType;45return InternalCreate(t,1, &length,null);6         }

  上面的代码中,我们可以发现两个地方需要关注,第一部分:RuntimeType? t = elementType.UnderlyingSystemType as RuntimeType;该方法获取数组元素类型的基础系统类型,并将其转换为 RuntimeType。第二部分:InternalCreate(t, 1, &length, null)具体创建数组的操作,我们来看一下其实现的源码。(源码进行部分删减)

 1privatestaticunsafe Array InternalCreate(RuntimeType elementType,int rank,int* pLengths,int* pLowerBounds) 2        { 3if (rank ==1) 4            { 5return RuntimeImports.RhNewArray(elementType.MakeArrayType().TypeHandle.ToEETypePtr(), pLengths[0]); 6            } 7else 8            { 9int* pImmutableLengths =stackallocint[rank];1011for (int i =0; i < rank; i++) pImmutableLengths[i] = pLengths[i];1213return NewMultiDimArray(elementType.MakeArrayType(rank).TypeHandle.ToEETypePtr(), pImmutableLengths, rank);14            }15         }

  该方法用于在运行时创建数组,其中参数elementType表示数组元素运行时的类型,rank表示数组的维度,pLengths表示指向数组长度的指针,pLowerBounds表示指向数组下限(如果有的话)的指针。根据设定的rank的值,创建一维或多维数组。其中elementType.MakeArrayType().TypeHandle.ToEETypePtr()表示先将当前type 对象表示的类型通过 MakeArrayType 方法创建一个数组类型,然后获取该数组类型的运行时类型句柄,最后通过 ToEETypePtr 方法将运行时类型句柄转换为指向类型信息的指针。我们先看一下创建一维数组的逻辑,具体代码如下:

1        [MethodImpl(MethodImplOptions.InternalCall)]
2 [RuntimeImport(RuntimeLibrary,"RhNewArray")]3privatestaticexternunsafe Array RhNewArray(MethodTable* pEEType,int length);45internalstaticunsafe Array RhNewArray(EETypePtr pEEType,int length) => RhNewArray(pEEType.ToPointer(), length);

  该方法是具体实现数组创建的逻辑,我们先来看一下参数,其中EETypePtr是CLR中用于表示对象类型信息的指针类型。每个.NET对象在运行时都关联有一EEType结构,它包含有关对象类型的信息,例如该类型的方法表、字段布局、基类信息等。

  这里简单的介绍一下代码上面的两个自定义属性:
(1)、[MethodImpl(MethodImplOptions.InternalCall)] 
   指示编译器生成的方法体会被一个外部实现取代,而该外部实现通常由运行时环境提供。
(
2)、[RuntimeImport(RuntimeLibrary,"RhNewArray")]    这是一个自定义的特性,在项目中定义的用于指示运行时导入的特性。
在C#中,使用属性标记运行时导入的位置通常是为了提供额外的元数据和信息,以告诉编译器和运行时环境如何正确地处理外部方法的调用。
  使用属性标记运行时导入的主要目的有以下几点:
(1)、元数据信息:运行时导入的位置可能包括一些元数据信息,如函数名称、库名称、调用约定等。
   使用属性可以将这些信息嵌入到C#代码中,使得代码更加自解释,并提供足够的信息供编译器和运行时使用。
(
2)、优化和安全性:编译器和运行时环境可能会使用属性来进行性能优化或安全性检查。 例如,通过指定调用约定或其他属性,可以帮助编译器生成更有效的代码。
(
3)、与运行时环境交互:属性可以提供一种与底层运行时环境进行交互的机制。 例如,通过自定义属性,可以向运行时环境传递一些特殊的标志或信息,以影响方法的行为。
(
4)、代码维护和可读性:使用属性可以提高代码的可维护性和可读性。 在代码中使用属性来标记运行时导入的位置,使得代码的意图更加清晰,也有助于团队协作。

  在CLR的内部,EETypePtr是一个指向EEType结构的指针,其中EEType是运行时中用于描述对象类型的结构。EEType结构的内容由运行时系统生成和管理,而EETypePtr则是对这个结构的指针引用。根据传入的运行时对象类型进行处理,我们接下来看一下pEEType.ToPointer()的实现。

1internalunsafe Internal.Runtime.MethodTable* ToPointer()2        {3return (Internal.Runtime.MethodTable*)(void*)_value;4         }

  ToPointer()方法目的是将其对象或值转换为指针,MethodTable 是CLR用于管理类和对象的元数据,用于存储类型相关信息的数据结构,每个对象在内存中都包含一个指向其类型信息的指针,这个指针指向该类型的 MethodTable,用于支持CLR在运行时进行类型检查、虚方法调用等操作。那我们来具体看一下MethodTable的数据结构。

 1struct MethodTable 2        { 3// 指向类型的虚方法表(VTable) 4             IntPtr* VirtualMethodTable; 5 6// 字段表 7             FieldInfo* Fields; 8 9// 接口表10             InterfaceInfo* Interfaces;1112// 其他元数据信息...13         }

  我们从原始的数组初始化和赋值,一直推导至对象的数组空间维护。截止当前,我们获取到数组的MethodTable* pEEType数据结构。接下来我们来看一下CLR对数组的内存空间分配逻辑和维护方案。由于CoreCLR中的实现代码我们没有办法全面的了解,我们接下按照预定的逻辑进行一定的推论。(CCoreCLR的实现代码绝大部分是使用C++实现)

 1 #include <cstdint> 2 3extern"C" { 4struct MethodTable {// 方法表等信息...}; 5struct Array {// 数组相关信息...}; 6void* RhNewArray(void* pEEType,int length) { 7// 假设存在一个用于对象分配的函数,该函数分配数组的内存 8void* rawArrayMemory = AllocationFunction(length *sizeof(Array)); 9// 将传递的 pEEType 信息保存到数组对象中10         Array* newArray = static_cast<Array*>(rawArrayMemory);11//为数组对象设置元数据信息12         newArray->MethodTablePointer = pEEType;13return rawArrayMemory;14    }15 }

  以上代码是一种假设实现方式, AllocationFunction 的函数用于内存分配,并且数组对象(Array)有一个成员 MethodTablePointer 用于存储 MethodTable 的指针。接下来我们再来看一下AllocationFunction()方法推测实现逻辑。

1void* AllocationFunction(size_t size) {2// 使用标准库的 malloc 函数进行内存分配3void* memory =malloc(size);4//处理内存分配失败的情况5    ...6return memory;7 }

  以上的代码中,使用标准函数库malloc()进行内存的分配,malloc ()是C标准库中的一个函数,用于在运行时动态分配内存。malloc ()接受一个 size 参数,表示要分配的内存字节数。它返回一个指向分配内存起始地址的指针,或者在分配失败时返回 NULL。malloc ()内存分配逻辑通常涉及以下步骤:

(1)、请求内存空间: malloc() 根据传递的 size 参数向系统请求一块足够大的内存空间。

(2)、内存分配:如果系统成功分配了请求的内存块,malloc 会在这块内存中标记已分配的部分,并将其起始地址返回给调用者。
(
3)、返回结果:如果分配成功,malloc 返回一个指向新分配内存的指针。如果分配失败(例如,系统内存不足),则返回 NULL。
(
4)、内存对齐:部分系统要求分配的内存是按照特定字节对齐的。因此,malloc 通常会确保返回的内存地址满足系统的对齐要求。
(
5)、初始化内存:malloc 返回的内存通常不会被初始化,即其中的数据可能是未知的。在使用之前,需要通过其他手段对内存进行初始化。
(
6)、内存管理:一些实现可能会使用内部数据结构来跟踪已分配和未分配的内存块,以便在 free 被调用时能够释放相应的内存。

  以上简单的描述了C++在底层实现内存分配的简单实现方式,对于CoreCLRe中对于数组的内存空间申请相对非常复杂,可能涉及内存池、分配策略、对齐要求等方面的考虑。后续有机会再做详细的介绍。既然说到CoreCLR的内存实现为C++的内存分配策略,那我们接下来看一下其对应的常用策略管理策略。我们用一个简单的数组的内存分配。

1int myArray[5];// 声明一个包含5个整数的数组23 +------+------+------+------+------+4 | int0 | int1 | int2 | int3 | int4 |5 +------+------+------+------+------+

  myArray 是整个数组的起始地址,然后每个 int 元素按照其大小排列在一起。基于以上的分析,我们可以看到C++对于内存的分配概述大致如下:

(1)、元素的内存布局:数组的元素在内存中是依次排列的,每个元素占用的内存空间由元素的类型决定。
   例如,一个int 数组中的每个整数元素通常占用4个字节(32位系统)或8个字节(64位系统)。
(
2)、数组的起始地址:数组的内存分配通常从数组的第一个元素开始。数组的起始地址是数组第一个元素的地址。
(
3)、连续存储:数组的元素在内存中是连续存储的,这意味着数组中的每个元素都直接跟在前一个元素的后面。

  上面介绍了内存空间的分配,我们接下来看一下这段代码的实现逻辑,rawArrayMemory: 这是一个 void* 类型的指针,通常指向分配的内存块的起始位置。static_cast 运算符,将 rawArrayMemory 从 void* 类型转换为 Array* 类型。

1  Array* newArray = static_cast<Array*>(rawArrayMemory);

  我们从以上对于数组的创建过程中,分析了C#、CoreCLR、C++等多个实现视角进行了简单的分析。

  接下来我们回归到CoreCLR中对于数组的内存空间管理策略,数组内存分配的常用步骤:
1、分配对象头:为数组对象分配对象头,对象头包含一些元数据,如类型指针、同步块索引等信息。
2、分配数组元素空间:分配存储数组元素的内存块,这是实际存储数组数据的地方。
3、初始化数组元素:根据数组类型的要求,初始化数组元素。这可能涉及到对元素进行默认初始化,例如将整数数组的每个元素初始化为零。
4、返回数组引用:返回指向数组对象的引用,使得该数组可以被使用。

  当我们在托管代码中声明一个数组时,CoreCLR会在托管堆上动态分配内存,以存储数组的元素,并在分配的内存块中存储有关数组的元数据,这些元数据通常包括数组的长度和元素类型等信息。CoreCLR通常会对分配的内存进行对齐,以提高访问效率,这可能导致分配的内存块略大于数组元素的实际大小。可能有同学会问为什么要进行内存的对齐,这里就简单的说明一下。

1、硬件要求:访问特定类型的数据时,其地址应该是某个值的倍数。
2、提高访问速度:对齐的内存访问通常比不对齐的访问更加高效。处理器通常能够更快地访问对齐的内存,因为这符合硬件访问模式。
3、减少内存碎片:内存对齐还有助于减少内存碎片,使得内存的使用更加紧凑。内存碎片可能导致性能下降,因为它可能增加了分配和释放内存的开销。
4、硬件事务:一些处理器和操作系统支持原子操作,但通常要求数据是按照特定的对齐方式排列的。

  上面介绍了为什么需要进行内存对齐,那么对于CoreCLR的内部实现是如何进行内存对齐的呢?我们简洁的介绍一下实现大流程:

1、使用操作系统的内存分配函数:使用操作系统提供的内存分配函数来分配托管堆上的内存。在Windows上可能是HeapAlloc。
2、对齐方式的指定:在调用内存分配函数时,会指定所需的对齐方式。通常是以字节为单位的对齐值。常见的对齐值包括4字节、8字节等。
3、内存块的对齐:内存分配函数返回的内存块通常是按照指定的对齐方式进行对齐的。CLR确保返回的内存块的起始地址符合对齐规则。
4、对齐规则的维护:维护对齐规则的信息,确保在托管堆上分配和释放的内存块都符合相同的对齐方式。
5、内存对齐的优化:对内存对齐进行一些优化,以提高访问效率。例如,它可以在对象的布局中考虑对齐规则,以减少内存碎片。

  具体的数组内存分配策略可能会因CLR的版本和实现而异。不同的垃圾回收算法(如标记-清除、复制、标记-整理等)以及不同的GC代(新生代、老年代)也可能影响内存分配的具体实现。在.NET中,CLR提供了不同的垃圾回收器实现,例如Workstation GC和Server GC。Workstation GC通常适用于单处理器或少量处理器的环境,而Server GC适用于多处理器环境。这些GC实现可能在内存分配和回收方面有一些差异。

本文借助了一个数组的初始化和赋值为样例,逐层的分析了数组对象运行时结构的获取、对象MethodTable结构的分析、CoreCLR底层对数组内存结构的创建推导、C++对于内存的分配策略等视角,最后还综合的介绍了CoreCLR对于数组内存的创建步骤。

我们一直以来对于数组的内存分配,都有一个整体的认识,其特点是"相同类型、连续内存、顺序存储",对于其连续内存的特点记忆深刻,但是在内部如何实现进行的连续内存却没有整体的了解,C#内部是如何完成不同类型对象数组的运行时创建,在CoreCLR内部如何进行内存的划分是没有做过了解和推导,甚至于CoreCLR内部是如何维护一个对象的结构,很多时候都只是了解到运行时对象使用Type类型就可以得到,那么CoreCLR内部如何来维护这个Type呢?其实很多时候没有特点去了解过其结构。

以上内容是对C#中Array的存储结构的简单介绍,如错漏的地方,还望指正。