淺析.NET中的參考型別和實值型別(下)

來源:互聯網
上載者:User

    上一篇文章中簡單講了.NET中實值型別和參考型別的區別,並分析了參考型別的記憶體布局和實現方式,並在開始的例子中簡單分析了實值型別相較於參考型別的若干優點。在平常的開發中,很多人一上來就用class,而很少去想到底該用class還是struct。本文詳細介紹.NET中的實值型別以及在使用中應該注意的問題。在某些情況下,使用實值型別較參考型別可以顯著減少記憶體佔用和GC壓力,提高程式的執行效率。本文參考《Pro .NET Performance》 《CLR Via C#》和 《Advanced .NET Debugging》,希望對您有協助。

實值型別內部實現

    和參考型別相比,實值型別具有相對簡單的記憶體布局,但是這種簡單的布局也引入了一些限制,尤其是在要將實值型別“當做”參考型別使用的時候需要進行裝箱操作。

    上篇文章提到,使用實值型別最主要的原因是:實值型別具有良好的記憶體配置密度以及沒有一些複雜的結構。當建立自己的實值型別時,每一個位元組都能夠實實在在的派上用處。

    為了討論方便,下面以Point2D這個類型來說明:

public struct Point2D{    public int X;    public int Y;}

    當我們將該對象執行個體化為 X=5,Y=7的時候,他的記憶體布局如下,沒有像參考型別那樣的額外欄位。

    在少數情況下,我們可能需要制定實值型別欄位在記憶體中的布局方式,最典型例子就的是在進行互操作的時候,欄位需要保持編程人員定義的順序原封不動的傳遞給Unmanaged 程式碼。為了向CLR發出指令,我們可以使用System.Runtime.InteropServices.StructLayoutAttribute屬性來實現這一要求。 StructLayout屬性可以用來讓類型的欄位在記憶體中的布局按照定義的方式進行,我們可以通過其建構函式傳入LayoutKind.Auto,讓CLR自動排文欄位、LayoutKind.Sequential讓CLR保持我們的欄位布局,或者是LayoutKind.Explicit結合FieldOffset來自訂布局。如果不設定,CLR會選擇它認為最好的布局方式,一般滴CLR會為參考型別預設選擇LayoutKind.Auto,為實值型別選擇LayoutKind.Sequential。顯式通過FieldOffset屬性來指定,這可以使得我們可以類似建立C風格的“聯合”類型,自訂位移後的欄位有可能會重疊(Overlap),下面的例子展示了使用結構類型將一個浮點型轉換為四個位元組的表示。

[StructLayout(LayoutKind.Explicit)]public struct FloatingPointExplorer{    [FieldOffset(0)]    public float F;    [FieldOffset(0)]    public byte B1;    [FieldOffset(1)]    public byte B2;    [FieldOffset(2)]    public byte B3;    [FieldOffset(3)]    public byte B4;}

    將一個浮點型賦給該對象的F欄位時,他會同時修改B1-B4欄位,反之亦然。F欄位合B1-B4欄位在記憶體中是重疊在一起的。

    因為實值型別執行個體沒有對象頭位元組,以及方法表指標,所以不能提供像參考型別那樣豐富的語義。下面來看看這種簡單的記憶體布局使得實值型別存在的局限性以及如果試映像參考型別那樣在某些地方使用實值型別會發生什麼情況。

實值型別的局限

    首先,考慮對象頭位元組,如果程式試圖使用實值型別的執行個體來作同步,這通常是一種Bug,但是運行時應該認為這樣是非法並拋出一個異常嗎?下面的代碼中,如果兩個線程同時調用Counter執行個體的Increase方法會怎麼樣呢?

class Counter{    private int _i;    public int Increment()    {        lock (_i)        {            return ++_i;        }    }}

    在VS中這樣做時,C#編譯器不允許在實值型別上使用lock關鍵字。但是,我們知道lock是C#語言提供的一種文法糖,他會轉換為Monitor的方式,所以我們將上面的代碼改寫為:

