【深刻理解CLR】2:細談值類型的裝箱和拆箱

裝箱  

總所周知,值類型是比引用類型更「輕型」的一種類型,由於它們不做爲對象在託管堆中分配,不會被垃圾回收,也不經過指針來引用。但在許多狀況下,都須要獲取對值類型的一個實例的引用。例如,假定要建立一個ArrayList 對象(System.Collections 命名空間中定義的一個類型)來容納一組 Point 結構,那麼代碼可能像下面這樣:ide

// 聲明一個值類型
struct Point {
public Int32 x, y;
}
public sealed class Program {
public static void Main() {
ArrayList a = new ArrayList();
Point p; // 分配一個 Point(不在堆中分配)
for (Int32 i = 0; i < 10; i++) {
p.x = p.y = i;  // 初始化值類型中的成員
a.Add(p); // 對值類型進行裝箱,並將引用添加到 Arraylist 中
}
...
}
}

  每一次循環迭代,都會初始化一個 Point 的值類型字段(x 和 y)。而後,這個 Point 會存儲到 ArrayList中。但讓咱們思考一下。ArrayList 中究竟存儲的是什麼?是 Point 結構,Point 結構的地址,仍是其餘徹底不一樣的東西?要知道正確答案,必須研究 ArrayList 的 Add 方法,瞭解它的參數被定義成什麼類型。函數

  咱們很容易的看到Add方法原型是這樣的:性能

public virtual Int32 Add(Object value);

  能夠看出,Add 須要獲取一個 Object 參數。換言之,Add 須要獲取對託管堆上的一個對象的引用(或指針)來做爲參數。但在以前的代碼中,傳遞的是 p,也就是一個 Point,是一個值類型。爲了使代碼正確工做,Point 值類型必須轉換成一個真正的、在堆中託管的對象,並且必須獲取對這個對象的一個引用。this

  爲了將一個值類型轉換成一個引用類型,要使用一個名爲裝箱(boxing)的機制。spa

  下面描述了實例進行裝箱操做時在內部發生的事情:線程

  1.  在託管堆中分配好內存。分配的內存量是值類型的各個字段須要的內存量加上託管堆的全部對象都有的兩個額外成員(類型對象指針和同步塊索引)須要的內存量。
  2.  值類型的字段複製到新分配的堆內存。
  3.  返回對象的地址。如今,這個地址是對一個對象的引用,值類型如今是一個引用類型。指針

  C#編譯器會自動生成對一個值類型的實例進行裝箱所需的 IL 代碼,但你仍然須要理解內部發生的事情,不然很容易忽視代碼長度問題和性能問題。code

  在上述代碼中,C#編譯器檢測到是向一個須要引用類型的方法傳遞一個值類型,因此會自動生成代碼對對象進行裝箱。在運行時,當前存在於 Point 值類型實例 p 中的字段會複製到新分配的 Point 對象中。已裝箱的 Point 對象(如今是一個引用類型)的地址會返回給 Add 方法。Point 對象會一直存在於堆中,直到被垃圾回收。Point 值類型變量 p 能夠重用,由於ArayList 根本不知道關於它的任何事情。注意,在這種狀況下,已裝箱值類型的生存期超過了未裝箱的值類型的生存期。對象

拆箱

  在知道裝箱如何進行以後,接着談談拆箱。假定須要使用如下代碼獲取 ArrayList 的第一個元素:blog

Point p = (Point) a[0];

  如今是要獲取ArrayList的元素0中包含的引用(或指針),並試圖將其放到一個Point值類型的實例p中。爲了作到這一點,包含在已裝箱 Point 對象中的全部字段都必須複製到值類型變量 p 中,後者在線程棧上。CLR 分兩步完成這個複製操做。第一步是獲取已裝箱的 Point 對象中的各個 Point 字段的地址。這個過程稱爲拆箱(unboxing)。第二步是將這些字段包含的值從堆中複製到基於棧的值類型實例中。

  拆箱不是直接將裝箱過程倒過來。拆箱的代價比裝箱低得多。拆箱其實就是獲取一個指針的過程,該指針指向包含在一個對象中的原始值類型(數據字段)。事實上,指針指向的是已裝箱實例中的未裝箱部分。因此,和裝箱不一樣,拆箱不要求在內存中複製任何字節。知道這個重要的區別以後,還應知道的一個重點在於,每每會緊接着拆箱操做發生一次字段的複製操做

  顯然,裝箱和拆箱/複製操做會對應用程序的速度和內存消耗產生不利影響,因此應該注意編譯器在何時生成代碼來自動這些操做,並嘗試手動編寫代碼,儘可能避免自動生成代碼的狀況。

  一個已裝箱的值類型實例在拆箱時,內部會發生下面這些事情:
  1.  若是包含了「對已裝箱值類型實例的引用」的變量爲 null,就拋出一個 NullReferenceException 異常。
  2.  若是引用指向的對象不是所期待的值類型的一個已裝箱實例,就拋出一個 InvalidCastException 異常。
