標籤:

問題已經被建議刪除?


題主的原問題:

.NET的Array是如何向在託管堆中申請大內存塊的,或者說Array是如何實現的?

假定題主想問的是如何用C#或其它.NET語言,實現自定義數組類型,想知道如何從託管堆動態分配指定大小的內存;而不是想問自己如何實現一套CLI VES,其中數組的部分如何實現。

簡短回答:如果想完全不依託CLI(Common Language Infrastructure)內建的數組類型,想要在CLI上實現自定義的託管數組類型是不可能的。有一條歪路但非常的不好,所以算在「不能」的範疇里。

注意:「託管」(managed)是重點。非託管(unmanaged)的話想怎樣都可以。

=========================================

題主問Array是如何實現的,這涉及很多層面:

  • C#或其它.NET語言的語法層面:這些語言的前端編譯器會對數組有特別的語法支持,對創建數組的語法最終編譯生成newarr這條CIL / MSIL指令;
  • CLI層面:CTS(Common Type System)規範規定了數組的語義,標準庫規範規定了數組類型及其基類(System.Array)需要支持的方法,CIL規範規定了一系列專門實現數組操作的位元組碼指令,其中分配內存的指令是newarr;
  • VM層面:CLI的實現,例如CLR、Mono,數組的語義貫穿於VM之中
    • 元數據:實現數組類型的類型系統以及反射支持
    • GC:實現數組類型的內存分配及釋放
    • JIT編譯器:實現數組類型的操作邏輯

正因為數組貫穿於這麼多的層面,每個層面都對數組有特殊支持,想要在用戶層面實現內建數組的替代品是不現實的。

=========================================

CLI上,涉及託管內存分配的CIL(Common Intermediate Language)/ MSIL位元組碼只有兩條:

  • newarr &:創建元素類型為etype的新數組對象實例

  • newobj &:對引用類型,創建類的未初始化的新對象實例;對值類型,創建未初始化的新值。然後調用ctor指定的構造器進行初始化。

沒了。沒有別的在CLI上標準的辦法可以分配託管內存。

這倆指令在VM里的實現都會直接跟GC打交道,向託管堆申請內存資源。這種動作是完全被封裝起來、無法從用戶代碼層面直接調用的。

例如說對於new object[10],CLR的JIT會生成代碼去調用CLR里的直接用彙編實現的JIT_NewArr1OBJ_MP_InlineGetThread()函數,以bump-the-pointer方式從當前線程在GC堆里分配來的alloc context分配所需的內存。

題主說:

我想自己從頭實現數組類型,但是找不到向託管堆申請指定大小內存的方法!

因為完全封裝住了,並不向用戶代碼層面暴露。

還有一條CIL / MSIL位元組碼是用來分配內存的,但分配的是非託管內存:

  • localloc:在「局部內存池」中分配指定大小的無類型(untyped)內存塊。這主要用於支持諸如C#的unsafe功能中的stackalloc關鍵字之類的功能,在棧幀上動態分配內存。

至於非託管的堆內存的分配和釋放,這是通過對非託管庫的調用(malloc() / free())來實現的,在CIL / MSIL里並沒有特別的指令去支持。

上面提到這幾條位元組碼指令,主要是為了說明CLI所支持的託管內存分配只有兩種情況,而這兩種情況分別是(重要的事情再說一遍):

  • newarr:創建新數組對象實例,例如new object[10]、new int[10]等;
  • newobj:對引用類型,創建類的對象實例,例如new List&()等;

那麼為啥數組對象與一般的類實例要分開對待?這是因為CLI的類型系統里,對象可以根據其類型與其大小的關係,分為3類:

  1. 不可變大小:同屬某類型的對象實例全部必須是固定的大小,於是知道了對象的類型就自動知道了其大小。一般的類的實例全部都是這種情況。例如說兩個System.Random對象實例,它們的大小一定是一樣的。
  2. 可變大小:同屬某類型的對象實例可以有不同的大小,於是知道的對象的類型還不足以知道其實例大小。所有CLI內建的數組類型都是這種情況,所以在數組對象實例里包含一個隱藏欄位來記錄數組長度,也就是用於實現數組的.Length屬性。例如說兩個int[]對象實例,數組長度並不是類型的一部分,所以兩個長度不同的int[]對象實例雖然類型相同但大小卻不一樣。
  3. VM直接支持的特殊類型:雖然是類的實例,但同屬該類型的對象實例卻可以有不同的大小。這種類型都是在VM里有特殊支持,無法在上層的用戶代碼里自定義的。典型例子是System.String:它有固定大小的對象頭,後面粘著一個可變長度的尾巴來存儲實際的字元串內容。