class Counter{    private int _i;    public int Increment()    {        bool acquired = false;        try        {            Monitor.Enter(_i, ref acquired);            return ++_i;        }        finally        {            if (acquired) Monitor.Exit(_i);        }    }}

    這樣,就能通過編譯了。這樣在程式中引入了一個Bug,其結果是,多個線程能夠同時進入到鎖中並修改_i變數,進一步Monitor.Exit調用會拋出異常。問題在於,Monitor.Enter方法接受一個參考型別的,System.Object型的參數,而我們傳進去的卻是實值型別。即使我們按要求傳參考型別進去,Monitor.Enter中的參數值和Monitor.Exit中的值也不相同,同樣,在一個線程中傳到Monitor.Enter中的參數和另一個線程中的Monitor.Enter方法中的參數也不一樣。如果我們傳實值型別進去,沒有辦法獲得正確的鎖定語義。

     實值型別語義不適合作為對象引用的另外一個例子是在一個方法中傳回值類型時。請看下面代碼:

object GetInt(){    int i = 42;    return i;}object obj = GetInt();

    GetInt方法傳回值類型。但是方法的傳回型別希望是一個Object類型的引用。方法可以直接返回線程堆棧中儲存i 的值的位置的引用。不幸的是,這樣會產生一個對記憶體位址的非法引用,因為方法的棧幀在值返回時就被回收了。這說明拷貝值語義,在需要對象引用時並不適合使用實值型別。

實值型別的虛方法

    到目前為止,我們沒有考慮到實值型別的方法表指標,然而在我們將實值型別作為一等公民時仍有很多不容易克服的問題。現在我們來看看實值型別如何?虛方法和介面方法。CLR禁止實值型別之間繼承,這使得我們不可能在實值型別上定義新的虛方法。這很幸運,因為如果在實值型別中能夠定義新的虛方法,那麼調用這些虛方法需要方法表指標,而實值型別是沒有這部分。這不是一個重大限制,因為參考型別的值拷貝語義使得他們比較適合用來做多態,因為這需要對象引用。

    但是,實值型別繼承有來自System.Object類型的虛方法。這些方法有Equals,GetHashCode,ToString和Finalize,我們先討論前面兩個,後面幾個虛方法也會討論到。下面來看他們的簽名:

public class Object{    public virtual bool Equals(object obj) ...    public virtual int GetHashCode() ...}

    .NET中的每一個類型都實現了這些虛方法,當然包括實值型別。這表示,給定一個實值型別的執行個體,我們能夠成功的調用它的虛方法,即使他們並沒有方法表指標。

    第三個例子展示了,實值型別的空間布局是如何影響對實值型別的一些簡單的操作,諸如將實值型別轉換為一些能夠提供更多功能的真正意義的對象上的能力。

實值型別的裝箱

    當語言編譯器檢測到需要將實值型別作為參考型別處理時,就會產生裝箱的IL指令。然後,JIT編譯器解釋這些指令,調用方法在託管堆上分配空間,然後將實值型別執行個體的內容拷貝到堆上,然後為實值型別封裝上對象頭(對象頭指標和方法表指標)。在任何需要將實值型別當做參考型別使用的地方都會產生裝箱操作。需要注意的是,裝箱後的對象和原來的實值型別執行個體是沒有關係的,改變其中一個對另外一個沒有影響。

