C#內存管理

儘管在.net framework中我們不太需要關注內存管理和垃圾回收這方面的問題,但是出於提高我們應用程序性能的目的,在我們的腦子裏還是需要有這方面的意識。明白內存管理的基本行爲將有助於我們解釋我們程序中變量是如何操作的。在本文中我將討論棧和堆的一些基本知識,變量的類型和某些變量的工作原理。
當你在執行程序的時候內存中有兩個地方用於存儲程序變量。如果你還不知道,那麼就來看看堆和棧的概念。堆和棧都是用於幫助我們程序運行的,包含某些特殊信息的操作系統內存模塊。那麼堆和棧有什麼不同呢?

VS棧的區別
棧主要用於存儲代碼,自動變量等信息;而堆則主要用於存儲運行期生成的對象等信息。將棧看作是一個有着層級關係的盒子,我們每一次只能操作盒子最上一格的東西。這也就是棧先進後出的數據結構特性。因此棧在我們程序中主要是用於保存程序運行時的一些狀態信息。堆則主要是用於保存對象內容,以便我們能夠在任何時候去訪問這些對象。總的來說,堆就是一種數據結構,我們不需要通過一套規則,可以隨時訪問的內存區域;棧則總是依據先進後出的,每次只能訪問最頂層元素的內存區域。


由於棧的特性所至,所以棧具有自我維護性,棧的內存管理可以通過操作系統來完成。而堆的管理就需要通過GC(垃圾回收器)來完成,使用一定的算法來掃描並釋放沒有用的對象。

關於棧和堆的更多內容

我們代碼中有四種主要的類型需要存儲在棧和堆當中:值類型,引用類型,指針和程序指令。

值類型:

c#中主要的值類型有:

bool byte char decimal double enum float int long sbyte short struct uint ulong ushort都來自於System.TypeValue

引用類型:
C#中主要的引用類型有:

class interface delegateobjectstring所有的引用類型都繼承自System.Object

指針:

在我們的內存管理中一個指針的意義就是一個引用對應到一個類型上。在.net framework中我們不能顯式的使用指針,所有的指針都被通用語言運行時(CLR)管理。指針是一塊指向其他內存區域的內存區域。指針需要佔據一定的內存空間就像其他任何數據一樣。


指令:

指令就是計算機執行代碼,如函數調用或是數據運算等。

內容和地址的問題

首先有兩點需要說明:

1.       引用類型總是存在於堆裏 – 很簡單,但是完全正確嗎?

2.       值類型和指針總是出現在他們聲明的地方。這個有點複雜需要相關的棧工作原理的知識。

棧就像我們之前提到的那樣,記錄我們程序執行時的一些信息。當我們在調用一個類的方法時,操作系統將調用指令壓棧並附帶方法參數。然後進入函數體處理變量操作。這個可以用下面的代碼來解釋:

 public int AddFive(int pValue) {
                
int result;
                result = pValue + 5;
                
return result;
 }

這個操作發生在棧的頂部,請注意我們看到已經有很多成員之前被壓入到棧中了。首先是方法的本身先被壓入棧中,緊接着是參數入棧。

然後是通過AddFive()裏面的指令來執行函數。

函數執行的結果同樣也需要分配一些內存來存放,而這些內存也分配在棧中。

函數執行結束後,就要將結果返回。

最後,通過刪除AddFive()的指針來清除所有之前棧中有關於函數運行時分配的內存。並繼續下一個函數(可能之前就存在在棧中)。

在這個例子中,我們的結果存儲在棧中。事實上,所有函數體內的值類型聲明都會分配到棧中。但是現在有些值類型也被分配在堆中。記住一個規則,值類型總是出現在聲明它們的地方。如果一個值類型聲明在函數體外,但是存於一個引用類型內,那麼它將跟這個引用類型一樣位於堆中。這裏用另外的一個例子來說明這個問題:

public class MyInt{          
             
public int MyValue;
}

