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

1         public static unsafe Array CreateInstance(Type elementType, int length)
2 {
3 RuntimeType? t = elementType.UnderlyingSystemType as RuntimeType;
4
5 return InternalCreate(t, 1, &length, null);
6 }

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

 1         private static unsafe Array InternalCreate(RuntimeType elementType, int rank, int* pLengths, int* pLowerBounds)
2 {
3 if (rank == 1)
4 {
5 return RuntimeImports.RhNewArray(elementType.MakeArrayType().TypeHandle.ToEETypePtr(), pLengths[0]);
6 }
7 else
8 {
9 int* pImmutableLengths = stackalloc int[rank];
10
11 for (int i = 0; i < rank; i++) pImmutableLengths[i] = pLengths[i];
12
13 return 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")]
3 private static extern unsafe Array RhNewArray(MethodTable* pEEType, int length);
4
5 internal static unsafe 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()的实现。

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

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

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

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

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

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

1 void* AllocationFunction(size_t size) {
2 // 使用标准库的 malloc 函数进行内存分配
3 void* memory = malloc(size);
4 //处理内存分配失败的情况
5 ...
6 return 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++的内存分配策略,那我们接下来看一下其对应的常用策略管理策略。我们用一个简单的数组的内存分配。

1 int myArray[5]; // 声明一个包含5个整数的数组
2
3 +------+------+------+------+------+
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++对于内存的分配策略等视角,最后还综合的介绍了CLRCore对于数组内存的创建步骤。

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

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

深度分析C#中Array的存储结构的更多相关文章

  1. Java中树的存储结构实现

    一.树 树与线性表.栈.队列等线性结构不同,树是一种非线性结构. 一棵树只有一个根节点,如果一棵树有了多个根节点,那它已经不再是一棵树了,而是多棵树的集合,也被称为森林. 二.树的父节点表示法 树中除 ...

  2. C/C++中float和double的存储结构(转)

    在C/C++中float是32位的,double是64位的,两者在内存中的存储方式和能够表示的精度均不同,目前C/C++编译器标准都遵照IEEE制定的浮点数表示法来进行float,double运算. ...

  3. linux的存储结构

    在linux中存储结构如下: Linux系统中的文件存储结构 那么在linux中每个目录都是什么含义呢 在linux中相对路径和绝对路径是必须要了解的一个概念 绝对路径(absolute path): ...

  4. Redis常用数据类型及其存储结构(源码篇)

    一.SDS 1,SDS源码解读 sds (Simple Dynamic String),Simple的意思是简单,Dynamic即动态,意味着其具有动态增加空间的能力,扩容不需要使用者关心.Strin ...

  5. kafka笔记-Kafka在zookeeper中的存储结构【转】

    参考链接:apache kafka系列之在zookeeper中存储结构  http://blog.csdn.net/lizhitao/article/details/23744675 1.topic注 ...

  6. 深度分析如何在Hadoop中控制Map的数量

    深度分析如何在Hadoop中控制Map的数量 guibin.beijing@gmail.com 很多文档中描述,Mapper的数量在默认情况下不可直接控制干预,因为Mapper的数量由输入的大小和个数 ...

  7. Hashtable数据存储结构-遍历规则,Hash类型的复杂度为啥都是O(1)-源码分析

    Hashtable 是一个很常见的数据结构类型,前段时间阿里的面试官说只要搞懂了HashTable,hashMap,HashSet,treeMap,treeSet这几个数据结构,阿里的数据结构面试没问 ...

  8. 网络存储结构简明分析—DAS、NAS和SAN 三者区别

    存储的总体分类     主流存储结构   网络存储结构大致分为三种:直连式存储(DAS:Direct Attached Storage).存储区域网络(SAN:Storage Area Network ...

  9. 存储结构简明分析——DAS、NAS和SAN

    存储的总体分类     主流存储结构   网络存储结构大致分为三种:直连式存储(DAS:Direct Attached Storage).存储区域网络(SAN:Storage Area Network ...

  10. Kafka在zookeeper中存储结构和查看方式

    Zookeeper 主要用来跟踪Kafka 集群中的节点状态, 以及Kafka Topic, message 等等其他信息. 同时, Kafka 依赖于Zookeeper, 没有Zookeeper 是 ...

随机推荐

  1. 如何用 ModelScope 实现 “AI 换脸” 视频

    前言 当下,视频内容火爆,带有争议性或反差大的换脸视频总能吸引人视线.虽然 AI 换脸在市面上已经流行了许久,相关制作工具或移动应用也是数不胜数.但是多数制作工具多数情况下不是会员就是收费,而且替换模 ...

  2. jQuery项目的小技巧

    1.返回顶部按钮 你可以利用 animate 和 scrollTop 来实现返回顶部的动画,而不需要使用其他插件. // Back to top // Back to top $('a.top').c ...

  3. Prompt Playground 7月开发记录

    Prompt Playground 2023年7月开发记录 上个月的时候,出于日常工作需求,做了一个简单的提示词调试工具 Prompt Playground. 这个工具的初衷是为了方便测试,所以没有做 ...

  4. pentaho(keetle)数据同步实践

    pentaho(keetle)数据同步实践 1 pentaho简介 pentaho可读作"彭塔湖",在keetle被pentaho公司收购后改名而来. pentaho是一款开源ET ...

  5. Vue项目打包后放到SpringBoot项目里注意点

  6. docker网络 bridge 与overlay 模式

    转载请注明出处: 1.bridge网络模式 工作原理:  在Bridge模式中,Docker通过创建一个虚拟网络桥接器(bridge)将容器连接到主机上的物理网络接口.每个容器都会被分配一个IP地址, ...

  7. 质量管理 | QC、QA、QM,去QA化与降本增效

    现在国内职业的质量管理都是从 CMMI 和 ISO 质量体系演化过来的,但是能做真正的质量管理的公司很少.质量管理的 QC 偏测试,对最终的产品负责:QA 偏过程,从过程把控质量:QM 偏体系,类似于 ...

  8. 《Kali渗透基础》01. 介绍

    @ 目录 1:渗透测试 1.1:安全问题的根源 1.2:安全目标 1.3:渗透测试 1.4:标准 2:Kali 2.1:介绍 2.2:策略 2.3:安装 3:Kali 初步设置 3.1:远程连接 3. ...

  9. 分享一个 SpringBoot + Redis 实现「查找附近的人」的小技巧

    前言 SpringDataRedis提供了十分简单的地理位置定位的功能,今天我就用一小段代码告诉大家如何实现. 正文 1.引入依赖 <dependency> <groupId> ...

  10. ISO/OSI七层模型的分层与作用

    ISO/OSI的七层模型 第七层:应用层 为用户提供服务,给用户一个操作界面,如window的图形界面,Linux的命令行: 第六层:表示层 数据提供表示:把01二进制转换为图像数字等用户可以看懂的内 ...