.method private hidebysig static object GetInt() cil managed{    .maxstack 8    L_0000: ldc.i4.s 0x2a    L_0002: box int32    L_0007: ret}

   裝箱是一種很昂貴的操作,它涉及到記憶體的分布,拷貝,並且由於需要收回臨時建立的裝箱對象,會對GC會產生壓力。在CLR 2.0中引入的泛型除了反射和其他一些極少情況,可以有效地避免裝箱操作。不論怎樣,裝箱在很多應用程式中會產生明顯的效能問題,在後面“如何正確使用實值型別”中我們會看到,如果不完全理解實值型別中的方法叫用作業,將很難避免各種裝箱操作。

    先不考慮效能問題,裝箱為我們之前遇到的一些問題提供了一種解決方案。比如GetInt方法返回一個對42實值型別的裝箱的引用。這個裝箱的對象只要存在引用會一直存在,他不會被方法呼叫堆疊的本地變數的生命週期所影響。同樣,當Monitor.Enter方法需要參考型別時,他會在運行時對實值型別進行裝箱,然後使用裝箱後的對象來進行同步操作。不幸的是,一些實值型別執行個體對象裝箱產生的引用對象在代碼的不同地方可能會不同,因此,Monitor.Exit中傳入的實值型別進行裝箱後的參考型別和Monitor.Enter中的實值型別裝箱後的參考型別並不相同。一個線程中的Monitor.Enter中傳入的實值型別進行裝箱後的參考型別和另一個線程中的同樣方法的同樣的實值型別裝箱後的對象也不同。這就意味著,使用實值型別作為基於monitor機制的同步策略在本質上是錯誤的,而不論是否實值型別被裝箱成了參考型別。

    還有一個遺留的關鍵問題是繼承自System.Object的虛方法。實際上,實值型別並沒有直接繼承自System.Object類型,相反,所有的實值型別都間接的繼承自System.ValueType。

  System.ValueType覆寫了繼承自System.Object類型的Equals和GetHashCode兩個虛方法,這樣做是有道理的。實值型別的相等性和參考型別的相等性具有不同的語義,實值型別的這種不同的語義需要在某個地方實現。比如覆寫System.ValueType中的Equals方法可以保證實值型別之間可以根據其包含的內容來相互比較,而在System.Object類型中的Equal方法卻是比較對象的引用是否相同。

    不論System.ValueType如何覆寫了這些虛方法,考慮下面的情境。你在List<Point2D>中儲存了1千萬個Point2D對象,然後再這個集合中使用Contain方法尋找是否存在某個特定的Point2D對象。然而,Contains只能從這1千萬個資料上執行線性尋找,然後逐個和提供的對象進行比較。

List<Point2D> polygon = new List<Point2D>();//insert ten million points into the listPoint2D point = new Point2D { X = 5, Y = 7 };bool contains = polygon.Contains(point);

   遍曆1千萬個對象然後逐個比較可能需要花點兒時間,不過這仍是一種相對較快的操作。訪問的位元組數大約會有8千萬個(每一個Point2D對象佔8個位元組),然後執行比較操作也很快。但是遺憾的是,比較兩個Point2D對象需要調用Equals虛方法:

Point2D a = ..., b = ...;a.Equals(b);

    這兒產生了兩個問題。首先即使從System.ValueType繼承過來的Equals虛方法,他也是接受一個System.Object的參考型別的參數。而將Point2D對象作為參考型別則需要進行裝箱操作。因此b需要進行裝箱,更進一步,調用對象上的Equals虛方法需要對a進行裝箱以擷取其方法表的頭指標。

    NoteJIT編譯器實際上會產生直接調用Equals的代碼,因為實值型別是密封的,並且不論Point2D是否覆寫了Equals方法,在編譯的時候調用哪個對象的那個方法是確定下來了的。但不論如何,因為System.ValueType是參考型別,Equals方法在內部接受的第一個this參數,也就是對自己是一個參考型別,所以在實值型別a上調用Equals方法,仍舊需要對b進行一次裝箱。

    簡言之,如果不考慮JIT編譯器的最佳化,每調用一個Point2D執行個體對象上的Equals方法需要進行兩次裝箱。上面的1千萬次比較會產生2千萬次的裝箱操作,在32為機器上每一次操作需要在分配16個位元組的空間,總共需要分配320,000,000個位元組,並且160,000,000要拷貝到託管堆上。這些分配操作所化的時間遠遠超過了簡單的對Point2D的兩個欄位的比較。

