C#多執行緒之執行緒基礎篇

2022-11-13 12:00:20

一、概念

《Threading in C# 》(Joseph Albahari):https://www.albahari.com/threading/

《Threading in C# 》中文翻譯(GKarch ):https://blog.gkarch.com/topic/threading.html

《圖解系統》(小林coding):https://xiaolincoding.com/os/

並行(parallel):同一時間,多個執行緒/程序同時執行。多執行緒的目的就是為了並行,充分利用cpu多個核心,提高程式效能

執行緒(threading):執行緒是作業系統能夠進行 運算排程的最小單位,是程序的實際運作單位。
一條執行緒指的是程序中一個單一順序的控制流,一個程序中可以並行多個執行緒,每條執行緒並行執行不同的任務。

程序(process):程序是作業系統進行資源分配的基本單位。多個程序並行的在計算機上執行,多個執行緒並行的在程序中執行,
程序之間是隔離的,執行緒之間共用堆,私有棧空間。

CLR 為每個執行緒分配各自獨立的 棧(stack) 空間,因此區域性變數是執行緒獨立的。

static void Main()
{
  new Thread(Go).Start();  // 在新執行緒執行Go()
  Go();  // 在主執行緒執行Go()
}

static void Go()
{
  // 定義和使用區域性變數 - 'cycles'
  for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}

變數cycles的副本是分別線上程各自的棧中建立,因此會輸出 10 個問號

??????????

執行緒可以通過對同一物件的參照來共用資料。例如:

static bool done = false;

static void Main()
{
  new Thread (tt.Go).Start(); // A
  Go(); // B
}

static void Go()
{
   if (!done) { 
      Console.WriteLine ("Done");
      done = true;
   }
}

這個例子引出了一個關鍵概念 執行緒安全(thread safety) ,由於並行,」 Done 「 有可能會被列印兩次

通過簡單的加鎖操作:在讀寫公共欄位時,獲得一個 排它鎖(互斥鎖,exclusive lock ) ,c#中使用lock即可生成 臨界區(critical section)

static readonly object locker = new object();
...
static void Go()
{
  lock (locker) // B
  {
    if (!done) { 
      Console.WriteLine ("Done");
      done = true;
    }
  }
}

臨界區(critical section):在同一時刻只有一個執行緒能進入,不允許並行。當有執行緒進入臨界區段時,其他試圖進入的執行緒或是程序必須 等待或阻塞(blocking)

執行緒阻塞(blocking):指一個執行緒在執行過程中暫停,以等待某個條件的觸發來解除暫停。阻塞狀態的執行緒不會消耗CPU資源

掛起(Suspend):和阻塞非常相似,在虛擬記憶體管理的作業系統中,通常會把阻塞狀態的程序的實體記憶體空間換出到硬碟,等需要再次執行的時候,再從硬碟換入到實體記憶體。描述程序沒有佔用實際的實體記憶體空間的情況,這個狀態就是掛起狀態

可以通過呼叫Join方法等待執行緒執行結束,例如:

static void Main()
{
  Thread t = new Thread(Go);
  t.Start();
  t.Join();  // 等待執行緒 t 執行完畢
  Console.WriteLine ("Thread t has ended!");
}

static void Go()
{
  for (int i = 0; i < 1000; i++) Console.Write ("y");
}

也可以使用Sleep使當前執行緒阻塞一段時間:

Thread.Sleep (500);  // 阻塞 500 毫秒

Thread.Sleep(0)會立即釋放當前的時間片(time slice),將 CPU 資源出讓給其它執行緒。Framework 4.0的Thread.Yield()方法與其大致相同,不同的是Yield()只會出讓給執行在相同處理器核心上的其它執行緒。

Sleep(0)和Yield在調整程式碼效能時偶爾有用,它也是一個很好的診斷工具,可以用於找出執行緒安全(thread safety)的問題。如果在你程式碼的任意位置插入Thread.Yield()會影響到程式,
基本可以確定存在 bug。

二、原理

硬體結構

https://xiaolincoding.com/os/1_hardware/how_cpu_run.html#圖靈機的工作方式