那麼像System.Collections.Generic.List&這樣的「可變長度容器」是如何實現的呢?請跳傳送門:

arraylist和array在內存分配和調用、編譯上有什麼本質區別? - RednaxelaFX 的回答

簡單說:這些外表是「可變長度」的純託管容器背後一定在什麼地方有數組(自身就可變長度)或鏈式結構(串其可變個數的固定大小對象,例如鏈表)。當然要實現混合了非託管資源的可變長度容器也可以,但這就不在本題的「託管內存」的討論範疇內了。

CLI對託管堆上所分配的內存有非常嚴格的類型安全要求,所有分配的託管內存都必須是強類型的,類型要滿足上述三種情況之一;不可以分配無類型(untyped)的託管內存。通過強制要求託管堆上分配的內存都帶有強類型信息,CLI的實現就可以完全掌控堆里的數據,例如說可以確保知道堆里哪些地方持有託管指針(引用)。

因此,題主想要做的,本質上是要達到上面(3)的效果,但又不想依賴(1)的支持。這在CLI上是做不到的。

=========================================

開頭提到有條歪路,那是什麼呢?回憶上面提到了newobj,但本回答里還沒用上對不對?

在託管堆上分配內存不是一定要合適的類型么?那就造個「合適」的類型就好了嘛。

對於元素類型為值類型的「自定義數組」類型,可以這樣做:

  • 先寫一個類作為這系列「自定義數組」類型的基類,實現自己需要的數組相關方法;
  • 然後對每一個數組大小×元素類型組合,通過System.Reflection.Emit.TypeBuilder來動態創建有合適的類型及個數的欄位的類,然後進一步通過反射來創建該類的實例。這樣就得到了至少有自己所希望的大小的類,模擬了「指定大小動態分配託管內存」的意圖。這些動態創建的類型可以放在一個字典里緩存起來,便於重複使用;
  • 基類里實現的數組方法,不考慮效率的話可以通過反射去實現數組元素的訪問(表面上實現的是數組元素訪問,底下實際上通過反射訪問欄位來實現);考慮效率的話可以試著用C#的unsafe功能把一堆欄位當作數組來訪問。

這麼做的話,要模擬出數組的表象還是做得到的。但背後的開銷頗大,特別是可以想像會需要動態創建大量的類,給VM的元數據管理帶來極大的壓力,性能也未必有多好。又是何必呢。


謝 @RednaxelaFX 大邀請

直接調試虛擬機代碼其實流程是蠻清晰的,在CLR里,為了避免頻繁的鎖,每個線程都有自己的allocation context,在創建數組的時候,如果數組大小小於65535 - 256這個大小(其中65535是大對象(Large Object)的底限,而為什麼要減去256這個數字,我暫時還不是很清楚),那麼就在allocation context里創建(代碼我還沒有讀的很細,allocatino context貌似也是在GC Heap上的),否則在GC heap上創建。

CLR對於primitive type(也就是int, short之類的類型)的數組,和object數組的處理是不一樣的,具體CLR的對象內存布局請參考我的文章:.NET對象的內存布局 - .NET源碼分析 - 知乎專欄。因此在分配內存的時候,對primitive type的數組和object數組也是區別處理的。關於是否是在large object heap上分配,從源碼看,貌似如果不開一個編譯標誌位的話,一維的primitive type的數組永遠都不會在large object heap上分配,當然我代碼讀的還不是很細,多維數組的情形沒有跟蹤源碼。

這是我用來跟蹤CLR源碼時使用的一個測試代碼:

using System;

public class Program
{
public static void Main()
{
System.Diagnostics.Debugger.Break();
var aha = new int[65535 + 1];
aha[0] = 123;
// Single-dimensional array
int[] numbers = new int[5];
}
}

按照CoreCLR的文檔編譯CLR源碼 coreclr/windows-instructions.md at master · dotnet/coreclr · GitHub,並按照下面這個步驟編譯上面的測試代碼(不要使用前面鏈接里的方法):

csc /nostdlib /noconfig /r:runtimemscorlib.dll /out:runtimehello.exe hello.cs

前面我使用的測試代碼在Main函數的第一行加了一行System.Diagnostics.Debugger.Break調用的目的是為了排除CoreCLR本身調用mscorlib代碼時,比如創建數組時觸發了我用來跟蹤創建數組的斷點。後面的編譯命令是告訴csc不要使用系統自帶.net framework的mscorlib.dll,而是用我們編譯出來的。

