問:當我們new一個對象時,會發生什么事?
答:調用該類型的構造函數。
問題看似簡單,不過事實上,CLR做的比這要多。。。
要准確回答這個問題,還要分情況來說。
new一個引用類型
首先,要實例化一個引用類型,就一定需要在堆上分配內存。要分配內存,就需要先計算出這個引用類型占多大空間,需要給它分配多少內存。
那怎么計算呢?簡單!只要計算該類型所有字段的長度總和就可以啦。我們知道,引用類型的字段,占一個指針的長度(32位機器上是4個字節,64位機器上是8個字節)。值類型的字段長度可以通過遞歸的方法計算得出(遞歸終點是遇到引用類型或基本類型)。根據這些信息,我們就可以輕松計算出所有字段長度的總和了。
但是實際上計算方法會比這個復雜一點點,因為還要考慮到內存對齊的情況,關於內存對齊的解釋我附在了本文的最后,這里就不多說了。考慮了內存對齊之后,得到的結果可能會比之前的要稍大一些。
不過這個仍然不是最終的結果。要得到最終的結果,還需要加上兩個指針的長度。原因是,每個分配在堆上的對象都會有兩個指針的“額外開銷”,這兩個開銷分別是同步塊索引和類型指針。關於同步塊索引,一兩句話也說不清楚,不過可以把它簡單地理解成一個指向“同步塊”的指針,而這個“同步塊”的作用則是為了讓擁有該同步塊的對象能夠支持線程同步。所謂類型指針,你可以這樣來理解:每個對象都是一個類型的實例,而每個類型本身都有一個Type類型的實例來表示,對象的類型指針就是指向該類型的Type實例的指針。舉個例子就清楚多了,我們知道,typeof(String)的值是一個Type類型的實例,這個Type類型的實例也就是所有的String對象的類型指針所指向的東西。
好了,到此為止,就可以得出實例化一個引用類型需要為其分配的內存數了。不過,要注意的是,CLR並不是在運行時計算分配內存的大小的,而是早在編譯的時候就已經計算好這個量了。
接下來要做的是初始化分配得到的內存塊。這個很簡單,只要把這段內存的所有二進制位都設為0就可以了。
然后就是初始化兩個“額外開銷”的值了。對於同步塊索引,CLR把它初始化為一個負數,並不指向任何的同步塊。這是因為對於絕大多數對象,我們不要求它支持線程同步,所以不必急着給他實例化一個同步塊,等到真的需要的時候再實際進行分配。而對於類型指針,則將其指向一個實實在在的對象——即該類型的類型對象實例。
再然后,就是調用類型的構造函數了。
完成了上述步驟,一個引用類型的對象實例就做好了,new操作符只要返回這個實例的引用就算完成任務了。
new一個值類型
首先,也是要計算需要分配多少內存。因為值類型是沒有所謂的“額外開銷”的,所以值類型所需的內存長度就是其內部字段的大小總和(同樣需要考慮內存對齊)。同樣的,CLR在編譯的時候就已經計算好這個量了,不需要在運行時計算。
然后,CLR分配所需的內存。在哪里分配呢?這可說不准,在堆上或在棧上都有可能。
再然后就是調用類型構造函數了。這里需要注意,CLR並沒有初始化這段內存塊,而是把初始化內存塊的任務都交給構造函數了。這樣做是為了保證值類型輕量性的特點。這也是為什么C#語言在值類型的構造函數中強制要求為所以字段賦值的原因。另外,所有值類型的默認構造函數都會把內部字段都初始化為0。
到此,一個值類型也做好了。一般來說,對於值類型,new操作符並不需要返回其地址。原因在於,值類型的位置相對固定,因此在編譯時就可以基本確定它們的位置。比如說,函數棧上的值類型實例都有一個相對於棧的偏移量,這個偏移量在編譯時就是確定的。再比如說,作為引用類型的字段的值類型,都有一個相對於該引用類型地址的偏移量,這個偏移量也是早在編譯時就固定下來的。所以,new操作符無需返回值類型實例的地址。
現在我們知道每new一個對象時CLR所需要做的工作了。可以看出,CLR的任務並不輕松。若是考慮到new一個對象之后還要垃圾回收該對象,那CLR就更辛苦了。所以,每當我們想要實例化一個類型的時候,都需要三思而后行。。。
附:關於內存對齊(這個是我之前學習的筆記,記得不是很系統,有興趣的同學湊合看一下吧。。。)
為什么要內存對齊?
為了提高程序的性能,內存中的數據結構應該盡可能地在自然邊界上對齊。原因在於,為了訪問未對齊的內存,處理器需要作兩次內存訪問,而對齊的內存訪問僅需要一次訪問。(對字,雙字,和四字來說,自然邊界分別是偶數地址,可以被4整除的地址,和可以被8整除的地址。)
怎樣才算內存對其?
一個字或雙字操作數跨越了4字節邊界,或者一個四字操作數跨越了8字節邊界,被認為是未對齊的,從而需要兩次總線周期來訪問內存。一個字起始地址是奇數但卻沒有跨越字邊界被認為是對齊的,能夠在一個總線周期中被訪問。