C# 內存分配

博文帶着3個疑問學習:(整理的有錯誤,請大家幫我改正)

問題1:CLR管理內存的三塊區域是什麼?

問題2:哪些操作會 創建對象和分配內存?

問題3:內存的分配機制?

1.CLR管理內存的三塊區域

注:內存——堆棧 堆(託管堆)

線程的堆棧:用於分配值類型的實例-有操作系統管理分配釋放內存。
GC堆(託管堆):用於分配引用類型的實例對象內存小於8500 byte的。當有內存分配時,垃圾回收器"可能"會對GC堆進行壓縮。
LOH堆(Large Object Heap):用於分配引用類型的大對象實例(大於8500byte),不會被垃圾回收器壓縮,而且只在GC堆完全被回收時回收。

2.哪些操作會 創建對象和分配內存?

IL指令:
newobj:引用類型對象創建
ldstr:string類型對象創建
newarr:數組對象創建
box:值類型轉換引用類型,值類型字段拷貝到託管堆上發生 內存分配

3.內存的分配機制

3.1 堆棧的內存分配機制

對於值類型:當作爲類的值類型成員時,這個時候值類型將被分配在託管堆(堆)上。如:

class Car
{
int carYear;
string carName;
}

即:Car oneCar=new Car(); 這個Car類的引用變量將被分配在線程堆棧上,而這個對象的成員,如:carYear,carName將被分配在堆上。
注意:堆棧-stack 堆-heap

對於堆棧的變量來說,是由操作系統來分配和釋放內存的,操作系統維護着一個堆棧指針來指向一個自由空間(未被分配的內存的開始位)。堆棧的分配是從高位——>低位,而釋放是從低位——>高位,如:

static void Main()
{
int i=1;
char a=‘A’;

}

分析:
分配
第一步:C#程序從Main函數開始,每個線程堆棧都有一個初始化地址,比如這個程序的初始化地址是100;
第二步:int類型佔有4字節,那麼開始堆棧指針是在100這個位置,然後分配4字節給值爲1的Int類型(100-97)保存,然後堆棧指針指向96.
第三步:char類型佔有2字節,那麼堆棧指針由96指向94。
第三步:當運行到右括號的時候,將會釋放內存
釋放
第四步:釋放的步驟是分配的反方向,堆棧指針逐步向上移動。

注:上面的方式更可以說是“局部變量的分配機制”,因爲這些值類型變量隨着方法的結束而結束,效率高,但是內存容量小。

3.2 堆(託管堆)的內存分配機制

對於引用類型:引用類型的變量的內存是分配在堆棧上的,而引用類型的對象實例的內存是分配在託管堆上的。
對於託管堆裏有2個重要的區域:GC堆和加載堆(Loader Heap)

GC堆存儲對象實例字段,由GC管理。
Loader堆有High-Frequency Heap、Low-Frequency Heap、Stub Heap,存儲的是元數據相關的信息,比如:Type對象,即方法表。方法表創建於編譯時,主要包含了類型的特徵信息、實現的接口數目、方法表的slot數目等。 Loader堆不受GC控制,生命週期是從創建到AppDomain卸載。

如下代碼:

class Program
{
static void Main(string[] args)
{
VIPUser vipuser;
vipuser = new VIPUser();
vipuser.isVip = true;
Console.WriteLine(vipuser.IsVip());
}
}

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 IsVip()
    {
        return isVip;
    }
}

分析:
第一步:Main函數開始,聲明一個VIPUser類型變量,這只是一個引用,或者說是一個指針,它是存儲在堆棧上的,佔有4個字節。這個時候,沒有指向對象,初始化爲NULL。
第二步:new對象,這個過程很複雜,因爲IL代碼爲newobj,這個過程會一直向上查找其所有父類,直到Object類型,並且這個過程會計算類型和所有繼承關係類型的字段,並且返回一個總的佔有字節數。
開始計算如下:
VIPUser類的bool類型 (1字節)+User類的[Int32類型(4字節)+UserInfo類型的user的引用(4字節)]+UserInfo類的[Int32類型的(4字節)+char類型的(2字節)]=1+8+6=15字節。

注意:在32位系統下,TypeHandle和SyncBlockIndex附加成員也會佔有8字節。即:一共是23字節,但是託管堆上一般是按4字節的倍數分配的,所以會分配24字節的內存。

注意:NextObjPtr是一個神奇的指針,他在託管堆中,標識下一個新建對象在託管中的位置,並且會返回對象實例的內存地址給 堆棧中的引用變量。
第三步:分配內存
對於堆棧是 先進後出,向低位擴展 ,分配內存是由上-下,釋放內存是由下-上。
對於堆是 先進先出,向高位擴展。分配內存是由下-上,由GC回收器回收內存。

