CodeGym /課程 /C# SELF /在 C# 的記憶體:stack、heap 與 垃圾回收 (

在 C# 的記憶體:stack、heap 與 垃圾回收 ( GC)

C# SELF
等級 65, 課堂 0
開放

1. 記憶體基礎:Stack 和 Heap (Stack & Heap)

當你的程式啟動時,它會取得記憶體。在 .NET 的上下文(CLR — Common Language Runtime)中,這些記憶體會自動被管理並分成兩個主要區域:Stack 和 Heap。了解資料放在哪裡以及怎麼存取對寫出有效率且穩定的程式非常重要。

Stack

把 Stack 想成一疊盤子:你總是把新盤子放在最上面,拿的時候也從最上面拿。這就是 LIFO(Last In, First Out)的原則 —「後進先出」。Stack 很快,但大小有限。

放在 stack 上的東西:

  • 值型別:直接儲存變數的值。
  • 物件參考:對於參考型別,stack 上只會放指向 heap 上物件的位址。
  • 方法參數:傳給函式的值。
  • 區域變數:在方法內宣告的變數。
  • 返回位址:方法執行完要回到哪裡。

Stack 的記憶體分配與釋放是自動且非常快速的,一旦對應的方法結束或變數超出作用域,記憶體就會被回收。

範例:值型別在 stack

void MyMethod()
{
    int a = 10;       // 'a' 和 值 10 - 在 stack
    bool flag = true; // 'flag' 和 值 true - 在 stack
    char initial = 'Z'; // 'initial' 和 值 'Z' - 在 stack

    // ...
} // 當 MyMethod 結束,'a', 'flag', 'initial' 會從 stack 被移除。

範例:stack 上的參考

class MyObject { }

void AnotherMethod()
{
    MyObject objRef; // 'objRef'(參考)- 在 stack。物件還未被建立。
    // ...
} // 當 AnotherMethod 結束,'objRef'(參考)會從 stack 被移除。

Heap

Heap 是更大且更彈性的記憶體區域。這裡沒有嚴格的 LIFO 順序;資料可以放在任何可用位置。Heap 用來放比較大或壽命較長的物件。

放在 heap 的東西:

  • 參考型別的物件:class、陣列、string 等,透過 new 建立。
  • 嵌套的值型別:如果值型別(struct)是參考型別(class)的欄位,它就儲存在 heap 上的物件內部。

Heap 的記憶體由 GC(Garbage Collector)自動管理。

範例:class 的物件在 heap

class Person { public string Name; public int Age; }

void CreatePerson()
{
    Person p = new Person(); // Person 物件 - 在 heap。'p'(參考)- 在 stack。
    p.Name = "Alice";        // 字串 "Alice"(也是物件)- 在 heap。
    p.Age = 30;              // 30(int,值型別)- 在 Person 物件內,位於 heap。
    // ...
} // 當 CreatePerson 結束,'p'(參考)會從 stack 被移除。
  // Person 物件在 heap 如果變得不可達,就會被垃圾回收處理。

範例:陣列在 heap

void ProcessArray()
{
    int[] numbers = new int[5]; // 由 5 個 int 組成的陣列 - 在 heap。'numbers'(參考)- 在 stack。
    numbers[0] = 10;            // 元素 10 - 成為陣列在 heap 的一部分。
    // ...
} // 當沒有任何參考指向陣列時,陣列會被 GC 清除。

2. 垃圾回收 (Garbage Collection)

Heap 的記憶體管理是靠 GC 自動完成的。這是 .NET 的一個重要特性,可以避免像沒用垃圾回收的語言那樣需要手動釋放記憶體而導致的記憶體洩漏。

目的與運作原理

GC 的主要目標是當 heap 上的物件不再被程式使用時,自動回收它們佔用的記憶體。

  1. 建立物件:呼叫 new 時,CLR 會在 heap 上分配空間。
  2. 追蹤參考:GC 會追蹤活躍的參考;如果從 stack、靜態欄位或其他可達物件有路徑指向該物件,則該物件被視為「可達」。
  3. 判定「垃圾」:如果沒有任何參考指向該物件,則物件被視為「不可達」,是垃圾。
  4. 回收:在必要時 GC 會標記可達物件,並釋放不可達物件的記憶體。
  5. 壓縮 (compact):為了減少碎片,剩下的物件可能會被移動以形成連續區塊。

GC 的世代 (Generational GC)

GC 採用世代的概念,因為大多數物件「年輕時就會消失」:

  • 世代 0 (Gen 0):新的物件;回收頻繁且快速。
  • 世代 1 (Gen 1):從 Gen 0 存活下來的物件;回收較少且耗時較長。
  • 世代 2 (Gen 2):長壽命物件;回收最少但代價最大。

何時會啟動 GC?

  • 沒有足夠的可用記憶體來分配新的物件時。
  • 閒置時的例行檢查。
  • 顯式(通常不建議)呼叫 GC.Collect()。

GC 與效能

GC 啟動時可能會造成短暫停頓,因為使用者程式的執行會被暫停。大量建立短命的 heap 物件會導致更頻繁的回收,進而影響效能。

範例:物件變得不可達