上述第二條意味着如下代碼不會如你可能預期的那樣工做:

public static void Main() {
Int32 x = 5;
Object o = x; // 對 x 進行裝箱,o 引用已裝箱的對象
Int16 y = (Int16) o; // 拋出一個 InvalidCastException 異常
}

下面是上述代碼正確的寫法:

public static void Main() {
Int32 x = 5;
Object o = x; // 對 x 進行裝箱,o 引用已裝箱的對象
Int16 y = (Int16)(Int32) o; // 先拆箱爲正確的類型,再進行轉型
}

前面說過,在一次拆箱操做以後,常常緊接着一次字段複製。如下 C#代碼演示了拆箱和複製操做:

public static void Main() {
Point p;
p.x = p.y = 1;
Object o = p;  // 對 p 進行裝箱;o 引用已裝箱的實例
p = (Point) o; // 對 o 進行拆箱,將字段從已裝箱的實例複製到棧變量中
}

在最後一行,C#編譯器會生成一條 IL 指令對 o 執行拆箱(獲取已裝箱實例中的字段的地址),並生成另外一條 IL 指令將這些字段從堆複製到基於棧的變量 p 中。

再來看看如下代碼:

public static void Main() {
Point p;
p.x = p.y = 1;
Object o = p; // 對 p 進行裝箱;o 引用已裝箱的實例
// 將 Point 的 x 字段變成 2
p = (Point) o; // 對 o 進行拆箱,並將字段從已裝箱的實例複製到棧變量中
p.x = 2; // 更改棧變量的狀態
o = p; // 對 p 進行裝箱;o 引用新的已裝箱實例
}

最後三行代碼惟一的目的就是將 Point 的 x 字段從 1 變成 2。爲此,首先要執行一次拆箱,再執行一次字段複製,再更改字段(在棧上),最後執行一次裝箱(從而在託管堆上建立一個全新的已裝箱實例)。但願你已體會到了裝箱和拆箱/複製操做對應用程序性能的影響。

演示

public static void Main() {
Int32 v = 5; // 建立一個未裝箱的值類型變量
Object o = v; // o 引用一個已裝箱的、包含值 5 的 Int32
v = 123; // 將未裝箱的值修改爲 123
Console.WriteLine(v + ", " + (Int32) o); // 顯示"123, 5"
}

能夠從上述代碼中看出發生了多少次裝箱操做嗎?若是說是 3 次,會不會以爲意外?讓咱們仔細分析一下代碼,理解具體發生的事情。爲了幫助理解,下面列出了爲上述代碼中的 Main 方法生成的 IL 代碼。