避免調用實值型別Equal方法產生的裝箱

    那麼怎樣才能徹底消除這種裝箱操作呢?一種方法是覆寫System.Value中繼承來的Equals方法,並且提供為我們自己的實值型別提供的相等邏輯。

public struct Point2D{    public int X;    public int Y;    public override bool Equals(object obj)    {        if (!(obj is Point2D)) return false;        Point2D other = (Point2D)obj;        return X == other.X && Y == other.Y;    }}

    即使考慮了JIT的最佳化,a.Equals(b)方法仍舊需要對b進行裝箱,因為繼承得來的方法接受一個System.Object類型的參考型別的參數,但是不需要對a進行裝箱了。為了移除第二個裝箱操作,我們需要從裝箱操作之外來思考,提供一個Equals方法的重載方法:

public struct Point2D{    public int X;    public int Y;    public override bool Equals(object obj) ... //同上    public bool Equals(Point2D other)    {         return X == other.X && Y == other.Y;    }}

    這樣當編譯器遇到a.Equals(b)時,他會優先選擇第二個,因為他的參數類型更具體。想到這裡,我們還有幾個方法需要重載-通常,我們使用==和!=符號來進行類型比較,所以需要重載這兩個操作符。

public struct Point2D{    public int X;    public int Y;    public override bool Equals(object obj) ... // as before    public bool Equals(Point2D other) ... //as before    public static bool operator==(Point2D a, Point2D b)    {        return a.Equals(b);    }    public static bool operator!= (Point2D a, Point2D b)    {        return !(a == b);    }}

    這基本上已經完成了。有一個極端情況是CLR在實現泛型的時候,調用List<Point2D>中的Point2D對象的Equals方法時仍具需要裝箱,因為Point2D是作為泛型型別參數(T)的一種實現。所以在這裡Point2D對象還需要實現IEquatable<Point2D>介面,這樣List<T>和EqualityComparer<T>對象就能正確的通過介面調用重載的Equals方法了(唯一有點兒遺憾的是需要花費一點兒虛方法調用的效能來調用EqualityComparer<T>.Equal抽象方法)。這樣執行速度較之前會快10倍,並且完全消除了在1000000個Point2D對象中尋找某個特定對象由於裝箱而引入的記憶體配置。

public struct Point2D : IEquatable<Point2D>{    public int X;    public int Y;    public bool Equals(Point2D other) ... //as before}

   現在我們可以開始思考實值型別的介面實現了。在前文中我們已經看到,一個典型的介面方法調用需要對象的方法表指標,這對於實值型別來說需要進行裝箱。實際上,從實值型別執行個體轉換為介面類型變數就需要裝箱,因為介面是被作為參考型別和目的來使用的。

Point2D point = ...;IEquatable<Point2D> equatable = point; //需要裝箱

    但是,當通過靜態實值型別變數調用介面方法時,並不需要進行裝箱,和前面討論的一樣,這是JIT編譯幫我們做的一點兒小最佳化。

Point2D point = ..., anotherPoint = ...;point.Equals(anotherPoint); //並不需要裝箱,調用 Point2D.Equals(Point2D) 方法。

    通過介面使用實值型別,在實值型別可變的情況下,可能會引發一些潛在的問題,比如Point2D對象。修改裝箱後了的實值型別並不會影響原始的實值型別,這樣就會引發一些不可預料的行為。

Point2D point = new Point2D { X = 5, Y = 7 };Point2D anotherPoint = new Point2D { X = 6, Y = 7 };IEquatable<Point2D> equatable = point; //裝箱equatable.Equals(anotherPoint); //falsepoint.X = 6;point.Equals(anotherPoint); //trueequatable.Equals(anotherPoint); // false, 裝箱後的值沒有發生變化

    關於這點,強烈建議設定實值型別設為不可變類型,然後需要改變時建立新的拷貝,System.DateTime 就是不變實值型別的一個典型的例子。

