標籤:
程式執行的原理
要徹底搞明白那一堆概念及其它們之間的關係似乎並不是一件容易的事,這是因為大部分C#程式員並不瞭解託管堆(簡稱“堆”)和線程棧(簡稱“棧”),或者知道它們,但瞭解得並不深入,只知道:參考型別儲存在託管堆裡,而實值型別“通常”儲存在棧裡。要搞明白那一堆概念的關係,我認為先要明白程式執行的基本原理,從而理解棧和託管堆的作用,才能理清它們的關係。考慮下面代碼,Main調用Method1,Method1調用Method2:
| 1234567891011121314151617181920 |
class Program{ static void Main(string[] args) { var num = 120; Method1(num); } static void Method1(int num) { var num2 = num + 250; Method2(num2); Console.WriteLine(num); } static void Method2(int i) { Console.WriteLine(i); }} |
大家都知道Windows程式通常是多個線程的,這裡不考慮多線程的問題。程式由Main方法進入開始執行,這時這個(主)線程會分配得到一個1M大小的只屬於它自己的線程棧。這1M的的棧空間用於向方法傳遞參數,定義局部變數。所以在Main方法進入Method1前,大家心理面要有一個”記憶體配置圖“:把num壓入線程棧,如:
接著把num作為參數傳入Method1方法,同樣在Method1內定義一個局部變數num2,調用加方法得到最後的值,所以在進入Method2前,“記憶體配置圖”如下,num是參數,num2是局部變數
接著調用Method2的過程雷同,然後退出Method2方法,回到的樣子,再退出Method1方法,再回到第一副圖的樣子,然後退出程式,整個過程如:
所以去除那些if,for,多線程等等概念,只保留對象記憶體配置相關概念的話,程式的執行可以簡單總結為如下:
程式由Main方法進入執行,並不斷重複著“定義局部變數,調用方法(可能會傳參),從方法返回”,最後從Main方法退出。在程式執行過程中,不斷壓入參數和局部變數到線程棧裡,也不斷的出棧。
注意,其實壓入棧的還有方法的返回地址等,這裡忽略了。
參考型別和堆
上面的例子我只用了一種簡單的int實值型別,目的是為了只關注線程棧的壓棧(生長)和出棧(消亡)。很明顯C#還有種參考型別,引入參考型別,再考慮上面的問題,看下面代碼:
| 123456789101112 |
static void Main(string[] args){ var user = new User { Age = 15 }; var num = 23; Console.WriteLine(user.Age); Console.WriteLine(num);} class User{ public int Age;} |
我想很多人都應該知道,這時應該引入託管堆的概念了,但這裡我想跟上面一樣,先從棧的角度去考慮問題,所以在調用WriteLine前,“記憶體配置圖”應該是這樣的(地址是亂寫的):
這也就是人們常說的:對於參考型別,棧裡儲存的是指向在堆裡的執行個體對象的地址(指標,引用)。既然只是個地址,那麼要擷取一個對象的執行個體應該有一個根據地址或尋找對象的步驟,而事實正是這樣,如果Console.WriteLine(num),這樣擷取棧裡的num的值給WriteLine方法算一步的話,要擷取上面user的執行個體對象,在運行時是要分兩步的,也就是多了根據地址去尋找託管堆裡執行個體對象的欄位或方法的步驟。IL反編譯上面的Main方法,刪去一些無關代碼後:
| 12345 |
//load local 0=>擷取局部變數0(是一個地址) IL_0012: ldloc.0// load field => 將指定對象中欄位的值推送到堆棧上。IL_0013: ldfld int32 CILDemo.Program/User::AgeIL_0018: call void [mscorlib]System.Console::WriteLine(int32) |
| 123 |
//load local 1=>擷取局部變數1(是一個值) IL_001e: ldloc.1IL_001f: call void [mscorlib]System.Console::WriteLine(int32) |
第二個WriteLine方法前,只需要一個ldloc.1(load local 1)讀取局部變數1指令即可擷取值給WriteLine,而第一個WriteLine前需要兩條指令完成這個任務,就是上面說的分兩步。
當然,大家都知道對我們來說,這是透明的,所以很多人喜歡畫這樣的圖去協助理解,畢竟,我們是感覺不到那個0x0612ecb4地址存在的。
也有一種說法就是,參考型別分兩段儲存,一是在託管堆裡的值(執行個體對象),二是持有它的引用的變數。對於局部變數(參數)來說,這個引用就在棧裡,而作為類型的欄位變數的話,引用會跟隨這個對象。
欄位和局部變數(參數)
上面圖的託管堆,大家應該看到,作為實值型別的Age的值是儲存在託管堆裡的,並不是儲存在棧裡,這也是很多C#新手所犯的錯誤:實值型別的值都是儲存在棧裡。
很明顯他們不知道這個結論是在我們上面討論程式運行原理時,局部變數(參數)壓棧和出棧時這個特定的情境下的結論。我們要搞清楚,就像上面代碼一樣,除了可以定義int類型的num這個局部變數儲存23這個值外,我們還可以在一個類型裡定義一個int類型Age欄位成員來儲存一個整形數字,這時這個Age很明顯不是儲存在棧,所以結論應該是:實值型別的值是在它聲明的位置儲存的。即局部變數(參數)的值會在棧裡,作為類型成員的話,會跟隨對象(執行個體對象等)。
當然,參考型別的值(執行個體對象)總是在託管堆裡,這個結論是正確的。
ref和out
C#有實值型別和參考型別的區別,再有傳參時有ref和out這兩個關鍵字使得人們對相關概念的理解更加模糊。要理解這個問題,還是要從棧的角度去理解。我們分四種情況討論:正常傳遞實值型別,正常傳遞參考型別,ref(out)傳遞實值型別,ref(out)傳遞參考型別。
注意,對於運行時來說,ref和out是一樣,它們的區別是C#編譯器對它們的區別,ref要求初始化好,out沒有要求。因為out沒有要求初始化,所以被調用的方法不能讀取out參數,且方法返回前必須賦值。
正常傳遞實值型別
| 123456789101112 |
static void Main(string[] args){ var num = 120; Method1(num); Console.WriteLine(num);//輸出=>120} static void Method1(int num){ Console.WriteLine(num); num = 180;} |
這種情境大家都熟悉,Method1的那句賦值是不起作用的,如果要畫圖的話,也跟上面第二幅圖類似:
也就是說傳參是把棧裡的值複製到Method1的num參數,Method1操作的是自己的參數,對Main的局部變數完全沒有影響,即影響不到屬於Main方法的棧裡的資料。
正常傳遞參考型別
| 1234567891011121314 |
static void Main(string[] args){ var user = new User(); user.Age = 15; Method2(user); Debug.Assert(user != null); Console.WriteLine(user.Age);//輸出=> 18} static void Method2(User user){ user.Age = 18; user = null;} |
留意這裡的Method2的代碼,把Age設為18,影響到了Main方法的user,而把user設為null卻沒有影響。要分析這個問題,還是要先從棧的角度去看,棧圖如下(地址亂寫):
看到第二幅圖,大家應該大概明白了這個事實:無論實值型別也好,參考型別也好,正常傳參都是把棧裡的值複製給參數,從棧的角度看的話,C#預設是按值傳參的。
既然都是“按值傳參”,那麼參考型別為什麼表現出可以影響到調用方法的局部變數這個跟實值型別不同的表現呢?仔細想想也不難發現,這個不同的表現不是由傳參方式不同引起的,而是實值型別和參考型別的局部變數(參數)在記憶體的儲存不同引起的。對於Main方法的局部變數user和Method2的參數user在棧裡是各自儲存的,棧裡的資料(地址,指標,引用)互不影響,但它們都指向同一個在託管堆裡的執行個體對象,而user.Age = 18這一句操作的正是對託管堆裡的執行個體對象的操作,而不是棧裡的資料(地址,指標,引用)。num = 180操作的是棧裡的資料,而user.Age = 18卻是託管堆,就是這樣造成了不同的表現。
對於user = null這一句不會響應Main的局部變數,看了第三幅圖應該也很容易明白,user = null跟user.Age = 18不一樣,user = null是把棧裡的資料(地址,指標,引用)設空,所以並不會影響Main的user。
這裡再補充一下,對參考型別來說,var user = null,var user = new User(),user1 = user2都會影響棧裡的資料(地址,指標,引用),第一個會設null,第二個會得到一個新的資料(地址,指標,引用),第三個跟上面傳參一樣,都是棧資料複製。
ref(out)傳遞實值型別
| 1234567891011121314151617181920 |
static void Main(string[] args){ var num = 10; Method1(num); Console.WriteLine(num);//輸出=> 10 Method3(ref num); Console.WriteLine(num);//輸出=> 28} static void Method1(int num){ Console.WriteLine(num); num = 18;} static void Method3(ref int num){ Console.WriteLine(num); num = 28;} |
代碼很簡單,而且輸出應該都很清楚,沒有難度。ref的使用看似簡單平常,背後其實是C#為我們做了大部分工作。畫圖的話,“棧圖”如下(地址亂寫):
看到這圖,不少人應該迷惑了,Method3的參數明明寫的是int類型的num,怎麼在棧裡卻是一個指標(地址,引用)呢?這其實C#“欺騙”了我們,IL反編譯看看:
可以看到,加了ref(out)的Method3編譯出來的方法參數是不一樣,再來看看方法裡對參數取值的IL代碼:
| 123456789 |
//這是Method1的代碼//load arg 0=>讀取索引0的參數,直接就是一個值IL_0001: ldarg.0 //這是Method3的代碼//load arg 0=>讀取索引0的參數,這是一個地址IL_0001: ldarg.0//將位於上面地址處的 int32 值作為 int32 載入到堆棧上。IL_0002: ldind.i4 |
可以看到,同樣是擷取參數值給WriteLine,Method1隻需一個指令,而Method3則需要2個,即多了一個根據地址去尋值的步驟。不難想到,賦值也有同樣的區別:
| 12345678910111213 |
//Method1//把18放入棧中IL_0008: ldc.i4.s 18//store arg=> 把值賦給參數變數numIL_000a: starg.s num //Method3//load arg 0=>讀取索引0的參數,這是一個地址IL_0009: ldarg.0//把28放入棧中IL_000a: ldc.i4.s 28//在給定的地址儲存 int32 值。IL_000c: stind.i4 |
沒錯,雖然同樣是num = 5這樣一個對參數的指派陳述式,有沒有ref(out)關鍵字,實際上運行時發生的事情是不一樣的。有ref(out)的方法跟上面取值一樣有給定地址然後去操作(這裡是賦值)的指令。
看到這裡大家應該明白,給參數加了ref(out)後,參數才是引用傳遞,這時傳遞的是棧地址(指標,引用),否則就是正常的值傳遞--棧資料複製。
ref(out)傳遞參考型別
加了ref(out)的參考型別的參數有什麼奧秘,這個留給大家去思考。可以肯定的是,還是從棧的角度去考慮的話,跟實值型別是沒有區別的,都是傳遞棧地址。
我個人認為,貌似給參考型別加ref(out)沒什麼用處。
總結
在考慮這一大堆概念問題時,我們首先要搞明白程式執行的基本原理,只不過是棧的生長和消亡的過程。明白這個過程後,要學會“從棧的角度”去思考問題,那麼很多事情將會迎刃而解。為什麼叫“值”類型和“引用”類型呢?其實這個“值”和“引用”是從棧的角度去考慮的,在棧裡,實值型別的資料就是值,參考型別在棧裡只是一個地址(指標,引用)。還要注意到,變數除了可以是一個局部變數(參數)外,還可以作為一個類型的欄位成員存在。知道這些後,“實值型別的對象是儲存在那裡?”這些問題應該就一清二楚了。最後就是明白C#預設是按值傳參的,也就是把棧裡的資料賦值給參數,這跟在同一個方法內把一個變數賦值給同一類型的另一個變數是一樣的,而加了ref(out)為什麼這個神奇,其實是C#背後做了更多的事情,編譯成不同的IL代碼了。
參考:《CLR via C#》
C#:圖解實值型別,參考型別,棧,堆,ref,out [轉]