對於引用類型,父類在前子類在後,當發現內存不足時,會啓動GC回收器,回收垃圾對象佔用的內存。
第四步:調用構造函數,進行初始化,完成創建對象過程
如下:
.構造VIPUser類型的Type對象,主要包括靜態字段、方法表、實現的接口等,並將其分配在上文提到託管堆的Loader Heap上。
.初始化vipuser的兩個附加成員:TypeHandle和SyncBlockIndex。將TypeHandle指針指向Loader Heap上的MethodTable,CLR將根據TypeHandle來定位具體的Type;將SyncBlockIndex指針指向Synchronization Block的內存塊,用於在多線程環境下對實例對象的同步操作。
.調用VIPUser的構造器,進行實例字段的初始化。實例初始化時,會首先向上遞歸執行父類初始化,直到完成System.Object類型的初始化,然後再返回執行子類的初始化,直到執行VIPUser類爲止。以本例而言,初始化過程爲首先執行System.Object類,再執行User類,最後纔是VIPUser類。最終,newobj分配的託管堆的內存地址,被傳遞給VIPUser的this參數,並將其引用傳給棧上聲明的vipuser

上一篇博文也說了一點關於內存的知識,但是不詳盡,這篇博文徹底的理解了從.net層面理解 引用類型的內存分配—>引用類型的堆內的工作,以及繼承的本質。

(說的不對,大家指正)

繼承
面向對象:實現單繼承和接口多繼承
對於.net通過訪問權限的修飾符控制安全:public protected internal private
抽象方法和虛方法纔可以被重寫override,而且虛方法不能是private纔可以被重寫,抽象方法必須是public.
接口的默認是公共抽象的方法,而且被繼承了,必須被實現。

1.繼承機制的執行

View Code

public abstract class Animal
{
public abstract void ShowType();
public void Eat()
{
Console.WriteLine(“Animal always eat.”);
}
}
public class Bird : Animal
{
private string type = “Bird”;
public override void ShowType()
{
Console.WriteLine(“Type is {0}”, type);
}
private string color;
public string Color
{
get { return color; }
set { color = value; }
}
}
public class Chicken : Bird
{
private string type = “Chicken”;
public override void ShowType()
{
Console.WriteLine(“Type is {0}”, type);
}
public void ShowColor()
{
Console.WriteLine(“Color is {0}”, Color);
}
}

public class TestInheritance
{
    public static void Main()
    {
        Bird bird = new Bird();
        Chicken chicken = new Chicken();
    }
}

分析:
1.程序入口依舊是 Main函數,當 Bird bird;時,堆棧中爲bird分配4字節的內存存儲指針指向堆中的對象實例地址。
2.new Bird()時,如下:
2.1先計算需要在堆中分配的內存,計算內存是從子類開始—基類結束。
2.2調用構造函數,構造Bird類型的Type對象,靜態方法,方法表,接口等,分配在Load Heap上,所以方法表式優先於對象分配內存的。
只有用到的類的方法表纔會被加載進Load堆。
注:任何類型方法表中,開始的4個方法總是繼承自System.Object類型的虛方法,它們是:ToString、Equals、GetHashCode和Finalize。
2.3 初始化Bird的兩個附加成員:TypeHandle和SyncBlockIndex。
2.4調用構造器,進行實例字段的初始化,初始化的過程是從基類-父類-子類。(由上一篇圖可知,是向高位擴展的方式,所以附件成員在下,字段在上)
字段的排序是:父類的字段在子類的字段前面,如果同名編譯器會認爲是不同的字段。
2.5 IL代碼newobj分配內存的地址將傳遞給引用變量:bird變量。

注意:方法表是在類第一次加載到AppDomain完成的,而且生命週期是直到AppDomain被卸載。
如果再有新的對象實例被創建,只是將對象的附件成員TypeHandle指向方法列表的Load Heap地址上。

3.當Chicken chicken時,大致是一樣的,但是會把父類的方法複製一份,然後與自己的方法列表比較,是否覆蓋
4. new Chicken()時,同上。

注意:而且爲對象實例分配內存,堆中式由下而上的,向高位擴展,類似於NextObjPtr指針是向上移動的。而在堆棧中,堆棧指針式向下移動的。
在回收內存時,必須GC堆被回收後纔會回收Load堆。