    最後一個問題是ValueType.Equals的實際執行方法。通過實值型別包含的內容來對兩個實值型別進行相等性比較是比較麻煩的。下面是使用Reflector查看系統ValueType的Equals方法的實現:

public override bool Equals(object obj){    if (obj == null) return false;    RuntimeType type = (RuntimeType) base.GetType();    RuntimeType type2 = (RuntimeType) obj.GetType();    if (type2 != type) return false;    object a = this;    if (CanCompareBits(this))    {        return FastEqualsCheck(a, obj);    }    FieldInfo[] fields = type.GetFields(BindingFlags.NonPublic |    BindingFlags.Public | BindingFlags.Instance);    for (int i = 0; i < fields.Length; i++)    {        object obj3 = ((RtFieldInfo) fields[i]).InternalGetValue(a, false);        object obj4 = ((RtFieldInfo) fields[i]).InternalGetValue(obj, false);        if (obj3 == null && obj4 != null)            return false;        else if (!obj3.Equals(obj4))            return false;    }    return true;}

    簡單分析一下,如果CanCompareBits方法返回true,那麼執行FastEqualsCheck方法來進行相等性比較。否則,方法使用反射,尋找所有的欄位,然後逐個遞迴調用Equals方法。毋庸置疑,基於反射的迴圈操作是效能瓶頸。反射是一種極其昂貴的操作。CanCompareBits和FastEqualsCheck是CLR的內部實現調用,不是通過IL調用的,所以我們不能夠輕易看到,但是我們可以分析得到,如果實值型別結構比較緊湊,且不好含對其他對象的引用,CanCompareBits就會返回true

    FastEqualsCheck方法看起來很神奇,但是它實際上是執行的memcmp操作,比較按位元組比較實值型別執行個體在記憶體中的儲存。這兩個方法都是內部實現的細節,要滿足以上苛刻條件來使用這種比較方法不是一個好的辦法。

GetHashCode方法

    最後一個需要覆寫的重要方法是GetHashCode方法。在我們覆寫一個合適的實現之前,簡要討論一下這東西有什麼用。雜湊碼用的最多的就是和雜湊表一起使用,雜湊表是一種可以在常數時間內實現插入,尋找,刪除操作的資料結構。.NET架構中最常見的雜湊表類有Dictionary<TKey,TValue>,Hashtable和HashSet<T>。一個典型的雜湊實現由一組動態長度的buckets數組組成,每一個bucket都包含一個鏈表。往雜湊表中放資料的時候,他首先調用GetHashCode來計算數值,然後通過雜湊Function Compute該映射到那一個buckets,然後將這個元素插入到該buckets的鏈表中。

    雜湊表的效能嚴重依賴於雜湊表實現時選用的雜湊函數,雜湊函數應該滿足一下幾點

  1. 如果兩個對象相等,那麼他們的雜湊值要相等。
  2. 如果兩個對象不相等,那麼他們的雜湊值應該儘可能的不相等。
  3. GetHashCode方法必須快,雖然經常是對象的線性大小。
  4. 對象的雜湊值應該是不變的。

    GetHashCode的一個典型的實現就是依賴對象的欄位。例如,對於int類型的GetHashCode的比較好的實現就是直接返回這個int值。對於Point2D對象,我們可以考慮對兩個座標做線性組合,或者對兩個座標分別取出某些位,然後組合。定義一個普遍的好的雜湊值演算法比較困難,在這裡不便討論。

    雜湊值應該是不變的。假設有一個point(5,5)的點,將它存放在一個雜湊表中,進一步假設他的雜湊值為10。如果將這個點修改為point(6,6),那麼他的雜湊值就變為了12 。現在,你就沒有辦法找到之前的插入的那個點了,因為雜湊值被改變了。但是在實值型別中這卻不是個問題,因為我們不能修改已經插入到雜湊表中的對象了。雜湊表儲存了一份拷貝,我們的代碼訪問不到。

    那麼參考型別是如何?了的,對於參考型別,通常基於內容的相等性,考慮到下面類型的GetHashCode方法的實現:

public class Employee{    public string Name { get; set; }    public override int GetHashCode()    {        return Name.GetHashCode();    }}