public MyInt AddFive(int pValue){
                MyInt result = 
new MyInt();
                result.MyValue = pValue + 5;
                
return result;
}

現在這個函數的執行跟先前的有了點不同。這裏的函數返回是一個MyInt類對象,也就是說是一個引用類型。引用類型是被分配在堆中的,而引用的指針是分配在棧中。

AddFive()函數執行結束後,我們將清理棧中的內存。

在這裏我們看到除了棧中有數據,在堆中也有一些數據。而堆中的數據將被垃圾回收器回收。當我們的程序需要一塊內存並且已經沒有空閒的內存可以分配時,垃圾回收器開始運行。垃圾回收器會先停止所有運行中的線程,掃描堆中的所有對象並刪除那些沒有被主程序訪問的對象。垃圾回收器將重新組織堆中的所有空閒的空間,並調整所有棧中和堆中的相關指針。就像你能想到的那樣,這樣的操作會非常的影響效率。因此這也是爲什麼我們要強調編寫高性能的代碼。好,那我要怎麼樣去做呢?

當我們在操作一個引用類型的時候,我們操作的是它的指針而不是它本身。當我們使用值類型的時候我們使用的是它本身,這個很明顯。我們看一下代碼:

          public int ReturnValue() {
                
int x = new int();
                x = 3;
                
int y = new int();
                y = x;      
                y = 4;          
                
return x;
    
      }

這段代碼很簡單,返回3。但是如果我們改用引用類型MyInt類,結果可能不同:

          public class MyInt {
                
public int MyValue;
          }

          public int ReturnValue2() {
                MyInt x = 
new MyInt();
                x.MyValue = 3;
                MyInt y = 
new MyInt();
                y = x;                 
                y.MyValue = 4;              
                
return x.MyValue;
          }

這裏的返回值卻是4。爲什麼呢? 想象一下,我們之前講的內容,我們在操作值類型數據的時候只是操作該值的一個副本。而在操作引用類型數據的時候,我們操作的是該類型的指針,所以y = x就修改了y的指針內容,從而使得y也指向了x那一部分棧空間。所以y.MyValue = 4 => x.MyValue = 4。所以返回值會是

參數

當我們開始調用一個方法的時候,發生了什麼呢?

1.       在棧中分配我們方法所需的空間,包括回調的指針空間,該指針通過一條goto指令來回到函數調用開始的那個棧位置的下一個位置,以便繼續執行。

2.       我們方法的參數將被拷貝過來。

3.       控制器通過JIT方法和線程開始執行代碼,因此我們有了另外一個稱呼叫調棧。

代碼如下:

public int AddFive(int pValue){
                int result;
                result = pValue + 5;
                return result;

}

棧的結構模式:

參數在棧中的位置取決於它的類型,值類型本身被拷貝而引用類型的引用被拷貝。

傳遞值類型參數

當我們傳遞一個值類型參數時,內存先被分配然後是值被拷貝到棧中。代碼如下:

class Class1 {

    public void Go () {

        int x = 5;

        AddFive(x);

        Console.WriteLine(x.ToString());

    }

 

 

    public int AddFive (int pValue) {

        pValue += 5;

        return pValue;

    }

}

AddFive方法被執行,x位置變成5

AddFive()方法執行結束後,線程回到執行go方法,pValue將被刪除。

所以當我們在傳遞一個很大的值類型的時候,程序會逐位的拷貝到棧中,這很明顯就是效率很低。更何況我們的程序如果要傳遞這個值數千次的進行,那麼效率就更低。

這時我們就要用到引用類型來解決這樣的問題。

public void Go() {

 MyStruct x = new MyStruct();

 DoSomething(ref x);

}

 

public struct MyStruct {

   long a, b, c, d, e, f, g, h, i, j, k, l, m;

}

 

public void DoSomething(ref MyStruct pValue) {

        // DO SOMETHING HERE....

}

這種方法就更有效的進行操作內存,其實我們並不需要拷貝這塊內存。

