581
技術社區[雲棲]
C# 引用類型、值類型
CLR支持兩種類型:引用類型和值類型,它們的區別是在內存分配方式上的差異:引用類型是從托管堆上分配的;值類型是在線程棧上分配的。而CLR的垃圾回收是針對托管堆的,因此值類型不受垃圾回收器的控製。
在FCL中,所有稱為“結構”(struct)的類型都是值類型,所有稱為“類”(class)的類型都是引用類型。所有的Struct都直接派生自抽象類System.ValueType,而System.ValueType直接從System.Object派生。所有的枚舉都直接從System.Enum派生,而後者又派生自System.ValueType,所以枚舉也是值類型。由於CLR的單繼承規則,所以我們在定義值類型時,不能指定基類型,但可以實現接口。同時從下圖生成的IL也可以看出,值類型是隱式密封的(sealed),也就是說也不能從值類型派生。
雖然引用類型與值類型實質隻是內存分配上的差異,但這種差異會導致兩種類型在行為表現上有著明顯不同,比如下麵的例子:
struct ValType { public int x;} class RefType { public int x;} class Program { static void Main(string[] args) { ValType v1 = new ValType(); //在棧上分配內存 RefType r1 = new RefType(); //在堆上分配內存 v1.x = 2; r1.x = 2; //執行到這裏,內存結構請見圖1 Console.WriteLine(v1.x); //2 Console.WriteLine(r1.x); //2 ValType v2 = v1; //在棧上分配內存(v2),並把v1棧的內容複製到v2 RefType r2 = r1; //把r1的堆地址複製給r2 v2.x = 5; //隻改變v2棧的內容 r2.x = 5; //由於r2和r1都引用同一個堆上的對象,改變r2也會改變r1 //執行到這裏,內存結構請見圖2 Console.WriteLine(v1.x); //2 Console.WriteLine(r1.x); //5 注意這裏變成了r2修改後的值 Console.WriteLine(v2.x); //5 Console.WriteLine(r2.x); //5 Console.ReadKey(); } }
首先我們定義一個一值類型與一個引用類型,內部都隻有一個字段。用new操作符分配內存時,值類型v1的內存分配在了線程棧上,引用類型r1的內存分配在了托管堆上,在程序運行到第一次WriteLine輸出時,看到的結果是一致的。但接下來聲明兩個新的對象並執行賦值時,這裏的發生的事明顯不同:雖然賦值操作都是拷貝線程棧上變量的內容,但由於值類型變量v1的棧內容就是ValType類型實例本身,而引用類型r1的棧內容是RefType對象實例在堆上的地址。所以賦值後的結果就是,v1和v2各保存了一份ValType類型實例,而r1和r2保存了同一塊堆內存的地址。所以改變r2對象導致了r1對象的隨同改變。下麵是內存示意圖:
圖1
圖2
雖然值類型實例不需要垃圾回收,但由於值類型在傳遞時,傳遞的是內容本身,所以並不適合將所一些實例較大的類型定義為值類型。實現上除非滿足以下所有條件,否則不應該將一個類型聲明為值類型。
- 沒有更改其字段的成員,即該類型是不可變的。(建議所有字段為readonly)
- 類型不需要從其他任何類型繼承。(值類型不能選擇基類)
- 類型也不會派生出其他任何類型。(所有的值類型都是隱式密封sealed的)
- 實例較小(約<=16Byte)或較大但不作為方法實參傳遞,也不從方法返回。
值類型的裝箱與拆箱
將值類型轉換成一個引用類型的過程叫裝箱,整個過程看起來是這樣的:
- 在托管堆中分配好內存,分配的內存量=值類型的各個字段所需的內存量+所有堆上對象都有的兩個額外成員(類型對象指針和同步塊索引)所需的內存量。
- 值類型的字段複製到新分配的內存。
- 返回對象的地址。
拆箱僅是獲取一個指針的過程,該指針指向包含在一個對象中的原始值類型(數據字段)。雖然拆箱比裝箱代價低,但實際在拆箱之後往往緊接著就是賦值操作(內存複製)。顯然裝箱和拆箱/複製會對應用程序的速度與內存消耗上產生不利影響,所以應該了解到這一點,並盡量避免裝箱和拆箱操作。那麼什麼時候會發生裝箱和拆箱,最直觀的方法就是看生成的IL代碼(IL對應指令是分別是box與unbox),比如下麵的例子:
示例中ArrayList的Add方法參數是Object類型,也就是說一個引用類型(在堆上分配的內存),當我們傳遞int類型時,這裏便會將int實例裝箱,以返回一個堆上的地址。在將array[0]強製轉型為int時,由於值類型int的對象是在線程棧上分配的,所以這裏拆箱並緊接著發生賦值(內存複製)操作。同時為了對比,我加了引用類型的reference,可以看出引用類型是不會發生裝箱與拆箱的。
那麼如何避免(或減少)裝箱與拆箱:
- 盡量使用泛型集合。
- 盡量將裝箱與拆箱操作移到循環體之外。
- 定義一個方法如果可接收引用類型或值類型時,盡量不要將參數定義為object,可以考慮通過重載定義多個版本或定義泛型方法。
最後更新:2017-04-03 12:54:10