執行時

  執行緒在內部由一個 執行緒排程器(thread scheduler) 管理,一般 CLR 會把這個任務交給作業系統完成。執行緒排程器確保所有活動的執行緒能夠分配到適當的執行時間,並且保證那些處於等待或阻塞狀態(例如,等待排它鎖或者使用者輸入)的執行緒不消耗CPU時間。

  在單核計算機上,執行緒排程器會進行 時間切片(time-slicing) ,快速的在活動執行緒中切換執行。在 Windows 作業系統上,一個時間片通常在十幾毫秒(譯者注:預設 15.625ms),遠大於 CPU 線上程間進行上下文切換的開銷(通常在幾微秒區間)。

  在多核計算機上,多執行緒的實現是混合了時間切片和 真實的並行(genuine concurrency) ,不同的執行緒同時執行在不同的 CPU 核心上。仍然會使用到時間切片,因為作業系統除了要排程其它的應用,還需要排程自身的執行緒。

  執行緒的執行由於外部因素(比如時間切片)被中斷稱為 被搶佔(preempted)。在大多數情況下,執行緒無法控制其在什麼時間,什麼程式碼塊被搶佔。

  多執行緒同樣也會帶來缺點,最大的問題在於它提高了程式的複雜度。使用多個執行緒本身並不複雜,複雜的是執行緒間的互動(共用資料)如何保證安全。無論執行緒間的互動是否有意為之,都會帶來較長的開發週期,以及帶來間歇的、難以重現的 bug。因此,最好保證執行緒間的互動儘可能少,並堅持簡單和已被證明的多執行緒互動設計。

  當頻繁地排程和切換執行緒時(且活動執行緒數量大於 CPU 核心數),多執行緒會增加系統資源和 CPU 的開銷,執行緒的建立和銷燬也會增加開銷。多執行緒並不總是能提升程式的執行速度,如果使用不當,反而可能降低速度。

三、基礎

建立與啟動

使用Thread類的構造方法來建立執行緒,支援以下兩種委託

public delegate void ThreadStart();

public delegate void ParameterizedThreadStart (object? obj);

關於Thread構造過載方法引數 maxStackSize,不建議使用

https://stackoverflow.com/questions/5507574/maximum-thread-stack-size-net

public void 建立一個執行緒()
{
    var t = new Thread(Go);  // 開一個執行緒t
    t.Start();  // 啟動t執行緒,執行Go方法
    
    Go();  // 主執行緒執行Go方法
}

void Go()
{
    _testOutputHelper.WriteLine("hello world!");
}

每一個執行緒都有一個 Name 屬性,我們可以設定它以便於偵錯。執行緒的名字只能設定一次,再次修改會丟擲異常。

public void 執行緒命名()
{
    var t = new Thread(Go);  // 開一個執行緒t
    t.Name = "worker";
    t.Start();  // 啟動t執行緒,執行Go方法
    
    Go();  // 主執行緒執行Go方法
}

void Go()
{
    // Thread.CurrentThread屬性會返回當前執行的執行緒
    _testOutputHelper.WriteLine(Thread.CurrentThread.Name + " say: hello!");
}

傳遞引數

Thread類的Start方法過載支援向thread範例傳參

public void Start(object? parameter)

引數被lambda表示式捕獲,傳遞給Go方法

public void 建立一個執行緒()
{
    var t = new Thread(msg => Go(msg));  // 開一個執行緒t
    t.Start("hello world!");  // 啟動t執行緒,執行Go方法
    
    Go("main thread say:hello world!");  // 主執行緒執行Go方法
}

void Go(object? msg)
{
    _testOutputHelper.WriteLine(msg?.ToString());
}

請務必注意,不要在啟動執行緒之後誤修改被捕獲變數(captured variables)

public void 閉包問題()
{
    for (int i = 0; i < 10; i++)
    {
        new Thread (() => Go(i)).Start();
    }
}

前臺/後臺執行緒

預設情況下,顯式建立的執行緒都是前臺執行緒(foreground threads)。只要有一個前臺執行緒在執行,程式就可以保持存活不結束。
當一個程式中所有前臺執行緒停止執行時,仍在執行的所有後臺執行緒會被強制終止。

這裡說的 顯示建立,指的是通過new Thread()建立的執行緒

