原文連接:https://www.c-sharpcorner.com/article/C-Sharp-heaping-vs-stacking-in-net-part-i/
引言
雖然使用.NET Framework時我們不需要主動關心內存管理和垃圾回收,但爲了保證程序的性能,對內存管理和垃圾回收還是應該有必要的瞭解。從根本上理解內存管理的工作方式,也可以幫助我們理解在項目中使用的每一個變量是如何工作的。在本篇中將帶你一起了解堆和棧的基礎,變量的類型以及他們是如何工作的。
在程序運行的過程中,.NET Framework將對象存儲在兩個空間裏。如果你還不知道,我這就給你介紹一下棧(Stack)和堆(Heap),這倆東西都在幫助我們運行代碼。他們寄存在設備的操作內存中,幷包含我們程序運行所需要的所有信息。
堆和棧的區別
棧主要負責存儲指令,堆主要負責保存對象和數據。
棧就像是一摞盒子,一個壓着一個。每當我們調用一個方法(有時候也叫幀"frame")時,就會在棧的頂部壓入一個指令用來告訴我們的程序該幹什麼。我們只能獲取到最頂端的那個盒子。當我們最頂端的盒子用完以後(方法執行完畢)就把它扔掉,繼續使用早先放進來的頂部的盒子。不同的是,堆是爲了存儲信息,而不關心命令的執行,所以堆中的任何信息都可以被隨時訪問到。在堆中沒有像棧一樣的讀寫限制。堆就像是我們洗好了但是沒有整理的散亂在牀上的衣服,我們可以快速的拿到我們想要的。棧就像是我們在壁櫥中已經摞好的鞋盒,我們必須拿走上面的,才能得到下面的。
(譯者曰:內存中的棧和數據結構的棧的性質是一樣的,都是後進先出)
上圖中的內容不能正確的表示在內存中的運行情況,只是爲了幫助理解堆和棧的區別。
棧是自我維護的,也就是說他們主要關心自己的內存管理。當頂部的盒子已經不再使用以後,就會被取出來。至於堆,則需要考慮垃圾回收(GC),以保持堆的整潔(沒有人希望到處都是的髒衣服,還散發出臭臭的味道)。
堆和棧上都有什麼?
在代碼運行的過程中,我們主要有四種類型的東東被放在堆和棧裏面:值類型(Value Types)、引用類型(Reference Types)、指針(Pointers)、指令(Instructions)。
值類型
C#中,下面的所有類型都是值類型,因爲他們都繼承自System.ValueType:
- bool
- byte
- char
- decimal
- double
- enum
- float
- int
- long
- sbyte
- short
- struct
- uint
- ulong
- ushort
引用類型
下面列出的都是引用類型,他們繼承自System.Object:
- class
- interface
- delegate
- object
- string
指針
第三種類型是類型的引用(a Reference to a Type),也就是指針。(譯者注:在C#中)我們不會顯示的使用指針,因爲他們被CLR管理。指針和引用類型不同,當我們在說啥啥啥是引用類型的時候,意思是說我們通過指針來訪問它。指針在內存中存儲的內容是其他內存空間的地址。指針空間的開闢和我們在堆棧上開闢其他東東的空間一樣,它的值可以是內存地址,也可以是Null。
指令
後面的內容將告訴你,指令是如何工作的...
怎樣決定什麼東西該去哪裏?
記住這兩條金科玉律:
- 引用類型一定去到堆中。這條夠簡單。
- 值類型和指針一般來說在哪裏聲明的就去到哪裏。這條一丟丟的複雜,需要對棧的工作方式有一定的瞭解以後,才能分辨出這些東東是在哪裏聲明的。
棧在代碼運行過程中用來表示各自線程(Thread)的進度。你可以把它理解爲線程的狀態,並且每個線程都有自己的棧。當代碼在執行一個方法時,線程就開始執行那些被編譯出來的在這個方法表中的指令,這會將該方法的參數壓入到線程的棧中。當我們在執行這個方法時,會將遇到的變量也都壓入到棧的頂部。舉個栗子理解起來會更簡單......
運行下面的代碼:
public int AddFive(int pValue)
{
int result;
result = pValue + 5;
return result;
}
接下來看一下在棧的最頂部到底做了什麼。切記,我們看到的這個棧,已經放了其他內容到裏面。
當我們開始執行這個方法的時候,方法的參數首先被壓入到棧內(之後我們會詳細的討論參數傳遞的問題)。
注意:這個方法是不再棧中的,只是作爲參考。
然後,如果方法是第一次被執行,則會進行JIT編譯,編譯出的指令被加入到方法的表中。
在執行方法的時候,我們需要一定的內存留給result變量,它也被分配在棧中。
方法執行結束以後,我們得到被返回的result。
通過將指向可用內存的指針移動到AddFive()方法開始的地方,可以將棧中分配的內存清理掉,然後我們就可以訪問上一個被裝進棧中的方法了。
在這個例子中,變量result被分配到棧中,事實上,所有的在方法內部聲明的值類型的變量,都會被分配在棧中。
現在,再來看看什麼時候值類型會被分配到堆中。還記得這條規則嗎,值類型在哪裏聲明的就到哪裏去。當然,如果一個值類型聲明在方法外部,但是在引用類型的內部,它就會跟着這個引用類型一起被分配到堆中。
再舉個栗子。
假設我們有下面這個MyInt類,因爲它是個class類型,所以它是個引用類型:
public class MyInt
{
public int MyValue;
}
並且將執行下面的這個方法:
public MyInt AddFive(int pValue)
{
MyInt result = new MyInt();
result.MyValue = pValue + 5;
return result;
}
跟前面一樣,線程在執行這個方法的時候,會把它的參數也壓入到棧中。
見證奇蹟的時刻...
因爲MyInt是一個引用類型,被分配在堆中,並且被一個棧中的指針所引用。
像第一個例子中一樣,當AddFive方法執行完畢以後,我們執行清理...
之後再也沒有對MyInt的引用了,它成了堆中的孤兒。
這時候就輪到GC表演了。當我們的程序達到一定的內存上限時,GC就會閃亮登場。GC會將所有的線程掛起(A FULL STOP),然後查找堆中的所有已經沒用的對象,並且刪除他們。然後GC會對堆中剩下的對象進行整理並且修改引用到這些對象的指針,不管是堆中的還是棧中的。你可以想象,這會造成巨大的內存開銷。這也就時爲什麼對於編寫高性能的代碼來說,關注堆棧中的內容這麼重要了。
(譯者注:GC的詳解看這裏)
好吧,太棒了,但這對我有什麼影響呢?
好問題!
當我們在使用引用類型時,我們是通過指針來完成的,而不是這個對象本身。當我們在使用值類型的時候,我們使用的是這個對象本身。這樣說也許還不夠清楚。
我們再來舉個栗子。
假設我們執行下面的方法:
public int ReturnValue()
{
int x = new int();
x = 3;
int y = new int();
y = x;
y = 4;
return x;
}
我們將得到3這個值,很簡單對吧。
如果我們使用之前的那個MyInt類:
public class MyInt
{
public int MyValue;
}
然後我們執行下面的這個方法:
public int ReturnValue2()
{
MyInt x = new MyInt();
x.MyValue = 3;
MyInt y = new MyInt();
y = x;
y.MyValue = 4;
return x.MyValue;
}
我們會得到什麼?...4!
咋回事呢?x.MyValue怎麼就變成4了呢?看看我們做的是不是有意義:
在第一個栗子裏一切都在計劃內執行的:
public int ReturnValue()
{
int x = 3;
int y = x;
y = 4;
return x;
}
在後面的栗子裏,我們沒有得到預期的3。因爲變量x和y的指針都指向了堆中的同一個對象。
public int ReturnValue2()
{
MyInt x;
x.MyValue = 3;
MyInt y;
y = x;
y.MyValue = 4;
return x.MyValue;
}
希望這能夠幫助你對值類型和引用類型的本質區別有更好的理解,並且對C#中的指針有基本的理解。在本系列的下一節中,我們將進一步討論內存的管理,並具體討論方法的參數問題。
Happy coding.