class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 MB 的資料

void AllocateAndLose()
{
    DataBlock block1 = new DataBlock(); // block1 物件在 heap,'block1' 參考在 stack

    // ... 一段程式碼 ...

    block1 = null; // 現在沒有指向 DataBlock 的活躍參考。它變成不可達。
    // 在這個時點 GC 還未必馬上刪除它,但它已成為回收候選。

    // 在此呼叫 GC.Collect()(僅用於示範,生產環境不建議這麼做!)
    Console.WriteLine("Calling GC.Collect()");
    GC.Collect(); // GC 可能(但不保證)現在就回收記憶體
}

AllocateAndLose();
// 當 AllocateAndLose 結束,'block1' 的參考會從 stack 被移除,
// 而 heap 上的 DataBlock 物件會變成不可達並可被回收。

3. 非受管資源的管理

GC 管理的是 .NET 的「受管」記憶體。但有一些 非受管資源 不在 GC 的控制範圍內,需要你明確釋放:

  • 檔案描述子(開啟的檔案)。
  • 網路 socket。
  • 視窗/圖形處理的描述子(例如 GDI+)。
  • 從 OS 直接分配的記憶體(例如透過 P/Invoke)。

Finalizers(析構子)

析構子是一個特殊方法,用來在物件被 GC 回收前進行非受管資源的最後清理。語法類似建構子,但前面有波浪號(tilde):

class MyClassWithFinalizer
{
    ~MyClassWithFinalizer() { /* 釋放非受管資源 */ }
}

GC 何時呼叫 finalizer 是不確定的,且在 finalizer 執行時會由獨立的 finalizer 執行緒處理。缺點是不可預測性和額外負擔(帶有 finalizer 的物件通常需要經過兩次回收)。因此 finalizers 只是保險機制,不應是主要的清理手段。

範例:把析構子當作保險做法

class UnmanagedResourceHolder
{
    private bool _resourceReleased = false;

    // 模擬非受管資源
    public UnmanagedResourceHolder() => Console.WriteLine("資源 已 建立。");
    
    public void ReleaseResource()
    {
        if (!_resourceReleased)
        {
            Console.WriteLine("資源 已 被 顯式 釋放。");
            _resourceReleased = true;
        }
    }

    // 析構子 (Finalizer) - 當 GC 當作最後手段時呼叫
    ~UnmanagedResourceHolder()
    {
        Console.WriteLine("析構子 被 呼叫(資源 沒有 被 顯式 釋放)。");
        ReleaseResource(); // 嘗試釋放資源
    }
}

介面 IDisposable

IDisposable 是顯式釋放資源的推薦方式。它只有一個方法:void Dispose()。常見的做法是實作一個可重入的 Dispose(),並在其中呼叫 GC.SuppressFinalize(this) 以抑制 finalizer。

範例:實作 IDisposable

using System.IO;

class MyFileWriter : IDisposable
{
    private StreamWriter _writer;
    private bool _disposed = false;

    public MyFileWriter(string path)
    {
        _writer = new StreamWriter(path, true);
        Console.WriteLine($"檔案 '{path}' 已 開啟。");
    }

    public void WriteLog(string message) => _writer.WriteLine(message);

    // 實作 IDisposable
    public void Dispose()
    {
        Dispose(true); // 呼叫主要的 Dispose 邏輯
        GC.SuppressFinalize(this); // 告訴 GC 不需要呼叫析構子
        _disposed = true;
    }

    // 保護性的 virtual 方法,符合常見的 Dispose pattern
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // 釋放受管資源
                _writer?.Dispose();
            }
            // 釋放非受管資源(如果有)
            Console.WriteLine("檔案 已 透過 Dispose 關閉。");
        }
    }

    // 選用的析構子作為「保險」
    ~MyFileWriter()
    {
        Console.WriteLine("檔案 透過 析構子 關閉(Dispose() 未 被 呼叫)。");
        Dispose(false); // 表示這不是顯式呼叫
    }
}

關鍵字 using

using 是語法糖,保證當物件離開作用域時會呼叫 Dispose(),即便發生例外也一樣。對於實作了 IDisposable 的物件,這是最推薦的使用方式。

範例:使用 using 自動清理

// 使用前面 MyFileWriter 的範例
void ProcessFile(string fileName)
{
    // MyFileWriter 的物件會在 using 區塊結束時自動被 Dispose
    using (var writer = new MyFileWriter(fileName))
    {
        writer.WriteLog("第一行。");
        writer.WriteLog("第二行。");
        // 即便這裡發生例外,Dispose() 仍會被呼叫
    } // 這裡會自動呼叫 writer.Dispose()

    Console.WriteLine("using 區塊 結束,檔案 已 關閉。");
}

ProcessFile("testlog.txt");
// 在這種情況下,因為已明確呼叫 Dispose(),MyFileWriter 的析構子不會被呼叫。

理解這些概念是寫出高效能、可靠且可擴展的 C# 應用程式的關鍵。

2
任務
C# SELF, 等級 65, 課堂 0
上鎖
堆疊(Stack)和堆(Heap)的差異
堆疊(Stack)和堆(Heap)的差異
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION