深度分析C#中Array的儲存結構

發表於2023-11-21
  陣列是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++對於記憶體的分配策略等視角,最後還綜合的介紹了CoreCLR對於陣列記憶體的建立步驟。

  我們一直以來對於陣列的記憶體分配,都有一個整體的認識,其特點是"相同型別、連續記憶體、順序儲存",對於其連續記憶體的特點記憶深刻,但是在內部如何實現進行的連續記憶體卻沒有整體的瞭解,C#內部是如何完成不同型別物件陣列的執行時建立,在CoreCLR內部如何進行記憶體的劃分是沒有做過瞭解和推導,甚至於CoreCLR內部是如何維護一個物件的結構,很多時候都只是瞭解到執行時物件使用Type型別就可以得到,那麼CoreCLR內部如何來維護這個Type呢?其實很多時候沒有特點去了解過其結構。

  以上內容是對C#中Array的儲存結構的簡單介紹,如錯漏的地方,還望指正。

相關文章