    這看起來是一個好主意,雜湊值基於對象的類容,並且我們使用了String.GetHashCode,因此我們不需要去為Strings來實現一個好的產生雜湊值函數,但是考慮到當我們將該類型插入到雜湊表後,我們改變了該欄位之後,會發生什麼情況:

HashSet<Employee> employees = new HashSet<Employee>();Employee kate = new Employee { Name = “Kate Jones” };employees.Add(kate);kate.Name = “Kate Jones-Smith”;employees.Contains(kate); //false!

    對象的雜湊值發生了改變,因為他的內容變化了,我們不在能在雜湊表中找到該對象了。這也是我們預料的,或許我們根本就不能從雜湊表中移除Kate這個對象了,雖然我們仍訪問的是原始的對象。

    CLR為參考型別提供了一個預設的GetHashCode實現,它基於對象在比較相等性時的依據原則。如果兩個對象的引用相等,僅且僅當引用的是同一個對象時,可以將雜湊值儲存到對象本身,這樣他就不會被修改並且容易訪問。實際上當一個參考型別的執行個體被建立時,CLR會將該對象的雜湊值存放到對象的頭位元組中(為了最佳化,一般是在第一次訪問雜湊值時產生,畢竟大多數對象從來都不會使用到雜湊表的鍵)。要計算雜湊值,並不需要產生隨機數其或者對象的內容,一個簡單的計數器就可以。

     Note: 對象的雜湊值如何與同步塊所以在對象的頭位元組中共存?上文中可以看到,大多數對象都不會用到頭位元組來存放同步塊所以,因為他們都不會被用來進行同步。在一些極少數情況下,對象會被用作 同步而需要在頭位元組中儲存同步塊碎銀,雜湊值被拷貝到同步塊索引上,一直到同步塊索引從對象頭位元組上移除。要確定對象頭位元組中當前儲存的是雜湊值還是同步塊索引,有一個標誌位可以用來進行判斷。

    參考型別使用預設的Equals和GetHashCoe實現,而不需要考慮上面提到的四個屬性,他們都已經實現好了。但是,如果參考型別需要覆寫預設的相等性行為,如果需要將參考型別作為雜湊表的鍵,那麼應該保證他的不變性。

 

使用實值型別應該注意的問題

    經過上面的一些討論,對於實值型別,CLR Via C#中建議,如果達到下面所有要求,就應該考慮使用實值型別:

  • 類型具有基元類型的行為,就是類型比較簡單,沒有成員回去修改類型的執行個體欄位。沒有提供修改欄位的方法,類型不可變。
  • 類型不需要從其他類型繼承並不會派生自其它類型。

    除此之外,考慮導致類型的拷貝複製,滿足上面兩點之後,還需要要滿足下面之一

  • 類型的執行個體較小(16位元組或更小)
  • 類型的執行個體較大(大於16位元組),但不作為方法參數傳遞,也不作為方法的傳回型別使用。

    當然,通過本文的分析,當遇到下面情況時,也可以考慮使用實值型別。

  • 如果對象比較少,並且數量比較多,應該使用實值型別
  • 如果需要高密度的記憶體集合分配,應該使用實值型別

    如果使用實值型別,需要注意下面幾點:

  • 自訂實值型別需要覆寫Equals方法,重載Equals方法,實現IEquatable<T>介面,重載==和!=操作符
  • 自訂的實值型別應該覆寫GetHashCode方法
  • 實值型別應該保持”不可變(immutable)”,改變應該重新建立新的對象的拷貝
結語

    我們分析了實值型別和參考型別的記憶體布局,以及這些細節是如何影響程式效能。實值型別具有較好的記憶體配置密度,這使得在建立大資料量的集合是具有比較好的優勢,但是他缺少參考型別的多態和同步支援。CLR為我們提供了這兩種不同類型來讓我們在需要的時候提高應用程式的效能,但是仍然需要我們通過分析,來正確的實現實值型別。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.