非預設情況,指的是將Thread的IsBackground屬性設定為true

static void Main (string[] args)
{
	Thread worker = new Thread ( () => Console.ReadLine() );
	if (args.Length > 0) worker.IsBackground = true;
	worker.Start();
}

當程序以強制終止這種方式結束時,後臺執行緒執行棧中所有finally塊就會被避開。如果程式依賴finally(或是using)塊來執行清理工作,例如釋放資料庫/網路連線或是刪除臨時檔案,就可能會產生問題。
為了避免這種問題,在退出程式時可以顯式的等待這些後臺執行緒結束。有兩種方法可以實現:

  • 如果是顯式建立的執行緒,線上程上呼叫Join阻塞。
  • 如果是使用執行緒池執行緒,使用訊號構造,如事件等待控制程式碼。

在任何一種情況下,都應指定一個超時時間,從而可以放棄由於某種原因而無法正常結束的執行緒。這是後備的退出策略:我們希望程式最後可以關閉,而不是讓使用者去開工作管理員(╯-_-)╯╧══╧

執行緒的 前臺/後臺狀態 與它的 優先順序/執行時間的分配無關。

例外處理

當執行緒開始執行後,其內部發生的異常不會拋到外面,更不會被外面的try-catch-finally塊捕獲到。

void 異常捕獲()
{
    try
    {
        new Thread(Go).Start();  // 啟動t執行緒,執行Go方法
    }
    catch (Exception e)
    {
        _testOutputHelper.WriteLine(e.Message);
    }
}
    
void Go() => throw null!;  // 丟擲空指標異常

解決方案是將例外處理移到Go方法中:自己的異常,自己解決

static void Go()
{
  try
  {
    // ...
    throw null;    // 異常會在下面被捕獲
    // ...
  }
  catch (Exception ex)
  {
    // 一般會記錄異常,或通知其它執行緒我們遇到問題了
    // ...
  }
}

AppDomain.CurrentDomain.UnhandledException 會對所有未處理的異常觸發,因此它可以用於集中記錄執行緒發生的異常,但是它不能阻止程式退出。

void UnhandledException()
{
    AppDomain.CurrentDomain.UnhandledException += HandleUnHandledException;
    new Thread(Go).Start();  // 啟動t執行緒,執行Go方法
}

void HandleUnHandledException(object sender, UnhandledExceptionEventArgs eventArgs)
{
    _testOutputHelper.WriteLine("我發現異常了");
}

並非所有執行緒上的異常都需要處理,以下情況,.NET Framework 會為你處理:

  • 非同步委託(APM)
  • BackgroundWorker(EAP)
  • 任務並行庫(TPL)

中斷與中止

所有阻塞方法Wait(), Sleep() or Join(),在阻塞條件永遠無法被滿足且沒有指定超時時間的情況下,執行緒會陷入永久阻塞。

有兩個方式可以實現強行結束:中斷、中止

中斷(Interrupt)

在一個阻塞執行緒上呼叫Thread.Interrupt會強制釋放它,並丟擲ThreadInterruptedException異常,與上文的一樣,這個異常同樣不會丟擲

var t = new Thread(delegate()
{
    try
    {
        Thread.Sleep(Timeout.Infinite);  // 無期限休眠
    }
    catch (ThreadInterruptedException)
    {
        _testOutputHelper.WriteLine("收到中斷訊號");
    }

    _testOutputHelper.WriteLine("溜溜球~");
});
t.Start();
Thread.Sleep(3000);  // 睡3s後中斷執行緒t
t.Interrupt();

如果在非阻塞執行緒上呼叫Thread.Interrupt,執行緒會繼續執行直到下次被阻塞時,丟擲ThreadInterruptedException。這避免了以下這樣的程式碼:

if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)  // 執行緒不安全的
{
    worker.Interrupt();
}

  隨意中斷一個執行緒是極度危險的,這可能導致呼叫棧上的任意方法(框架、第三方包)收到意外的中斷,而不僅僅是你自己的程式碼!只要呼叫棧上發生阻塞(因為使用同步構造),
