從面試題【一個空物件佔用多少記憶體】聊聊物件的記憶體佈局

語言: CN / TW / HK

本文轉自林德熙的部落格(blog.lindexi.com)

導語

在 C# 中的物件大概可以分為三個不同的型別,包括值型別、引用型別和其他型別。本文主要討論的是引用型別對記憶體空間的佔用情況。在討論開始之前我想問問大家,一個空的物件會佔用多少記憶體空間?當然這個問題本身就有問題,因為沒有區分棧空間與堆空間的記憶體空間。其實小夥伴會發現這不是一個好回答的問題,因為似乎沒有一個可以認為標準的標準答案。請讓我為你詳細聊聊物件的記憶體佈局

在開始之前,先廣告一下農夫的書 《.NET Core底層入門》 這本書寫的非常底層,記憶體這一篇章寫的特別棒。如果看本文之後覺得更加迷糊了,請看農夫大大的書

開始的問題其實問題本身不算對,為什麼呢?因為咱 .NET 可以在 x86 和 x64 和 ARM 等執行,而執行時包括了 .NET Framework 和 .NET Core 還有 mono 和 .NET Micro Framework 等,這些表現有稍微的不同。至少有趣的 .NET Framework 有超級多個不同的版本,本考古學家也不能確定這些版本之間是否存在差異,只是聽小夥伴吹過。而 .NET Micro Framework (NETMF) 本身就是設計在極度小的記憶體下執行,裡面對引用型別做了很多有趣的優化,而我僅僅知道有優化,具體做了什麼就不知道了,也不想知道

而 .NET Core 下還有一個有趣的技術叫 .NET Native 通過這個有趣的技術可以極大混淆引用型別和值型別的概念,這個技術底層沒啥文件,需要自己去翻程式碼。是否有差別還請大佬們教教我

本文僅能告訴大家的只有是 .NET Core 3.1 在 x86 和 x64 下的引用型別的記憶體佔用情況

在我寫本文的時候,實際上是很慌的,有太多的分支我沒有理清楚。在重新閱讀了農夫的 《.NET Core底層入門》和 《CLR via C#》和 https://github.com/dotnet/runtime 的很小一部分程式碼之後,稍微有點底氣來和大家聊聊

以下情況是不在本文討論範圍

  • .NET Framework

  • .NET Micro Framework

  • Mono

  • IL2CPP

  • .NET Native

  • WASM

  • ARM

  • ARM64

  • AOT

  • Itanium

在說到記憶體優化等,這裡說的記憶體預設都是說堆空間的記憶體空間。為什麼不提到棧空間的記憶體空間?因為棧空間預設是固定大小(.NET Core)也就是用或不用都需要這麼大的空間。而棧空間會隨方法的執行結束自動清空方法佔用的棧空間,這部分就包含了區域性變數佔用的棧空間。因此使用棧空間不存在記憶體回收壓力,也不存在記憶體分配的效能問題。但棧空間是很小的一段空間,一旦用完將會丟擲堆疊溢位

因此本文所說的空物件佔用的記憶體空間僅說佔用的堆空間的記憶體空間,這不意味著本文說的物件僅僅是引用型別物件,此時值型別物件也是能包含的。但可惜的是我不準備直接討論值型別物件在堆空間的情況

在開始之前,請讓咱忽略吃雞蛋應該從大的一頭開始吃還是從小的一頭開始吃的問題,從 x86 和 x64 開始比較好,這是從雞蛋小的一頭開始吃的故事。等等,怎麼到了吃雞蛋的時候了?其實我說的是大端和小端的問題哈。在 .NET Core 下,在 x86 與 x86-64 平臺儲存整數使用的是 Little Endian 小端法,而在 ARM 與 ARM64 平臺儲存整數使用的是 Big Endian 大端法。具體這兩個儲存方法有啥不同,請自行搜尋或看農夫的《.NET Core底層入門》 的第7章第二節

試試在 VS 裡面新建一個控制檯程式,在裡面建立一個物件,看看他的記憶體佈局是如何的

static void Main(string[] args)
{
var obj = new object();
}

在 obj 建立完成的下一行新增斷點,執行此斷點則記憶體中存在建立完成的 obj 物件

那如何在 VS 裡面檢視某個物件的記憶體?點選除錯視窗記憶體,在記憶體窗口裡面,可以開啟4個不同的記憶體視窗,同時看4個不同的記憶體。預設開啟記憶體1視窗就足夠了。這裡的記憶體4個視窗只是提供了4個視窗可以檢視不同的內容,能看到的記憶體是相同的記憶體