使用visual studio調試編譯好的corerun.exe,調試方法請參考文檔:coreclr/debugging-instructions.md at master · dotnet/coreclr · GitHub,然後在coreclr/JitHelpers_InlineGetThread.asm at 959025a1ec18e6685e27c0a3ea579c78487daa25 · dotnet/coreclr · GitHub上設置斷點就可以跟蹤,如下圖:

彙編源碼稍微解釋一下,我彙編不是很熟,有些指令也是現查資料的

LEAF_ENTRY JIT_NewArr1VC_MP_InlineGetThread, _TEXT
; RCX寄存器保存的是數組元素的類型信息,也就是type descriptor
; RDX寄存器保存的是數組的大小
; 在前面VS調試的時候,打開寄存器窗口可以看到各個寄存器的值
; 原始的英文注釋里說明了減去256的原因,這裡我就不翻譯了,看的一知半解的
; 下面這個mov指令取array類型的method table
mov r9, [rcx + OFFSETOF__ArrayTypeDesc__m_TemplateMT - 2]

FIX_INDIRECTION r9

; 這裡就是做數組大小的範圍比較了,如果大於65535 - 256這個值,那麼就跳轉
; 到錯誤處理那塊代碼
cmp rdx, (65535 - 256)
jae OversizedArray

; 使用數組的大小乘以元素的大小,如果是primitive type,就是primitive type
; 的大小,否則就是指針的大小,把結果保存到 r8d 這個寄存器里
movzx r8d, word ptr [r9 + OFFSETOF__MethodTable__m_dwFlags] ; component size is low 16 bits
imul r8d, edx
add r8d, dword ptr [r9 + OFFSET__MethodTable__m_BaseSize]

add r8d, 7
and r8d, -8

; 上面的代碼都是計算數組大小以及處理對齊的
; 下面看當前線程的allocation context里是否還有足夠的空間
; alloc_limit,就是allocation context的限額
; alloc_ptr,是指向當前已經分配的最後一個對象的後面,也就是新對象的位置
PATCHABLE_INLINE_GETTHREAD r11, JIT_NewArr1VC_MP_InlineGetThread__PatchTLSOffset
mov r10, [r11 + OFFSET__Thread__m_alloc_context__alloc_limit]
mov rax, [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr]

; 如果溢出那麼就跳轉到錯誤處理代碼
add r8, rax
jc AllocFailed

; 如果空間不夠那麼就跳轉到錯誤處理代碼
cmp r8, r10
ja AllocFailed

; 正式給數組分配空間,也就是簡單的指針加法操作
; 這個符合棧分配或者GC分配內存的邏輯
mov [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr], r8
mov [rax], r9

; 在新分配的數組對象的頭部保存元素大小
mov dword ptr [rax + OFFSETOF__ArrayBase__m_NumComponents], edx

; 調試版本才會編譯啟用這段代碼
ifdef _DEBUG
call DEBUG_TrialAllocSetAppDomain_NoScratchArea
endif ; _DEBUG

ret

; 如果分配不成功,那麼就調用 JIT_NewArr1 這個函數在GC Heap上嘗試分配內存
OversizedArray:
AllocFailed:
jmp JIT_NewArr1
LEAF_END JIT_NewArr1VC_MP_InlineGetThread, _TEXT

而JIT_NewArr1函數是傳統的C++函數,我簡化一下代碼說明下