中斷就會發生在這,如果在設計時沒有考慮中斷(在finally塊中執行適當清理),執行緒中的物件就可能成為一個奇怪狀態(不可用或未完全釋放)。

  如果是自己設計的阻塞,完全可以用 訊號構造(signal structure) 或者 取消令牌(cancellation tokens) 來達到相同效果,且更加安全。如果希望結束他人程式碼導致的阻塞,Abort總是更合適

中止(Abort)

通過Thread.Abort方法也可以使阻塞的執行緒被強制釋放,效果和呼叫Interrupt類似,不同的是它丟擲的是ThreadAbortException的異常。另外,這個異常會在catch塊結束時被重新丟擲(試圖更好的結束執行緒)。

Thread t = new Thread(delegate()
{
    try
    {
        while (true)
        {
        }
    }
    catch (ThreadAbortException)
    {
        _testOutputHelper.WriteLine("收到中止訊號");
    }
    // 這裡仍然會繼續丟擲ThreadAbortException,以保證此執行緒真正中止
});

_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Unstarted 狀態

t.Start();
Thread.Sleep(1000);
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Running 狀態

t.Abort();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // AbortRequested 狀態

t.Join();
_testOutputHelper.WriteLine(t.ThreadState.ToString()); // Stopped 狀態

除非Thread.ResetAbort在catch塊中被呼叫,在此之前,執行緒狀態(thread state) 是AbortRequested,呼叫Thread.ResetAbort來阻止異常被自動重新丟擲之後,
執行緒重新進入Running狀態(從這開始,它可能被再次中止)

static void Main()
{
  Thread t = new Thread (Work);
  t.Start();
  Thread.Sleep (1000); t.Abort();
  Thread.Sleep (1000); t.Abort();
  Thread.Sleep (1000); t.Abort();
}

static void Work()
{
  while (true)
  {
    try { while (true); }
    catch (ThreadAbortException) { Thread.ResetAbort(); }
    Console.WriteLine ("我沒死!");
  }
}

Thread.Abort在NET 5被棄用了:https://learn.microsoft.com/zh-cn/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete

未處理的ThreadAbortException是僅有的兩個不會導致應用程式關閉的異常之一,另一個是AppDomainUnloadException。

Abort幾乎對處於任何狀態的執行緒都有效:Running、Blocked、Suspended以及Stopped。然而,當掛起的執行緒被中止時,會丟擲ThreadStateException異常。中止會直到執行緒之後恢復時才會起作用。

try { suspendedThread.Abort(); }
catch (ThreadStateException) { suspendedThread.Resume(); }
// 現在 suspendedThread 才會中止

Interrupt和Abort最大的不同是:呼叫Interrupt執行緒會繼續工作直到下次被阻塞時丟擲異常,而呼叫Abort會立即線上程正在執行的地方丟擲異常(非受控程式碼除外)。

這將導致一個新的問題:.NET Framework 中的程式碼可能會被中止,而且不是安全的中止。如果中止發生在FileStream被構造期間,很可能造成一個非託管檔案控制程式碼會一直保持開啟直到應用程式域結束。

共同作業取消模式

正如上面所說Interrupt和Abort總是危險的,替代方案就是實現一個共同作業模式(cooperative ):工作執行緒定期檢查一個用於指示是否應該結束的標識,發起者只需要設定這個標識,等待工作執行緒響應,即可取消執行緒執行。

Framework 4.0 提供了兩個類CancellationTokenSourceCancellationToken來完成這個模式:

  • CancellationTokenSource定義了Cancel方法。
  • CancellationToken定義了IsCancellationRequested屬性和ThrowIfCancellationRequested方法。
void 取消令牌()
{
    var cancelSource = new CancellationTokenSource();
    cancelSource.CancelAfter(3000);
    var t = new Thread(() => Work(cancelSource.Token));
    t.Start();
    t.Join();
}
void Work(CancellationToken cancelToken)
{
    while (true)
    {
        cancelToken.ThrowIfCancellationRequested();
        // ...
        Thread.Sleep(1000);
    }
}

四、非同步程式設計模式

MSDN檔案:https://learn.microsoft.com/zh-cn/dotnet/standard/asynchronous-programming-patterns/

非同步程式設計模型(APM)