當我們傳遞的是值類型的引用,那麼程序修改這個引用的內容都會直接反映到這個值上。

傳遞引用類型

傳遞引用類型參數有點類似於前面的傳遞值類型的引用。

public class MyInt {

    public int MyValue;

}

 

public void Go() {

     MyInt x = new MyInt();

     x.MyValue = 2;

     DoSomething(x);

     Console.WriteLine(x.MyValue.ToString());

}

 

public void DoSomething(MyInt pValue) {

    pValue.MyValue = 12345;

}

這段代碼做了如下工作:

1.       開始調用go()方法讓x變量進棧。

2.       調用DoSomething()方法讓參數pValue進棧

3.       然後x值拷貝到pValue

這裏有一個有趣的問題是,如果傳遞一個引用類型的引用又會發生什麼呢?

如果我們有兩類:

public class Thing {

}

 

public class Animal : Thing {

    public int Weight;

}

 

public class Vegetable : Thing {

    public int Length;

}

我們要執行go()的方法,如下:

public void Go () {

    Thing x = new Animal();

 

    Switcharoo(ref x);

 

    Console.WriteLine(

      "x is Animal    :   "

      + (x is Animal).ToString());

 

    Console.WriteLine(

        "x is Vegetable :   "

        + (x is Vegetable).ToString());

}

 

public void Switcharoo (ref Thing pValue) {

    pValue = new Vegetable();

}

X的輸出結果:

is Animal    :   False

is Vegetable :   True

爲什麼是這樣的結果呢?我們來看一下程序過程:

如果我們沒傳遞Thing對象的引用,那麼我們將得到相反的結果。

拷貝和不拷貝

首先我們查看值類型,請使用下面的類和結構體。我們擁有一個Dude類包含個Name元素和2Shoe。我們還有一個CopyDude()方法去產生一個新的Dude對象。

public struct Shoe {

    public string Color;

}

 

 

public class Dude {

    public string Name;

    public Shoe RightShoe;

    public Shoe LeftShoe;

 

    public Dude CopyDude () {

        Dude newPerson = new Dude();

        newPerson.Name = Name;

        newPerson.LeftShoe = LeftShoe;

        newPerson.RightShoe = RightShoe;

        return newPerson;

    }

 

 

    public override string ToString () {

        return (Name + " : Dude!, I have a " + RightShoe.Color +

            " shoe on my right foot, and a " +

             LeftShoe.Color + " on my left foot.");

    }

}

 

Dude類是一個引用類型並且因爲Shoe結構是類的一個成員,所以它們都被分配到堆中。

運行下面的程序:

public static void Main () {

    Class1 pgm = new Class1();

 

    Dude Bill = new Dude();

    Bill.Name = "Bill";

    Bill.LeftShoe = new Shoe();

    Bill.RightShoe = new Shoe();

    Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue";

 

    Dude Ted = Bill.CopyDude();

    Ted.Name = "Ted";

    Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red";

 

    Console.WriteLine(Bill.ToString());

    Console.WriteLine(Ted.ToString());

}

 

我們將得到如下的輸出:

 

Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot.

Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot.

 

那麼我們將Shoe聲明爲一個引用類型又會產生什麼結果呢?

 

public class Shoe {

    public string Color;

}

 

再次運行main()函數, 我們得到的結果是:

Bill : Dude!, I have a Red shoe on my right foot, and a Red on my left foot
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot

明顯Red shoeBill的腳上是錯誤的。爲什麼會這樣呢?看一下圖

因爲 我們使用Shoe作爲一個引用類型來取代值類型。當一個引用被拷貝的時候,只拷貝了其指針,所以我們不得不做一些額外的工作來確保我們的引用類型看起來更像是值類型。

幸運的是我們擁有一個名爲ICloneable接口可以幫助我們。這個接口基於一個契約,所有的Dude對象都將定義一個引用類型如何被複制以確保我們的Shoe不會發生共享錯誤。我們所有的類都可以使用ICloneable接口的clone方法來複制類對象。