.method public hidebysig static void Main() cil managed
{
.entrypoint
// 代碼大小 45 (0x2d)
.maxstack 3
.locals init (int32 V_0,
object V_1)
// 將 5 加載到 v 中
IL_0000: ldc.i4.5
IL_0001: stloc.0
// 對 v 進行裝箱,將引用指針存儲到 o 中
IL_0002: ldloc.0
IL_0003: box [mscorlib]System.Int32
IL_0008: stloc.1
// 將 123 加載到 v 中
IL_0009: ldc.i4.s 123
IL_000b: stloc.0
// 對 v 進行裝箱,並將指針保留在棧上以進行 Concat(鏈接)操做
IL_000c: ldloc.0
IL_000d: box [mscorlib]System.Int32
// 將字符串加載到棧上以執行 Concat 操做
IL_0012: ldstr ", "
// 對 o 進行拆箱:獲取一個指針,它指向棧上的 Int32 的字段
IL_0017: ldloc.1
IL_0018: unbox.any [mscorlib]System.Int32
// 對 Int32 進行裝箱,並將指針保留在棧以進行 Concat 操做
IL_001d: box [mscorlib]System.Int32
// 調用 Concat
IL_0022: call string [mscorlib]System.String::Conct(object,
object,
object)
// 將從 Concat 返回的字符串傳給 WriteLine
IL_0027: call void [mscorlib]System.Console::WriteLine(string)
// 從 Main 返回,終止這個應用程序
IL_002c: ret
} // end of method App::Main

  首先在棧上建立一個 Int32 未裝箱值類型實例(v),並將其初始化 5。而後,建立一個 Object 類型的變量(o),並初始化它,讓它指向 v。可是,因爲引用類型的變量必須始終指向堆中的對象,因此 C#會生成正確的 IL 代碼對 v 進行裝箱,再將 v 的一個已裝箱拷貝的地址存儲到 o 中。接着,值 123 被放到未裝箱的值類型實例 v 中,但這個操做不會影響已裝箱的 Int32,後者的值依然爲 5。接着調用 WriteLine 方法,WriteLine 要求獲取一個 String 對象。可是,當前沒有字符串對象。相反,當前有三個數據項可供使用:一個未裝箱的 Int32 值類型實例(v),一個 String(它是一個引用類型),以及對一個已裝箱 Int32 值類型實例的引用(o),它須要轉型爲一個未裝箱的 Int32。必須採起某種方式對這些數據項進行合併,以建立一個 String。爲了建立一個 String,C#編譯器生成代碼來調用 String 對象的靜態方法 Concat。該方法有幾個重載的版本,全部版本執行的操做都是同樣的,惟一的區別是參數數量。因爲須要鏈接三個數據項來建立一個字符串,因此編譯器選擇的是 Concat 方法的下面這個版本:

public static String Concat(Object arg0, Object arg1, Object arg2);

  爲第一個參數 arg0 傳遞的是 v。可是,v 是一個未裝箱的值參數,而 arg0 是一個 Object,因此必須對v 進行裝箱,並將已裝箱的 v 的地址傳給 arg0。爲 arg1 參數傳遞的是字符串",",它本質上是對一個 String對象的引用。最後,對於 arg2 參數,o(對一個 Object 的引用)會轉型爲一個 Int32。這要求執行一次拆箱操做(但不緊接着執行一次複製操做),從而獲取包含在已裝箱 Int32 中的未裝箱 Int32 的地址。這個未裝箱的 Int32 實例必須再次裝箱,並將新的已裝箱實例的內存地址傳給 Concat 的 arg2 參數

  應該指出的是,若是像下面這樣寫對 WriteLine 的調用,生成的 IL 代碼將具備更高的執行效率:

Console.WriteLine(v + ", " + o); // 顯示"123, 5"

  這和前面的版本幾乎徹底一致,只是移除了變量 o 以前的(Int32)強制轉型。之因此具備更高的效率,是由於 o 已是指向一個 Object 的引用類型,它的地址能夠直接傳給 Concat 方法。因此,在移除了強制轉型以後,有兩個操做能夠避免:一次拆箱和一次裝箱。

  咱們還能夠這樣調用 WriteLine,進一步提高上述代碼的性能:

Console.WriteLine(v.ToString() + ", " + o); // 顯示"123, 5"

  如今,會爲未裝箱的值類型實例 v 調用 ToString 方法,它返回一個 String。String 對象已是引用類型,因此能直接傳給 Concat 方法,不須要任何裝箱操做。

 

  關於裝箱最後要注意一點:若是知道本身寫的代碼會形成編譯器反覆對一個值類型進行裝箱,請改爲用手動方式對值類型進行裝箱。這樣代碼會變得更小、更快。下面是一個例子:

using System;
public sealed class Program {
public static void Main() {
Int32 v = 5; // 建立一個未裝箱的值類型變量
#if INEFFICIENT
// 編譯下面這一行時,v 會被裝箱三次,浪費時間和內存
Console.WriteLine("{0}, {1}, {2}", v, v, v);
#else
// 下面的代碼能得到相同的結果,但不管執行速度,
// 仍是內存利用,都比前面的代碼更勝一籌
Object o = v; // 對 v 進行手動裝箱(僅一次)
// 編譯下面這行時,不會發生裝箱
Console.WriteLine("{0}, {1}, {2}", o, o, o);
#endif
}
}