非同步程式設計模型(Asynchronous Programming Model),提出於.NET Framework 1.x 的時代,基於IAsyncResult介面實現類似BeginXXX和EndXXX的方法。

APM是建立在委託之上的,Net Core中的委託 不支援非同步呼叫,也就是 BeginInvoke 和 EndInvoke 方法。

void APM()
{
    var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
    Func<Uri, int> f = CalcUriStringCount;
    var res = f.BeginInvoke(uri, null, null);
    // do something
    _testOutputHelper.WriteLine("我可以做別的事情");
    _testOutputHelper.WriteLine("共下載字元數:" + f.EndInvoke(res));
}
int CalcUriStringCount(Uri uri)
{
    var client = new WebClient();
    var res = client.DownloadString(uri);
    return res.Length;
}

EndInvoke會做三件事:

  1. 如果非同步委託還沒有結束,它會等待非同步委託執行完成。
  2. 它會接收返回值(也包括refout方式的引數)。
  3. 它會向呼叫執行緒丟擲未處理的異常。

不要因為非同步委託呼叫的方法沒有返回值就不呼叫EndInvoke,因為這將導致其內部的異常無法被呼叫執行緒察覺。MSDN檔案中明確寫了 「無論您使用何種方法,都要呼叫 EndInvoke 來完成非同步呼叫。」

BeginInvoke也可以指定一個回撥委託。這是一個在完成時會被自動呼叫的、接受IAsyncResult物件的方法。

BeginInvoke的最後一個引數是一個使用者狀態物件,用於設定IAsyncResultAsyncState屬性。它可以是需要的任何東西,在這個例子中,我們用它向回撥方法傳遞method委託,這樣才能夠在它上面呼叫EndInvoke

var uri = new Uri("https://www.albahari.com/threading/part3.aspx");
Func<Uri, int> func = CalcUriStringCount;
var res = func.BeginInvoke(uri, new AsyncCallback(res =>
{
    var target = res.AsyncState as Func<string, int>;
    _testOutputHelper.WriteLine("共下載字元數:" + target!.EndInvoke(res));
    _testOutputHelper.WriteLine("非同步狀態:" + res.AsyncState);
}), func);
// do something
_testOutputHelper.WriteLine("我可以做別的事情");
func.EndInvoke(res);

基於事件的非同步模式(EAP)

基於事件的非同步模式(event-based asynchronous pattern),EAP 是在 .NET Framework 2.0 中提出的,讓類可以提供多執行緒的能力,而不需要使用者顯式啟動和管理執行緒。這種模式具有以下能力:

  • 共同作業取消模型(cooperative cancellation model)
  • 執行緒親和性(thread affinity)
  • 將異常轉發到完成事件(forwarding exceptions)

這個模式本質上就是:類提供一組成員,用於在內部管理多執行緒,類似於下邊的程式碼:

// 這些成員來自於 WebClient 類:

public byte[] DownloadData (Uri address);    // 同步版本
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;

public void CancelAsync (object userState);  // 取消一個操作
public bool IsBusy { get; }                  // 指示是否仍在執行

當呼叫基於EAP模式的類的XXXAsync方法時,就開始了一個非同步操作,EAP模式是基於APM模式之上的。

var client = new WebClient();
client.DownloadStringCompleted += (sender, args) =>
{
    if (args.Cancelled) _testOutputHelper.WriteLine("已取消");
    else if (args.Error != null) _testOutputHelper.WriteLine("發生異常:" + args.Error.Message);
    else
    {
        _testOutputHelper.WriteLine("共下載字元數:" + args.Result.Length);
        // 可以在這裡更新UI。。
    }
};
_testOutputHelper.WriteLine("我在做別的事情");
client.DownloadStringAsync(new Uri("https://www.albahari.com/threading/part3.aspx"));

BackgroundWorker是名稱空間System.ComponentModel中的一個工具類,用於管理工作執行緒。它可以被認為是一個 EAP 的通用實現,在EAP功能的基礎上額外提供了:

  • 報告工作進度的協定
  • 實現了IComponent介面

另外BackgroundWorker使用了執行緒池,意味著絕不應該在BackgroundWorker執行緒上呼叫Abort