在記憶體裡面檢視某個物件的記憶體的方法是輸入這個物件的變數名

按下回車之後將會自動將變數名修改這個變數物件的記憶體的地址

這個代表什麼意思呢?儘管可以看到記憶體裡面的值,但是依然需要一點文件的輔助,才能瞭解含義。按照程式執行的原理,記憶體的值如果脫離了資料結構,那麼將沒有任意意義,和亂碼是相同的。但是有了對應的資料結構,那麼將可以解析出裡面的含義

從農夫的《.NET Core底層入門》書中可以看到,引用型別物件的值由以下三個部分組成

物件頭 (Object Header)

型別資訊 (MethodTable Pointer)

各個欄位的內容

物件頭包含標誌與同步塊索引 (SyncBlock Index) 等資料,在 32 位平臺上佔用 4 個位元組,在 64 位平臺上佔用 8 個位元組但只有後 4 個位元組會使用。型別資訊是一個記憶體地址,指向 .NET 執行時內部儲存的型別資料 (型別是 MethodTable),在 32 位平臺上佔用 4 個位元組,在 64 位平臺上佔用 8 個位元組

而預設執行的控制檯是使用 AnyCpu 執行的,而我的系統是 x64 系統,換句話說,此時的 .NET 程式是 x64 程式。在 x64 程式中,根據上面描述可以知道,型別資訊佔用了 8 個位元組

又根據 .NET 中引用型別物件本身儲存的記憶體地址指向型別資訊的開始,而物件頭會在 物件記憶體地址 - 4 的位置,可以瞭解到,當前記憶體裡面顯示的內容只是型別資訊 (MethodTable Pointer) 的值

因為咱建立的是一個空的 object 物件,因此不包含任何欄位,可以看到的內容如下

0x00000231B98AAD70  e8 0a 2e 5c fc 7f 00 00  ?..\?...
0x00000231B98AAD78  00 00 00 00 00 00 00 00  ........
0x00000231B98AAD80  00 00 00 00 00 00 00 00  ........
0x00000231B98AAD88  00 00 00 00 00 00 00 00  ........

而物件頭開始的地方是在 物件記憶體地址 - 4 的地址,可以在記憶體位址列新增上 -4 如下圖所示看到物件頭的值

為什麼在 物件記憶體地址 - 4 的地址就是物件頭的值?在 x64 和 x86 是相同的?沒錯,如上面所說,儘管物件頭會在 x64 佔用 8 個位元組,但是隻有後 4 個位元組會使用,因此 -4 就能看到物件頭了

那麼如何校驗一下關於物件頭和型別資訊的值,拿到的是對的值?可以在控制檯裡面多建立幾個空物件,根據相同型別的物件的型別資訊一定相同的原理,可以判斷咱剛才拿到的型別資訊是否是對的。如果多個相同的 object 的型別資訊都是相同的值,那麼證明多個相同的 object 型別的物件使用了指向相同的記憶體空間的型別資訊

static void Main(string[] args)
{
var obj = new object();
var obj1 = new object();
var obj2 = new object();
var obj3 = new object();
}

現在建立了 4 個 object 物件了,在執行程式碼的最後一句之後新增斷點,然後執行

理論上此時的應用程式將會將這幾個物件做連續的分配,因為此時的堆空間還沒有內容

咱先輸入 obj 到記憶體視窗的位址列,我可以看到以下資訊

0x000002532039AD70  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x000002532039AD78  00 00 00 00 00 00 00 00  ........
0x000002532039AD80  00 00 00 00 00 00 00 00  ........
0x000002532039AD88  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x000002532039AD90  00 00 00 00 00 00 00 00  ........
0x000002532039AD98  00 00 00 00 00 00 00 00  ........
0x000002532039ADA0  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x000002532039ADA8  00 00 00 00 00 00 00 00  ........
0x000002532039ADB0  00 00 00 00 00 00 00 00  ........
0x000002532039ADB8  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x000002532039ADC0  00 00 00 00 00 00 00 00  ........
0x000002532039ADC8  00 00 00 00 00 00 00 00  ........

可以看到有相同的 e8 0a 96 60 fc 7f 00 00 這一段二進位制值,那麼這應該就是型別資訊所在的地址了。嘗試看一下這個地址的值

根據在 x86 和 x64 下是小端顯示的,也就是 e8 0a 96 60 fc 7f 00 00 需要按照位元組反過來寫才是十六進位制的值,反過來寫是 0x00007ffc60960ae8 相當於去掉空格,然後兩個字元兩個字元,從後到前寫一次