public class Shoe : ICloneable {

    public string Color;

    #region ICloneable Members

 

    public object Clone () {

        Shoe newShoe = new Shoe();

        newShoe.Color = Color.Clone() as string;

        return newShoe;

    }

    #endregion

}

Clone()方法內我們創建了一個Shoe,拷貝所有引用類型並拷貝所有值類型並返回一個新的對象實例。你可能注意到string類已經實現了ICloneable接口,因此我們可以調用Color.Clone()。因爲Clone()返回的是一個對象的引用,我們不得不進行類型轉換在我們設置ShoeColor前。

接下來,我們用CopyDude()方法去克隆shoe

public Dude CopyDude () {

    Dude newPerson = new Dude();

    newPerson.Name = Name;

    newPerson.LeftShoe = LeftShoe.Clone() as Shoe;

    newPerson.RightShoe = RightShoe.Clone() as Shoe;

 

    return newPerson;

}

 

public static void Main () {

    Class1 pgm = new Class1();

    Dude Bill = new Dude();

    Bill.Name = "Bill";

    Bill.LeftShoe = new Shoe();

    Bill.RightShoe = new Shoe();

    Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue";

 

    Dude Ted = Bill.CopyDude();

    Ted.Name = "Ted";

    Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red";

 

    Console.WriteLine(Bill.ToString());

    Console.WriteLine(Ted.ToString());

}

重新運行程序,我們將得到如下輸出:

Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot

包裝實體

一般來說,我們總是想克隆一個引用類型和拷貝一個值類型。記住這點將有助於你解決調試時發生的錯誤。讓我們更進一步分析並清理一下Dude類實現,使用ICloneable接口來代替CopyDude()方法。

public class Dude : ICloneable {

    public string Name;

    public Shoe RightShoe;

    public Shoe LeftShoe;

 

    public override string ToString () {

        return (Name + " : Dude!, I have a " + RightShoe.Color +

            " shoe on my right foot, and a " +

             LeftShoe.Color + " on my left foot.");

    }

 

    #region ICloneable Members

 

    public object Clone () {

        Dude newPerson = new Dude();

        newPerson.Name = Name.Clone() as string;

        newPerson.LeftShoe = LeftShoe.Clone() as Shoe;

        newPerson.RightShoe = RightShoe.Clone() as Shoe;

        return newPerson;

    }

    #endregion

}

 

我們再來修改Main()中的方法:

public static void Main () {

    Class1 pgm = new Class1();

    Dude Bill = new Dude();

    Bill.Name = "Bill";

    Bill.LeftShoe = new Shoe();

    Bill.RightShoe = new Shoe();

    Bill.LeftShoe.Color = Bill.RightShoe.Color = "Blue";

    Dude Ted = Bill.Clone() as Dude;

    Ted.Name = "Ted";

    Ted.LeftShoe.Color = Ted.RightShoe.Color = "Red";

 

    Console.WriteLine(Bill.ToString());

    Console.WriteLine(Ted.ToString());

}

最後,運行我們的程序,會得到如下的輸出:

Bill : Dude!, I have a Blue shoe on my right foot, and a Blue on my left foot.
Ted : Dude!, I have a Red shoe on my right foot, and a Red on my left foot.

還有些比較有意思的東西,比如System.String重載的操作符=號就實現了clones方法,因此你不用過於擔心string類的引用複製問題。但是你要注意內存的消耗問題。由於string是引用類型所以需要一個指針指向堆中的另一個對象,但是看起來它像是一個值類型。

