.NET Core CSharp初級篇 類的生命歷程

WarrenRyan發表於2019-07-27

.NET Core CSharp初級篇 1-7

本節內容為類的生命週期

引言

物件究竟是一個什麼東西?對於許多初學者而言,物件都是一個非常抽象的知識點。如果非要用一句話描述,我覺得“萬物皆物件”是對於物件最全面的概述了。本節內容中,我們將以在富土康打工的張全蛋組裝一臺水果手機作為例子,詳細的講解物件導向的各個方面。

物件類的構造

“張全蛋,你去水果公司,把他們的組裝零件需求清單帶過來~,並且還要帶上組裝的技術說明書。”車間主任吆喝著叫張全蛋辦事。張全蛋前往了水果公司,如願以償的拿到了他想要的東西,組裝零件清單上寫著:

  • amoled螢幕*1
  • 電池3000MA *1
  • CPU*1
  • 記憶體*1

技術說明上寫著:

  • 組裝零件:螢幕放置在頂部,電池在底部,中間夾著PCB板,PCB上面封住CPU和記憶體
  • 開機方法:長按開機鍵三秒

限於篇幅,我們只列舉這些,你可以發現,我們的組裝清單上面,事實上就是我們手機的組成部分,需要佔用手機內部空間,並且是這個手機的重要組成引數。這就和我們類中的屬性和欄位的功能是一樣的;而技術說明,是對於這裡的具體操作,他們是一個工序,一個操作,並不是一個實體,因此他們就是和我們類中的函式是一個意思。

突然一位老員工對張全蛋說,其實啊,每一款水果手機都幾乎沒多大差別,你可以在機器中預設好記憶體大小和CPU的型號,這樣你就可以直接將模具做好了。面對這種情況,張全蛋想出了一個絕妙的方法,那就是在建構函式中傳入引數。

因此我們可以構造出這樣一個類

class FruitPhone
{
    public FruitPhone(int msize,string cpuType)
    {
        CpuType = cpuType;
        MemSize = msize;
    }

    public string ScreenType{get;set}
    public string CpuType{get;set;} 
    public int MemSize{get;set;}
    public int Battery{get;set;}

    void Make()
    {
        //todo
    }
    void Open()
    {
        //todo
    }
}

物件的出生

物件就像個體的人,生而入世,死而離世。我們的故事就從物件之生開始吧。首先,看看在上面的例子中,一個物件是如何出生的。

FruitPhone p = new FruitPhone(2,"A12");

我們通過呼叫建構函式,成功的創造了一個手機物件,在手機被建立的同時,雖然我們還沒有組裝好螢幕一類的,但是我們在手機模具中也需要預留他們的空間,因此在物件例項化的時候,其內部的每個欄位都會被初始化。

對於螢幕和電池一類的,我們後續可能會根據成本等等進行調整,對
象的出生也只是完成了對必要欄位的初始化操作,其他資料要通過後面的操作來完成。例如對屬性賦值,通過方法獲取必要的資訊等。

物件在記憶體中的建立過程

關於記憶體的分配,首先應該瞭解分配在哪裡的問題。CLR 管理記憶體的區域,主要有三塊,分別為:

  • 執行緒的堆疊,用於分配值型別例項。堆疊主要由作業系統管理,而不受垃圾收集器的控制,當值型別例項所在方法結束時,其儲存單位自動釋放。棧的執行效率高,但儲存容量有限。
  • GC 堆,用於分配小物件例項。如果引用型別物件的例項大小小於 85000 位元組,例項將被分配在 GC 堆上,當有記憶體分配或者回收時,垃圾收集器可能會對 GC 堆進行壓縮,詳情見後文講述。
  • LOH(Large Object Heap)堆,用於分配大物件例項。如果引用型別物件的例項大小不小於 85000 位元組時,該例項將被分配到 LOH 堆上,而 LOH 堆不會被壓縮,而且只在完全 GC 回收時被回收。

對於分配在堆疊上的區域性變數來說,作業系統維護著一個堆疊指標來指向下一個自由空間的地址,並且堆疊的記憶體地址是由高位到低位向下填充。

而對於引用型別的例項分配於託管堆上,而執行緒棧卻是物件生命週期開始的地方。對 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 解除安裝。