開啟另一個記憶體視窗,輸入 0x00007ffc60960ae8 到位址列,就可以看到 型別資訊 的記憶體值

我這裡擷取一部分內容放在下面,用於證明這就是 型別資訊 的記憶體值

0x00007FFC60960AE8  00 02 00 00 18 00 00 00  ........
0x00007FFC60960AF0  08 40 7d 00 04 00 00 00  [email protected]}.....
0x00007FFC60960AF8  00 00 00 00 00 00 00 00  ........
0x00007FFC60960B00  20 40 86 60 fc 7f 00 00   @?`?...
0x00007FFC60960B08  50 0b 96 60 fc 7f 00 00  P.?`?...
0x00007FFC60960B10  18 bb 98 60 fc 7f 00 00  .??`?...
0x00007FFC60960B18  88 00 99 60 fc 7f 00 00  ?.?`?...
0x00007FFC60960B20  00 00 00 00 00 00 00 00  ........

如何證明這就是 型別資訊 的記憶體值?其實嘗試多次執行控制檯,看看每次 obj 對應的 型別資訊指標 指向的記憶體地址的值是不是和當前的相同,如果相同,那麼證明這就是 型別資訊 的值了

如上述測試,咱可以瞭解到在 x64 下一個 object 空物件在記憶體中佔用的 byte 數量是 3 * 8 個位元組大小

  • 8 位元組表示物件頭

  • 8 位元組表示型別資訊的記憶體地址的值

  • 8 位元組用於 object 的佔坑資訊(欄位記憶體對齊)

上面是不是歪樓了?什麼是佔坑資訊?其實就是本來放欄位的空間。咱試試在某個類裡面方一個簡單的 int 在裡面填寫特殊的數值,用來找到記憶體的存放這個欄位的空間

class Program
{
static void Main(string[] args)
{
var obj = new object();
var obj1 = new object();
var obj2 = new object();
var obj3 = new object();
var p1 = new Program();
}
private uint _foo = 0xFF020306;
}

如上面程式碼在 Program 類新增 _foo 欄位,然後創建出這個物件,理論上此時這個物件應該是在所有 object 物件的後面。注意,在記憶體裡面有很少物件的時候確實可以這麼說,後建立的物件就剛好放在新建立的物件後面。如果記憶體裡面存在碎片的時候,上面這句話就不一定對了。不過咱的測試程式足夠簡單,因此這句話還是對的

咱繼續在記憶體位址列輸入 obj 按下回車,此時顯示的記憶體就是這幾個物件的記憶體