結論:對於堆棧,是向低位擴展,所以指針是向下移動的。
對於GC堆,對象的實例是向高位擴展的,所以指針是向上移動,但是對於初始化實例,存儲過程是在對象所佔內存中是由上到下,即在GC堆內部父類字段在前,子類字段在後。
對於Load堆是屬於堆的,所有也是從低位向高位擴展存儲分配,對於Load堆內部的方法表父類的方法在前,子類的方法在後。任何類型方法表中,開始的4個方法總是繼承自System.Object類型的虛方法,它們是:ToString、Equals、GetHashCode和Finalize。

  • 繼承是可傳遞的,子類是對父類的擴展,必須繼承父類方法,同時可以添加新方法。
  • 子類可以調用父類方法和字段,而父類不能調用子類方法和字段。
  • 虛方法如何實現覆寫操作,使得父類指針可以指向子類對象成員。
  • 子類不光繼承父類的公有成員,同時繼承了父類的私有成員,只是在子類中不被訪問。
  • new關鍵字在虛方法繼承中的阻斷作用。

疑惑1:Bird bird2 = new Chicken(); 調用方法的時候到底調用哪一個類的方法?子類?父類?
答:調用子類方法還是父類方法,取決於創建的對象是子類對象還是父類對象,與引用變量的類型無關。引用類型的不同只是決定了不同對象在方法表中的不同訪問權限。
如下代碼:

View Code

class Program
{
static void Main(string[] args)
{
P1 p1 = new P2();
p1.S();//因爲是P1類型的所以無法訪問P2的方法

        P2 p2 = new P2();
        p2.S1();
        p2.S();
    }
}
class P1
{
    public void S()
    {
        Console.WriteLine("我是父類方法");
    }
}
class P2 : P1
{
    public void S1()
    {
        Console.WriteLine("我是子類方法");
    }    
}

如果這樣聲明:
Bird bird2 = new Chicken();
Chicken chicken = new Chicken();
答:根據上文的分析,bird2對象和chicken對象在內存佈局上是一樣的,差別就在於其引用指針的類型不同:bird2爲Bird類型指針,而chicken爲Chicken類型指針。以方法調用爲例,不同的類型指針在虛擬方法表中有不同的附加信息作爲標誌來區別其訪問的地址區域,稱爲offset。不同類型的指針只能在其特定地址區域內執行,子類覆蓋父類時會保證其訪問地址區域的一致性,從而解決了不同的類型訪問具有不同的訪問權限問題。

  • 執行就近原則:對於同名字段或者方法,編譯器是按照其順序查找來引用的,也就是首先訪問離它創建最近的字段或者方法,例如上例中的bird2,是Bird類型,因此會首先訪問Bird_type(注意編譯器是不會重新命名的,在此是爲區分起見),如果type類型設爲public,則在此將返回“Bird”值。這也就是爲什麼在對象創建時必須將字段按順序排列,而父類要先於子類編譯的原因了。

注:也就是我們所說的 引用類型不同決定的是 訪問權限問題。

疑惑2:如果在子類內 new 父類的同名方法會怎麼樣?
答:關於new關鍵字在虛方法動態調用中的阻斷作用,也有了更明確的理論基礎。在子類方法中,如果標記new關鍵字,則意味着隱藏基類實現,其實就是創建了與父類同名的另一個方法,在編譯中這兩個方法處於動態方法表的不同地址位置,父類方法排在前面,子類方法排在後面。

Type is Chicken,爲什麼,這裏就沒有就近原則?
如下代碼:

View Code

public abstract class Animal
{
public abstract void ShowType();
public void Eat()
{
Console.WriteLine(“Animal always eat.”);
}
}
public class Bird : Animal
{
public string type = “Bird”;
public override void ShowType()
{
Console.WriteLine(“Type is {0}”, type);
}
private string color;
public string Color
{
get { return color; }
set { color = value; }
}
}
public class Chicken : Bird
{
public string type = “Chicken”;
public override void ShowType()
{
Console.WriteLine(“Type is {0}”, type);
}
public void ShowColor()
{
Console.WriteLine(“Color is {0}”, Color);
}
}

public class TestInheritance
{
    public static void Main()
    {
        Bird bird2 = new Chicken();
        Console.WriteLine(bird2.type);//bird 就近原則

        bird2.ShowType();//Type is Chicken,爲什麼,這裏就沒有就近原則?

    }
}

疑惑3:bird2.ShowType();//Type is Chicken,爲什麼,這裏就沒有就近原則?
答:這裏創建的是 Chicken對象,所以加載父類Bird方法表和自己的方法表,如果有重寫就覆蓋,這裏覆蓋了,所以沒有使用就近原則。
上面 bird2.type,因爲沒有被覆蓋,編譯器認爲是2個不同的變量,所以就近原則

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章