總結

  經過這些例子,很容易判斷在何時一個值類型的實例須要裝箱。簡單地說,若是要獲取對值類型的一個實例的引用,該實例就必須裝箱。將一個值類型的實例傳給須要獲取一個引用類型的方法,就會發生這種狀況。然而,這並非要求對值類型實例進行裝箱的惟一狀況。

前面說過,未裝箱值類型是比引用類型更「輕型」的類型。這要歸結於如下兩個緣由:
    它們不在託管堆上分配。
    它們沒有堆上的每一個對象都有的額外成員,也就是一個「類型對象指針」和一個「同步塊索引」。

  因爲未裝箱的值類型沒有同步塊索引,因此不能使用 System.Threading.Monitor 類型的各類方法(或者使用 C#的 lock 語句)讓多個線程同步對這個實例的訪問。(我想這很好的解釋了lock 代碼塊只能對引用類型加鎖的緣由,就是由於值類型沒有「同步塊索引」)

  雖然未裝箱的值類型沒有類型對象指針,但仍可調用由類型繼承或重寫的虛方法(好比 Equals,GetHashCode 或者 ToString)。若是你的值類型重寫了其中任何一個虛方法,那麼 CLR 能夠非虛地調用該方法(一般比使用虛方法調用該函數更加高效),由於值類型是隱式密封的,沒有任何類型可以從它們派生。此外,用於調用虛方法的值類型實例不會被裝箱。然而,若是你重寫的虛方法要調用方法在基類中的實現,那麼在調用基類的實現時,值類型實例就會裝箱,以便經過 this 指針將對一個堆對象的引用傳給基方法

  然而,調用一個非虛的、繼承的方法時(好比 GetType 或 MemberwiseClone),不管如何都要對值類型進行裝箱。這是由於這些方法是由 System.Object 定義的,因此這些方法指望 this 實參是指向堆上一個對象的指針。

  除此以外,將值類型的一個未裝箱實例轉型爲類型的某個接口時,要求對實例進行裝箱。這是由於接口變量必須包含對堆上的一個對象的引用

 

重要提示:

  在值類型中定義的成員不該修改類型的任何實例字段。也就是說,值類型應該是不可變(immutable)的。事實上,我建議將值類型的字段都標記爲 readonly。這樣一來,若是不慎寫了一個方法企圖更改一個字段,編譯時就會報錯。前面的例子很是清楚地揭示了這背後的緣由。假如一個方法企圖修改值類型的實例字段,調用這個方法就會產生非預期的行爲。構造好一個值類型以後,若是不去調用任何會修改其狀態的方法(或者若是根本不存在這樣的方法),就不用再爲何時候會發生裝箱和拆箱/字段複製而擔憂。若是一個值類型是不可變的,只需簡單地複製相同的狀態就能夠了(不用擔憂有任何方法會修改這些狀態),代碼的任何行爲都將在你的掌控之中。有許多開發人員審閱了本章節。在閱讀了個人部分示例代碼以後(好比前面的代碼),他們告訴我他們不再敢使用值類型了。這裏我必須指出的是,但願記住我在這裏描述的一些問題。這樣一來,當代碼真正出現這些問題的時候,就會心中有數。不過,雖然如此,但有一點是確定的,不該懼怕值類型。它們是有用的類型,有本身的適用場合。畢竟,程序偶爾仍是須要 Int32 的。只是要注意,取決於值類型和引用類型的使用方式,它們的行爲也會出現顯著的區別。事實上,在前面的例子中,將 Point 聲明爲一個 class,而不是一個 struct,便可得到使人滿意的結果。最後還要告訴你一個好消息,FCL 的核心值類型(Byte,Int32,UInt32,
Int64,UInt64,Single,Double,Decimal,BigInteger,Complex 以及全部 enums)都是「不可變」的,因此在使用這些類型時,不會發生任何稀奇古怪的事情。

 

PS:以上內容部分摘自CLR via C#,少部分是博主添加補充

相關文章
相關標籤/搜索