0x000002961B1CAD70  e8 0a 94 60 fc 7f 00 00  ?.?`?...
0x000002961B1CAD78  00 00 00 00 00 00 00 00  ........
0x000002961B1CAD80  00 00 00 00 00 00 00 00  ........
0x000002961B1CAD88  e8 0a 94 60 fc 7f 00 00  ?.?`?...
0x000002961B1CAD90  00 00 00 00 00 00 00 00  ........
0x000002961B1CAD98  00 00 00 00 00 00 00 00  ........
0x000002961B1CADA0  e8 0a 94 60 fc 7f 00 00  ?.?`?...
0x000002961B1CADA8  00 00 00 00 00 00 00 00  ........
0x000002961B1CADB0  00 00 00 00 00 00 00 00  ........
0x000002961B1CADB8  e8 0a 94 60 fc 7f 00 00  ?.?`?...
0x000002961B1CADC0  00 00 00 00 00 00 00 00  ........
0x000002961B1CADC8  00 00 00 00 00 00 00 00  ........
0x000002961B1CADD0  88 1b a2 60 fc 7f 00 00  ?.?`?...
0x000002961B1CADD8  06 03 02 ff 00 00 00 00  ........

請先注意第一行,可以看到此時的型別資訊的記憶體地址的值和之前一次執行的不相同了,這次的值是 e8 0a 94 60 fc 7f 00 00 咱先嚐試在另一個記憶體視窗輸入這個地址 0x00007ffc60940ae8 看看型別資訊的記憶體

大概擷取一下內容

0x00007FFC60940AE8  00 02 00 00 18 00 00 00  ........
0x00007FFC60940AF0  08 40 7d 00 04 00 00 00  [email protected]}.....
0x00007FFC60940AF8  00 00 00 00 00 00 00 00  ........
0x00007FFC60940B00  20 40 84 60 fc 7f 00 00   @?`?...
0x00007FFC60940B08  50 0b 94 60 fc 7f 00 00  P.?`?...
0x00007FFC60940B10  18 bb 96 60 fc 7f 00 00  .??`?...
0x00007FFC60940B18  88 00 97 60 fc 7f 00 00  ?.?`?...
0x00007FFC60940B20  00 00 00 00 00 00 00 00  ........

可以和上面的值對比一下,大部分都是相同的,然後依然有幾個歪樓的值,咱這裡就先忽略

好接下來找到剛才定義的 _foo 的值,咱給他的是 0xFF020306 而根據小端的寫法,將會是如下的值 06 03 02 ff 沒錯,剛好放在了最後一行裡面

0x000002961B1CADD8  06 03 02 ff 00 00 00 00  ........

複習一下,在 C# 裡面無論在 x86 還是 x64 下,每個 int 都佔領 4 個位元組

如果覺得不夠直觀,咱修改一下物件建立的順序,請看程式碼

static void Main(string[] args)
{
var obj = new object();
var p1 = new Program();
var obj1 = new object();
var obj2 = new object();
var obj3 = new object();
}

此時在記憶體視窗輸入 obj 按下回車可以看到的值如下

0x00000106BFF2AD68  00 00 00 00 00 00 00 00  ........
0x00000106BFF2AD70  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x00000106BFF2AD78  00 00 00 00 00 00 00 00  ........
0x00000106BFF2AD80  00 00 00 00 00 00 00 00  ........
0x00000106BFF2AD88  88 1b a4 60 fc 7f 00 00  ?.?`?...
0x00000106BFF2AD90  06 03 02 ff 00 00 00 00  ........
0x00000106BFF2AD98  00 00 00 00 00 00 00 00  ........
0x00000106BFF2ADA0  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x00000106BFF2ADA8  00 00 00 00 00 00 00 00  ........
0x00000106BFF2ADB0  00 00 00 00 00 00 00 00  ........
0x00000106BFF2ADB8  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x00000106BFF2ADC0  00 00 00 00 00 00 00 00  ........
0x00000106BFF2ADC8  00 00 00 00 00 00 00 00  ........
0x00000106BFF2ADD0  e8 0a 96 60 fc 7f 00 00  ?.?`?...
0x00000106BFF2ADD8  00 00 00 00 00 00 00 00  ........

儘管 _foo 是一個int只佔用了 4 個位元組,但是根據位元組對齊,後面的 4 個位元組依然空閒不用。我也就是將他算在了這個物件上面

看到這裡小夥伴是不是能夠大概知道為什麼這個問題不好回答了,一個空的物件必定佔的記憶體一定包括 物件頭(syncblk資訊)和型別資訊,而後面的欄位的空間就有點爭議了,因為不確定是否要將佔坑的加上去。儘管這個空間不是我這個物件用的,但是其他物件也不用這部分空間

以上是 x64 下的物件記憶體佈局,大概可以認定答案是一個空物件佔用了3*8個位元組

那麼 x86 下的物件會如何?修改一下配置,讓控制檯在 x86 下執行

根據農夫大大的書可以瞭解在 x86 下的物件頭和型別資訊都是佔 4 個位元組。而此時物件的佔坑的欄位也是 4 個位元組,因此一個物件佔用的記憶體是 3*4 個位元組

執行剛才的程式,繼續在記憶體視窗輸入 obj 按下回車,此時可以看到的記憶體資訊如下圖。當然你看到的值應該和我看到的不相同

0x057EA794  9c 00 64 05 00 00 00 00  ?.d.....
0x057EA79C  00 00 00 00 e8 eb df 07  ....???.
0x057EA7A4  06 03 02 ff 00 00 00 00  ........
0x057EA7AC  9c 00 64 05 00 00 00 00  ?.d.....
0x057EA7B4  00 00 00 00 9c 00 64 05  ....?.d.
0x057EA7BC  00 00 00 00 00 00 00 00  ........
0x057EA7C4  9c 00 64 05 00 00 00 00  ?.d.....
0x057EA7CC  00 00 00 00 00 00 00 00  ........

這裡的 9c 00 64 05 就是 Object 的型別資訊,而後面的 00 00 00 00 就是佔坑的欄位空間。第一行是因為 obj 指向的記憶體是物件的型別資訊,而物件的物件頭資訊是放在型別資訊前面,因此在上圖就沒有看到第一個物件的物件頭

大概看到這裡,相信小夥伴也能理解一個空物件在佔用了多少堆記憶體空間了

那麼是不是有小夥伴好奇空物件可以在棧空間佔用多少記憶體?回答是0到爆棧這麼大,看你如何用