void 工作進度報告()
{
    worker = new BackgroundWorker();
    worker.WorkerReportsProgress = true;  // 支援進度報告
    worker.WorkerSupportsCancellation = true;  // 支援取消
    worker.DoWork += DoWoker;
    worker.ProgressChanged += (_, args) => _testOutputHelper.WriteLine($"當前進度:{args.ProgressPercentage}%");
    worker.RunWorkerCompleted += (sender, args) =>
    {
        if (args.Cancelled) _testOutputHelper.WriteLine("工作執行緒已被取消");
        else if (args.Error != null) _testOutputHelper.WriteLine("工作執行緒發生異常: " + args.Error);
        else _testOutputHelper.WriteLine("任務完成,結果: " + args.Result); // Result來自DoWork
    };
    
    worker.RunWorkerAsync();
}

private void DoWoker(object? sender, DoWorkEventArgs e)
{
    for (int i = 0; i < 100; i+= 10)
    {
        if (worker.CancellationPending)
        {
            e.Cancel = true;
            return;
        }
        worker.ReportProgress(i);  // 上報進度
        Thread.Sleep(1000);  // 模擬耗時任務
    }

    e.Result = int.MaxValue;  // 這個值會回傳給RunWorkerCompleted
}

基於任務的非同步模式 (TAP)

從 .NET Framework 4 開始引入

五、拓展知識

小林coding:https://xiaolincoding.com/os/4_process/process_base.html#程序的控制結構

執行緒優先順序

執行緒的Priority屬性決定了相對於作業系統中的其它活動執行緒,它可以獲得多少CPU 時間片(time slice)

優先順序依次遞增,在提升執行緒優先順序前請三思,這可能會導致其它執行緒的 資源飢餓(resource starvation)

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

提升執行緒的優先順序並不等於直接優先,因為執行緒還受程序優先順序影響,因此還需要使用System.Diagnostics中的Process類

using (Process p = Process.GetCurrentProcess())
{
    p.PriorityClass = ProcessPriorityClass.High;
}

ProcessPriorityClass.High是一個略低於最高優先順序Realtime的級別。將一個程序的優先順序設定為Realtime是通知作業系統,我們絕不希望該程序將 CPU 時間出讓給其它程序。
如果你的程式誤入一個死迴圈,會發現甚至是作業系統也被鎖住了,就只好去按電源按鈕了o(>_<)o 正是由於這一原因,High 通常是實時程式的最好選擇。

什麼是程序退出?

程序退出一般出現在以下幾種情況:

  • 正常退出,程序執行完任務。

  • 錯誤退出,程序遇到不可繼續執行的錯誤(發生異常未捕獲導致程式退出)

  • 被作業系統終止,程序本身有問題,比如程序企圖存取不屬於自己的記憶體地址

  • 被其它程序終止,比如通過資源管理器我們可以選擇終止掉某個程序

以上只有前兩種情況是程序自願退出的,因此,總體上可以分為三類:程序自願退出,作業系統終止程序以及程序終止程序。

main()執行結束時會自動隱式呼叫exit(),windows下叫ExitProcess。中止整個程式的執行,把控制返還給作業系統,並返回一個整數值,通常0表示正常終止,非0表示異常終止,這個值將會返回給作業系統。

windows中通過工作管理員,linux中通過kill去殺掉一個程序,其資源是否會釋放?

會。程序的特徵之一就是動態性,其生存週期就是產生到消亡。當發生程序終止後,呼叫程序終止原語,從PCB匯流排中將其刪除,將PCB結構歸還給系統,釋放該程序的資源給其父程序或者作業系統。

但不完全會。如果使用者強行終止了.NET 程序,所有執行緒都會被當作後臺執行緒一般丟棄,有的資源沒來得及釋放,需要等待一段時間

Process類有以下兩種方法:

  • CloseMainWindow:向主視窗訊息迴圈傳送wm_quit訊息以請求關閉程序,這使程式有機會重新呼叫其子視窗和核心物件。
  • Kill:強制終止程序,就像在工作管理員中終止程序一樣。

我們可以使用visual studio元件:記憶體分析器 分析發現幾乎在所有情況下,kill速度更快,但通過檢查實時記憶體圖可以發現其「根參照」和「範例參照」釋放的記憶體更少。