對象創建始末

本文將介紹以下內容:

對象的創建過程
內存分配分析
內存佈局研究

  1. 引言

  瞭解.NET的內存管理機制,首先應該從內存分配開始,也就是對象的創建環節。對象的創建,是個複雜的過程,主要包括內存分配和初始化兩個環節。例如,對象的創建過程可以表示爲:

FileStream fs = new FileStream(@"C:"temp.txt", FileMode.Create);

  通過new關鍵字操作,即完成了對FileStream類型對象的創建過程,這一看似簡單的操作背後,卻經歷着相當複雜的過程和周折。

  本篇全文,正是對這一操作背後過程的詳細討論,從中瞭解.NET的內存分配是如何實現的?

  2. 內存分配

  關於內存的分配,首先應該瞭解分配在哪裏的問題。CLR管理內存的區域,主要有三塊,分別爲:

  線程的堆棧,用於分配值類型實例。堆棧主要由操作系統管理,而不受垃圾收集器的控制,當值類型實例所在方法結束時,其存儲單位自動釋放。棧的執行效率高,但存儲容量有限。

  GC堆,用於分配小對象實例。如果引用類型對象的實例大小小於85000字節,實例將被分配在GC堆上,當有內存分配或者回收時,垃圾收集器可能會對GC堆進行壓縮,詳情見後文講述。

  LOH(Large Object Heap)堆,用於分配大對象實例。如果引用類型對象的實例大小不小於85000字節時,該實例將被分配到LOH堆上,而LOH堆不會被壓縮,而且只在完全GC回收時被回收。

  本文討論的重點是.NET的內存分配機制,因此下文將不加說明的以GC堆上的分配爲例來展開。關於值類型和引用類型的論述,請參見[第八回:品味類型---值類型與引用類型(上)-內存有理]。

瞭解了內存分配的區域,接着我們看看有哪些操作將導致對象創建和內存分配的發生,關於實例創建有多個IL指令解析,主要包括:

  newobj,用於創建引用類型對象。
  ldstr,用於創建string類型對象。
  newarr,用於分配新的數組對象。

  box,在值類型轉換爲引用類型對象時,將值類型字段拷貝到託管堆上發生的內存分配。

  在上述論述的基礎上,下面從堆棧的內存分配和託管堆的內存分配兩個方面來分別論述.NET的內存分配機制。

  2.1 堆棧的內存分配機制

  對於值類型來說,一般創建在線程的堆棧上。但並非所有的值類型都創建在線程的堆棧上,例如作爲類的字段時,值類型作爲實例成員的一部分也被創建在託管堆上;裝箱發生時,值類型字段也會拷貝在託管堆上。

  對於分配在堆棧上的局部變量來說,操作系統維護着一個堆棧指針來指向下一個自由空間的地址,並且堆棧的內存地址是由高位到低位向下填充。以下例而言:
public static void Main()
{
int x = 100;
char c = 'A';
}

  假設線程棧的初始化地址爲50000,因此堆棧指針首先指向50000地址空間。代碼由入口函數Main開始執行,首先進入作用域的是整型局部變量x,它將在棧上分配4Byte的內存空間,因此堆棧指針向下移動4個字節,則值100將保存在49997~50000單位,而堆棧指針表示的下一個自由空間地址爲49996,如圖所示:

  接着進入下一行代碼,將爲字符型變量c分配2Byte的內存空間,堆棧指針向下移動2個字節至49994單位,值’A’會保存在49995~49996單位,地址的分配如圖:

  最後,執行到Main方法的右括號,方法體執行結束,變量x和c的作用域也隨之結束,需要刪除變量x和c在堆棧內存中的值,其釋放過程和分配過程剛好相反:首先刪除c的內存,堆棧指針向上遞增2個字節,然後刪除x的內存,堆棧指針繼續向上遞增4個字節,程序執行結束,此時的內存狀況爲:

  其他較複雜的分配過程,可能在作用域和分配大小上有所不同,但是基本過程大同小異。棧上的內存分配,效率較高,但是內存容量不大,同時變量的生存週期隨着方法的結束而消亡。

 2.2 託管堆的內存分配機制

  引用類型的實例分配於託管堆上,而線程棧卻是對象生命週期開始的地方。對32位處理器來說,應用程序完成進程初始化後,CLR將在進程的可用地址空間上分配一塊保留的地址空間,它是進程(每個進程可使用4GB)中可用地址空間上的一塊內存區域,但並不對應於任何物理內存,這塊地址空間即是託管堆。

  託管堆又根據存儲信息的不同劃分爲多個區域,其中最重要的是垃圾回收堆(GC Heap)和加載堆(Loader Heap),GC Heap用於存儲對象實例,受GC管理;Loader Heap又分爲High-Frequency Heap、Low-Frequency Heap和Stub Heap,不同的堆上又存儲不同的信息。Loader Heap最重要的信息就是元數據相關的信息,也就是Type對象,每個Type在Loader Heap上體現爲一個Method Table(方法表),而Method Table中則記錄了存儲的元數據信息,例如基類型、靜態字段、實現的接口、所有的方法等等。Loader Heap不受GC控制,其生命週期爲從創建到AppDomain卸載。

  在進入實際的內存分配分析之前,有必要對幾個基本概念做以交代,以便更好的在接下來的分析中展開討論。

  TypeHandle,類型句柄,指向對應實例的方法表,每個對象創建時都包含該附加成員,並且佔用4個字節的內存空間。我們知道,每個類型都對應於一個方法表,方法表創建於編譯時,主要包含了類型的特徵信息、實現的接口數目、方法表的slot數目等。

  SyncBlockIndex,用於線程同步,每個對象創建時也包含該附加成員,它指向一塊被稱爲Synchronization Block的內存塊,用於管理對象同步,同樣佔用4個字節的內存空間。

  NextObjPtr,由託管堆維護的一個指針,用於標識下一個新建對象分配時在託管堆中所處的位置。CLR初始化時,NextObjPtr位於託管堆的基地址。

  因此,我們對引用類型分配過程應該有個基本的瞭解,由於本篇示例中FileStream類型的繼承關係相對複雜,在此本文實現一個相對簡單的類型來做說明:

// 2007 Anytao.com
//http://www.anytao.com
public class UserInfo
{
private Int32 age = -1;
private char level = 'A';
}

public class User
{
private Int32 id;
private UserInfo user;
}

public class VIPUser : User
{
public bool isVip;
public bool IsVipUser()
{
return isVip;
}

public static void Main()
{
VIPUser aUser;
aUser = new VIPUser();
aUser.isVip = true;
Console.WriteLine(aUser.IsVipUser());
}
}

  將上述實例的執行過程,反編譯爲IL語言可知:new關鍵字被編譯爲newobj指令來完成對象創建工作,進而調用類型的構造器來完成其初始化操作,在此我們詳細的描述其執行的具體過程:

  首先,將聲明一個引用類型變量aUser:
  VIPUser aUser;

  它僅是一個引用(指針),保存在線程的堆棧上,佔用4Byte的內存空間,將用於保存VIPUser對象的有效地址,其執行過程正是上文描述的在線程棧上的分配過程。此時aUser未指向任何有效的實例,因此被自行初始化爲null,試圖對aUser的任何操作將拋出NullReferenceException異常。

  接着,通過new操作執行對象創建:
aUser = new VIPUser();

  如上文所言,該操作對應於執行newobj指令,其執行過程又可細分爲以下幾步:

  (a)CLR按照其繼承層次進行搜索,計算類型及其所有父類的字段,該搜索將一直遞歸到System.Object類型,並返回字節總數,以本例而言類型VIPUser需要的字節總數爲11Byte,具體計算爲:VIPUser類型本身字段isVip(bool型)爲1Byte;父類User類型的字段id(Int32型)爲4Byte,字段user(UserInfo型)爲6Byte。

  實例對象所佔的字節總數還要加上對象附加成員所需的字節總數,其中附加成員包括TypeHandle和SyncBlockIndex,共計8字節(在32位CPU平臺下)。因此,需要在託管堆上分配的字節總數爲19字節,而堆上的內存塊總是按照4Byte的倍數進行分配,因此本例中將分配20字節的地址空間。

  (c)CLR在當前AppDomain對應的託管堆上搜索,找到一個未使用的20字節的連續空間,併爲其分配該內存地址。事實上,GC使用了非常高效的算法來滿足該請求,NextObjPtr指針只需要向前推進20個字節,並清零原NextObjPtr指針和當前NextObjPtr指針之間的字節,然後返回原NextObjPtr指針地址即可,該地址正是新創建對象的託管堆地址,也就是aUser引用指向的實例地址。而此時的NextObjPtr仍指向下一個新建對象的位置。注意,棧的分配是向低地址擴展,而堆的分配是向高地址擴展。

  另外,實例字段的存儲是有順序的,由上到下依次排列,父類在前子類在後,詳細的分析請參見[第十五回:繼承本質論]。

   在上述操作時,如果試圖分配所需空間而發現內存不足時,GC將啓動垃圾收集操作來回收垃圾對象所佔的內存,我們將以後對此做詳細的分析。

  最後,調用對象構造器,進行對象初始化操作,完成創建過程。該構造過程,又可細分爲以下幾個環節:

  (a)構造VIPUser類型的Type對象,主要包括靜態字段、方法表、實現的接口等,並將其分配在上文提到託管堆的Loader Heap上。

  (b)初始化aUser的兩個附加成員:TypeHandle和SyncBlockIndex。將TypeHandle指針指向Loader Heap上的MethodTable,CLR將根據TypeHandle來定位具體的Type;將SyncBlockIndex指針指向Synchronization Block的內存塊,用於在多線程環境下對實例對象的同步操作。

  (c)調用VIPUser的構造器,進行實例字段的初始化。實例初始化時,會首先向上遞歸執行父類初始化,直到完成System.Object類型的初始化,然後再返回執行子類的初始化,直到執行VIPUser類爲止。以本例而言,初始化過程爲首先執行System.Object類,再執行User類,最後纔是VIPUser類。最終,newobj分配的託管堆的內存地址,被傳遞給VIPUser的this參數,並將其引用傳給棧上聲明的aUser。

  上述過程,基本完成了一個引用類型創建、內存分配和初始化的整個流程,然而該過程只能看作是一個簡化的描述,實際的執行過程更加複雜,涉及到一系列細化的過程和操作。對象創建並初始化之後,內存的佈局,可以表示爲:

  由上文的分析可知,在託管堆中增加新的實例對象,只是將NextObjPtr指針增加一定的數值,再次新增的對象將分配在當前NextObjPtr指向的內存空間,因此在託管堆棧中,連續分配的對象在內存中一定是連續的,這種分配機制非常高效。

  2.3 必要的補充

  有了對象創建的基本流程概念,下面的幾個問題時常引起大家的思考,在此本文一併做以探索:

  值類型中的引用類型字段和引用類型中的值類型字段,其分配情況又是如何?

  這一思考其實是一個問題的兩個方面:對於值類型嵌套引用類型的情況,引用類型變量作爲值類型的成員變量,在堆棧上保存該成員的引用,而實際的引用類型仍然保存在GC堆上;對於引用類型嵌套值類型的情況,則該值類型字段將作爲引用類型實例的一部分保存在GC堆上。在[ 第八回:品味類型---值類型與引用類型(上)-內存有理]一文對這種嵌套結構,有較詳細的分析。對於值類型,你只要記着它總是分配在聲明它的地方。

  方法保存在Loader Heap的MethodTable中,那麼方法調用時又是怎麼樣的過程?

  如上文所言,MethodTable中包含了類型的元數據信息,類在加載時會在Loader Heap上創建這些信息,一個類型在內存中對應一份MethodTable,其中包含了所有的方法、靜態字段和實現的接口信息等。對象實例的TypeHandle在實例創建時,將指向MethodTable開始位置的偏移處(默認偏移12Byte),通過對象實例調用某個方法時,CLR根據TypeHandle可以找到對應的MethodTable,進而可以定位到具體的方法,再通過JIT Compiler將IL指令編譯爲本地CPU指令,該指令將保存在一個動態內存中,然後在該內存地址上執行該方法,同時該CPU指令被保存起來用於下一次的執行。

  在MethodTable中,包含一個Method Slot Table,稱爲方法槽表,該表是一個基於方法實現的線性鏈表,並按照以下順序排列:繼承的虛方法,引入的虛方法,實例方法和靜態方法。方法表在創建時,將按照繼承層次向上搜索父類,直到System.Object類型,如果子類覆寫了父類方法,則將會以子類方法覆蓋父類虛方法。關於方法表的創建過程,可以參考[第十五回:繼承本質論]中的描述。

  靜態字段的內存分配和釋放,又有何不同?

  靜態字段也保存在方法表中,位於方法表的槽數組後,其生命週期爲從創建到AppDomain卸載。因此一個類型無論創建多少個對象,其靜態字段在內存中也只有一份。靜態字段只能由靜態構造函數進行初始化,靜態構造函數確保在類型任何對象創建前,或者在任何靜態字段或方法被引用前執行,其詳細的執行順序請參考相關討論。

  3. 結論

  對象創建過程的瞭解,是從底層接觸CLR運行機制的入口,也是認識.NET自動內存管理的關鍵。通過本文的詳細論述,關於對象的創建、內存分配、初始化過程和方法調用等技術都會建立一個相對全面的理解,同時也清楚的把握了線程棧和託管堆的執行機制。

  對象總是有生有滅,本文簡述其生,這是個偉大的開始。 

發佈了46 篇原創文章 · 獲贊 0 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章