HCIMPL2(Object*, JIT_NewArr1, CORINFO_CLASS_HANDLE arrayTypeHnd_, INT_PTR size)
{
OBJECTREF newArray = NULL;
TypeHandle typeHnd(arrayTypeHnd_);

typeHnd.CheckRestore();
ArrayTypeDesc* pArrayClassRef = typeHnd.AsArray();

if (size &< 0) COMPlusThrow(kOverflowException); // // 判斷是否是primitive type // CorElementType elemType = pArrayClassRef-&>GetArrayElementTypeHandle().GetSignatureCorElementType();

if (CorTypeInfo::IsPrimitiveType(elemType))
{
// 不允許創建一個void[]數組
if (elemType == ELEMENT_TYPE_VOID)
COMPlusThrow(kArgumentException);

BOOL bAllocateInLargeHeap = FALSE;

// 只有下面這個編譯標誌位打開了,才會判斷是否在 LargeObjectHeap上創建數組
// 而且創建的話,會記錄一個日誌,clr採用一個特殊的全局開關可以看到這個日誌
#ifdef FEATURE_DOUBLE_ALIGNMENT_HINT
if ((elemType == ELEMENT_TYPE_R8)
(static_cast&(size) &>= g_pConfig-&>GetDoubleArrayToLargeObjectHeapThreshold()))
{
STRESS_LOG1(LF_GC, LL_INFO10, "Allocating double array of size %d to large object heap
", size);
bAllocateInLargeHeap = TRUE;
}
#endif

if (g_pPredefinedArrayTypes[elemType] == NULL)
g_pPredefinedArrayTypes[elemType] = pArrayClassRef;

// 使用 FastAllocatePrimitiveArray 函數分配內存
newArray = FastAllocatePrimitiveArray(pArrayClassRef-&>GetMethodTable(), static_cast&(size), bAllocateInLargeHeap);
}
else
{
// 非primitive type數組使用AllocateArrayEx分配內存
INT32 size32 = (INT32)size;
newArray = AllocateArrayEx(typeHnd, size32, 1);
}

return(OBJECTREFToObject(newArray));
}
HCIMPLEND

順便說一下,如果大家在前面設置斷點的時候,查看一下堆棧,會發現堆棧的底部(也就是入口函數)不是main函數,如下圖:


@RednaxelaFX 大大從原理上解釋了為啥不能從託管堆中申請大內存塊

我來從概念上解釋一下,這句話其實是一個病句:

託管和申請內存這兩件事情是相悖的。

如果你選擇託管內存,那麼你就不能申請內存,你只能申請對象(程序員都有好多好多對象)

如果你想要直接申請內存塊,那麼這就不是託管內存了(因為託管的意思就是你不直接訪問內存)

當然,使用C#是可以申請非託管內存的(native memory),然後題主就可以實現一個數組(這東西需要實現嗎?)如下:

IntPtr handle = System.Runtime.InteropServices.Marshal.AllocHGlobal(1024);
unsafe
{
byte* arr = (byte*)handle.ToPointer();
Console.WriteLine(arr[123]);
}

System.Runtime.InteropServices.Marshal.FreeHGlobal(handle);

有興趣可以查閱MSDN,AllocHGlobal就是申請一段非託管內存,然後你就像在寫C一樣愛怎麼pointer怎麼pointer了(編譯需要加上/unsafe選項)。而且在使用過後一定記得釋放內存(使用FreeHGlobal函數)。

或者,如果題主非常在意使用託管內存,那麼也不是沒有辦法,題主需要申請一個託管對象,然後把這個對象當做內存來使用,下面這個例子用一個byte數組當做內存塊來使用,也可以使用MemoryStream對象:

class MyIntArray
{
private byte[] buffer;

public MyIntArray(int size)
{
buffer = new byte[size * 4];
}

public int this[int index]
{
get
{
return buffer[index * 4] +
(buffer[index * 4 + 1] &<&< 8) + (buffer[index * 4 + 2] &<&< 16) + (buffer[index * 4 + 3] &<&< 24); } set { // 懶得寫 } } }

C#寫多了已經忘記int是大頭還是小頭的了,意思到了就行了哈


其實R大出手已經沒什麼好補充的了。

數組是個特殊類型,從頭特殊到尾,從創建數組對象就有專用的IL,也是有限幾個可變大小的託管類型。

不過,如果真的想在託管堆裡面申請指定大小的內存,那還真的可以用數組來實現。數組本質上就是一坨動態分配的內存,然後包裝成了一個Array對象,儲存了其大小等信息。

所以先分配一個數組,然後直接fixed釘住指針指過去就可以當一坨內存來用了(嘛,C#支持指針你不知道?)。

如果嫌每次都要fixed很蛋疼,也可以自己定義一個對象,構造函數裡面分配一坨非託管內存,再到析構函數裡面去釋放掉這坨非託管內存(析構函數會由GC調用)。也可以享受到全自動垃圾回收的好處。當然最好再實現個IDisposable介面手動釋放。

既然都說了是託管堆了,當然不可能有什麼分配內存的介面,要不怎麼託管,你只能創建一個對象,然後託管堆丟給你一個引用,GC幫你看著這個引用要啥時候弄丟了就把託管堆的內存釋放掉。


恩,我想說。為嘛不用List& 而跑去用Array。


推薦閱讀:

為什麼知乎上的回答越來越長?
知乎有些用戶回答少但是編輯比較多,是否代表此人比較糾結?
在知乎回答問題,你在乎別人點不點贊嗎?回答問題又是出於什麼原因?
知乎的回答風格有哪些 ?
為什麼知乎上的回答總有那麼一部分,不針對內容回答反而對於抖機靈樂此不疲?

TAG:知乎回答 |