接下來讓我們從垃圾回收器的角度來看一下內存管理。如果我們想清理一下沒用的東西我們可能需要計劃一下怎麼做才更有效率。很明顯,我們需要先區分什麼是垃圾,什麼不是垃圾。那麼我們要先做一個假設:任何東西如果沒有用了那麼就認爲是垃圾。幸好我們身邊有兩位好朋友:即時編譯器(JIT)和統一語言運行時(CLR)。JITCLR保持着一個列表關於它們正在使用的對象。我們將使用這個列表作爲起始列表。我們將保持關於所有正在使用的對象到一個圖表中。所有的活動的對象都將被添加到這個圖表裏。

這也是垃圾回收器所作的事情,從即時編譯器和統一語言運行時那裏得到一份關於所有根對象的引用列表。然後遞歸的查找活動對象的引用去建立一個圖表。

根的組成如下:

l  全局/靜態指針。一種方法確定我們的對象不會被垃圾回收通過保持他們的引用在一個靜態變量裏。

l  棧內指針。我們不想拋棄那些我們應用程序需要使用的東西。

l  CPU寄存器指針。在託管堆裏的任何被CPU寄存器的內存地址指向的對象都應該保留。

調整堆

現在我們已經將我們的要保留的對象加到圖表中,現在我們可以分析一下這些東西。

由於對象2是不需要的,所以就像垃圾回收器那樣,我們下移對象3並修改對象1的指針。

然後我們在將對象5下移。

現在我們已經將託管堆進行了緊縮調整,爲新來的對象騰出空間。

知道垃圾回收器的工作原理就知道移動對象的工作是很繁重的。從這裏看出如果我們減少移動對象的大小就能提高垃圾回收器的工作效率,因爲減少了拷貝內容。

託管堆之外

有時候垃圾回收器需要執行代碼去清理非託管的資源諸如文件,數據庫連接,網絡連接等等。一種有效的控制這些內容的方式是終結器(finalizer)。

class Sample {

    ~Sample () {

        // FINALIZER: CLEAN UP HERE

    }

}

當對象在創建的時候,所有對象附帶的終結器(finalizer)都會添加到終結隊列裏。我們可以說圖中的對象1,4,5擁有終結器(finalizer)並都處於終結隊列中。讓我們看一下當對象24在沒有被應用程序引用並且垃圾回收器準備好的情況下會發生什麼。

圖裏對象2被作爲無用對象處理。但是,當我們處理對象4的時候,垃圾回收器會先查看它的終結隊列並重新聲明對象4所擁有的內存,對象4被移動並且它的終結器(finalizer)被添加到一個特殊的隊列- freachable

這裏有專門的線程去處理freachable隊列的成員。一旦對象4的終結器被線程執行,那麼它就會從freachable隊列中移除。然後對象4就可以被回收了。

而對象4在下一次回收開始前仍然存在。

在創建對象時添加終結器(finalizer)是垃圾回收器的一個額外工作。它要花費很高的代價並且嚴重影響垃圾回收器和我們的應用程序的性能。所以請確定在絕對必要的情況下再使用終結器(finalizer)

有更好的方案用作清理非託管資源。就像你想的那樣,我們可以使用IDisposable接口取代終結器(finalizer)去關閉數據庫鏈接並清理資源。

IDisposible

使用IDisposable接口的Dispose()方法做清理工作。因此如果我們有一個ResouceUser的類使用到了終結器(finalizer),如下:

public class ResourceUser {

    ~ResourceUser () // THIS IS A FINALIZER

    {

        // DO CLEANUP HERE

    }

}

那麼我們可以使用IDisposable來實驗同樣的功能:

public class ResourceUser : IDisposable {

    #region IDisposable Members

    public void Dispose () {

        // CLEAN UP HERE!!!

    }

    #endregion

}

IDisposable已經被集成到了關鍵字中。在using()的最後Dispose()的代碼塊會被調用。對象不應該在Dispose()的代碼塊後被引用,因爲它被標上了”gone”並且準備被垃圾回收器回收。