對於本例中的物件建立,首先會在棧中宣告一個指向堆中資料的指標(引用),它佔用4個位元組,然後呼叫newobj指令,搜尋該類是否含有父類,如果有,則從父類開始分配記憶體,對於本例中,FruitPhone物件所需要的記憶體為4位元組的string引用兩個,4位元組的int*2。例項物件所佔的位元組總數還要加上物件附加成員所需的位元組總數,其中附加成員包括 TypeHandle 和 SyncBlockIndex,共計 8 位元組(在 32 位 CPU 平臺下),共計24位元組。

CLR 在當前 AppDomain 對應的託管堆上搜尋,找到一個未使用的 20 位元組的連續空間,併為其分配該記憶體地址。事實上,GC 使用了非常高效的演算法來滿足該請求,NextObjPtr 指標只需要向前推進 20 個位元組,並清零原 NextObjPtr 指標和當前 NextObjPtr 指標之間的位元組,
然後返回原 NextObjPtr 指標地址即可,該地址正是新建立物件的託管堆地址,也就是p引用指向的例項地址。而此時的 NextObjPtr 仍指向下一個新建物件的位置。注意,棧的分配是向
低地址擴充套件,而堆的分配是向高地址擴充套件。

最後,呼叫物件構造器,進行物件初始化操作,完成建立過程。該構造過程,又可細分為
以下幾個環節:

  • 構造 FruitPhone 型別的 Type 物件,主要包括靜態欄位、方法表、實現的介面等,並將其
    分配在上文提到託管堆的 Loader Heap 上。
  • 初始化 p 的兩個附加成員:TypeHandle 和 SyncBlockIndex。將 TypeHandl
    e 指標指向 Loader Heap 上的 MethodTable,CLR 將根據 TypeHandle 來定位具體的 Type;
    將 SyncBlockIndex 指標指向 Synchronization Block 的記憶體塊,用於在多執行緒環境下對例項
    物件的同步操作。
  • 呼叫 FruitPhone 的構造器,進行例項欄位的初始化。例項初始化時,會首先向上遞迴執
    行父類初始化,直到完成 System.Object 型別的初始化,然後再返回執行子類的初始化,直到
    執行 FruitPhone 類為止。以本例而言,初始化過程為首先執行 System.Object 類,直接執行 FruitPhone。最終,newobj 分配的託管堆的記憶體地址,被傳遞給 FruitPhone 的 thi
    s 引數,並將其引用傳給棧上宣告的 p。

上述過程,基本完成了一個引用型別建立、記憶體分配和初始化的整個流程,然而該過程只能看作是一個簡化的描述,實際的執行過程更加複雜,涉及到一系列細化的過程和操作。

(插入記憶體影象)

補充

靜態欄位的記憶體分配和釋放,又有何不同?

靜態欄位也儲存在方法表中,位於方法表的槽陣列後,其生命週期為從建立到 AppDomain
解除安裝。因此一個型別無論建立多少個物件,其靜態欄位在記憶體中也只有一份。靜態欄位只能由靜
態建構函式進行初始化,靜態建構函式確保在型別任何物件建立前,或者在任何靜態欄位或方法
被引用前執行,其詳細的執行順序請參考相關討論。

物件的消亡

在這一部分,我們首先觀察物件之死,以此反思和體味人類入世的哲學,兩者相比較,也會給我們更多關於自己的啟示。物件的生命週期由 GC 控制,其規則大概是這樣:GC 管理所有的託管堆物件,當記憶體回收執行時,GC 檢查託管堆中不再被使用的物件,並執行記憶體回收操作。不被應用程式使用的物件,指的是物件沒有任何引用。關於如何回收、回收的時刻,以及遍歷可回收物件的演算法,是較為複雜的問題,我們將在 後續進行深度探討。不過,這個回收的過程,同樣使我們感慨。大自然就是那個看不見的 GC,造物而又終將萬物回收,無法改變。我們所能做到的是,將生命的週期拓寬、延長、書寫得更加精彩

Reference

你必須知道的.NET

Github

BiliBili主頁

WarrenRyan's Blog

部落格園

相關文章