public static void DoSomething () {

    ResourceUser rec = new ResourceUser();

    using (rec) {

        // DO SOMETHING

    // DISPOSE CALLED HERE

 

    // DON'T ACCESS rec HERE

}

我喜歡把代碼放在using塊內,這樣所有的變量和資源在塊結束後回被自動回收(主要是因爲using關鍵字擴展了後是try … finally …, 而所有的具有IDisposable接口的對象的Dispose()方法會在finally的代碼塊中被自動調用)。

public static void DoSomething () {

    using (ResourceUser rec = new ResourceUser()) {

        // DO SOMETHING

    // DISPOSE CALLED HERE

}

通過實現類的IDisposible接口,這樣我們可以在垃圾回收器前通過強制方式釋放我們的對象。

謹防靜態變量

class Counter {

    private static int s_Number = 0;

    public static int GetNextNumber () {

        int newNumber = s_Number;

 

        // DO SOME STUFF

        s_Number = newNumber + 1;

        return newNumber;

    }

}

如果同時有兩個線程同時調用GetNextNumber()方法並同時爲newNumber分配同樣的變量在s_Num前。

那麼兩個線程同時將得到同樣的返回值。爲了解決這個問題,你需要去鎖定一部分的代碼塊,使得競爭線程進入一個等待隊列但是這樣會降低效率。

class Counter {

    private static int s_Number = 0;

 

    public static int GetNextNumber () {

        lock (typeof(Counter)) {

            int newNumber = s_Number;

 

            // DO SOME STUFF

            newNumber += 1;

            s_Number = newNumber;

            return newNumber;

        }

    }

}

謹防靜態變量2

接下來我們要關注引用類型的靜態變量。記住,任何被根引用的對象都不能被清除。下面是一段代碼:

class Olympics {

    public static Collection<Runner> TryoutRunners;

}

 

class Runner {

    private string _fileName;

    private FileStream _fStream;

 

    public void GetStats () {

        FileInfo fInfo = new FileInfo(_fileName);

        _fStream = _fileName.OpenRead();

    }

}

因爲Collection是存儲Olympics類的靜態集合,所以集合內的對象不會被垃圾回收器釋放(因爲它們都被root間接引用)。但是你可能要注意,每一次我們都要運行GetStats()來獲取被打開文件流的狀態。因爲它們不能被關閉也不能被垃圾回收器釋放而一直等待在那。想象一下我們如果有100000這樣的對象存在,那麼程序的性能就變得有多差。

 

單件

通過某種方式我們可以永久的保持一個對象實例在內存中。我們通過使用單件模式來實現。

單件可以看成是一個全局變量並且它會帶來很多頭疼的問題和奇怪的行爲在多線程應用程序中。如果我們使用單模式,那麼我們要進行適當的調整。

public class Earth {

      private static Earth _instance = new Earth();

      private Earth() { }

      public static Earth GetInstance() { return _instance; }

}

我們擁有一個私有的構造器因此用戶只能通過靜態的GetInstance()方法來獲取一個Earth實例。這是一個比較經典的線程安全實現,因爲CLR會去創建安全的靜態變量。這也是c#中我發現的最優雅的單件實現模式。

總結

1.         不要留下打開的資源!明確關閉所有連接和清理所有非託管資源。一個通用的規則在using塊內使用非託管資源。

2.         不要過度的使用引用。當我們的對象活着,那麼所有相關的引用對象將不會被回收。當我們操作了引用類的一些屬性後,我們需要明確的將引用變量設置爲null。以便垃圾回收器回收這些對象。

3.         使用終結器(finalizer)使工作更容易,但是是在必須的情況下。終結器(finalizer)需要花費垃圾回收器的昂貴的代價,所以必須在必要的時候使用它。一個更好的方案是使用IDisposible 接口來取代終結器(finalizer)。這樣做會使垃圾回收器工作的更有效率。

4.         將對象和它們的孩子保持在一起。這樣使得垃圾回收器更容易去產生大塊內存而不用去收集託管堆上的每一個零散的內存。因此當我們聲明一個對象由多個其他對象組合成的時候,我們應該顯示的將它們安排的緊密一些。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章