☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
快取&效能 進階

效能優化與 Profiling

記憶體管理:Value Types vs Reference Types

在 .NET 中,了解資料存放在哪裡是效能優化的第一步。

💡 比喻:口袋 vs 置物櫃

  • Value Type(值型別) = 東西直接放口袋裡(Stack)
    • 小東西:鑰匙、零錢(int, bool, struct)
    • 拿出來就能用,不用跑去別的地方找
  • Reference Type(參考型別) = 口袋放一張「置物櫃號碼牌」(Stack 上放指標)
    • 大東西放在置物櫃裡(Heap)
    • 要先看號碼牌,再跑去置物櫃拿
// Value Type:直接存在 Stack 上
// int, double, bool, char, struct, enum 都是 Value Type
int x = 42;        // 42 直接放在 Stack 上
int y = x;         // 複製一份 42 給 y
y = 100;           // 改 y 不影響 x
Console.WriteLine(x);  // 輸出 42(x 沒有被改到)

// Reference Type:Stack 上存指標,物件在 Heap 上
// class, string, array, delegate 都是 Reference Type
var list1 = new List<int> { 1, 2, 3 };  // 物件在 Heap,list1 是指標
var list2 = list1;  // 複製的是指標,不是物件!
list2.Add(4);       // 透過 list2 修改,list1 也會被影響
Console.WriteLine(list1.Count);  // 輸出 4!因為指向同一個物件

// 效能差異
// Value Type:複製很快(直接複製值)
// Reference Type:建立物件需要在 Heap 上分配記憶體
// Heap 分配比 Stack 慢,而且需要 GC 回收

垃圾回收 GC 的運作原理

💡 比喻:垃圾車

  • 你家不斷產生垃圾(new 出來的物件)
  • 垃圾車(GC)會定期來收垃圾
  • 但垃圾車來的時候,整條街的人都要停下來等(STW - Stop The World)
  • 所以垃圾產生得越多,垃圾車來得越頻繁,大家等越久
.NET GC 的三代回收機制:

Gen 0(第 0 代):新生兒病房
├── 剛 new 出來的物件都在這裡
├── 回收最頻繁,但也最快
├── 大部分物件在這裡就被回收了(朝生暮死)
└── 比喻:紙杯,用完就丟

Gen 1(第 1 代):觀察室
├── 從 Gen 0 存活下來的物件
├── 回收頻率適中
└── 比喻:通過面試的實習生,再觀察看看

Gen 2(第 2 代):長期住戶
├── 長期存活的物件
├── 回收最慢,代價最高(Full GC)
├── 靜態變數、長壽物件都在這裡
└── 比喻:正式員工,解雇成本高

LOH(Large Object Heap):大型物件堆
├── 超過 85,000 bytes 的物件
├── 直接進入 Gen 2
├── 回收代價非常高
└── 比喻:大型家具,搬運費很貴
// 查看 GC 統計資訊
// 取得 Gen 0 回收次數
Console.WriteLine($"Gen 0 回收次數:{GC.CollectionCount(0)}");
// 取得 Gen 1 回收次數
Console.WriteLine($"Gen 1 回收次數:{GC.CollectionCount(1)}");
// 取得 Gen 2 回收次數
Console.WriteLine($"Gen 2 回收次數:{GC.CollectionCount(2)}");
// 取得目前記憶體使用量(bytes)
Console.WriteLine($"記憶體使用量:{GC.GetTotalMemory(false):N0} bytes");

// ⚠️ 不要手動呼叫 GC.Collect()!
// GC.Collect();  ← 除非你非常確定在做什麼,否則不要這樣做
// 手動觸發 Full GC 會造成效能問題

Span 與 Memory

// Span<T>:不需要複製就能操作陣列的片段
// 比喻:用手指框住書本的某幾行,不需要影印出來

// 傳統方式:要複製一份子陣列
var numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Substring 或 Array.Copy 會建立新的陣列(分配 Heap 記憶體)
var subset = numbers[3..7];  // 複製了一份新陣列 [4, 5, 6, 7]

// Span<T>:直接指向原始陣列的一部分(零複製!)
Span<int> span = numbers.AsSpan(3, 4);  // 指向 [4, 5, 6, 7],沒有複製
// 可以讀取和修改(修改會影響原始陣列)
span[0] = 99;  // numbers[3] 也變成 99 了
Console.WriteLine(numbers[3]);  // 輸出 99

// 字串處理的效能提升
var text = "Hello, World! Welcome to .NET";

// 傳統方式:每次 Substring 都會建立新字串(Heap 分配)
var hello = text.Substring(0, 5);     // 新字串 "Hello"
var world = text.Substring(7, 6);     // 新字串 "World!"

// Span 方式:零記憶體分配
ReadOnlySpan<char> textSpan = text.AsSpan();
// 直接指向原始字串的片段,不建立新字串
var helloSpan = textSpan.Slice(0, 5);  // 指向 "Hello"
var worldSpan = textSpan.Slice(7, 6);  // 指向 "World!"

// Memory<T>:可以存在 Heap 上的 Span(可以用於 async 方法)
// Span<T> 只能存在 Stack 上,不能用在 async 方法中
// Memory<T> 解決了這個限制
Memory<int> memory = numbers.AsMemory(3, 4);
// 需要操作時再轉成 Span
Span<int> fromMemory = memory.Span;

StringBuilder vs 字串串接

// 字串是不可變的(Immutable)!
// 每次用 + 串接都會建立新字串

// ❌ 糟糕的效能:每次迴圈都建立新字串物件
string result = ";
for (int i = 0; i < 10000; i++)
{
    // 每次 += 都會:1. 建立新字串 2. 複製舊內容 3. 加上新內容
    // 10000 次迴圈 = 10000 個暫時字串物件(GC 壓力大)
    result += $"第 {i} 行\n";
}
// 時間複雜度:O(n²),因為每次都要複製越來越長的字串

// ✅ 正確:使用 StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    // StringBuilder 內部維護一個可變的 char 陣列
    // 不需要每次都建立新字串
    sb.AppendLine($"第 {i} 行");
}
// 最後才轉成字串(只建立一次)
string result2 = sb.ToString();
// 時間複雜度:O(n),效能差距可達 100 倍以上!

// 小量串接(2-3 個)用 + 就好,不需要 StringBuilder
// 編譯器會自動優化成 string.Concat
var name = firstName + " " + lastName;  // 這樣沒問題

async 效能最佳實踐

// 1. 不要在 async 方法中做不必要的 await
// ❌ 不必要的 async/await(多了一層狀態機的開銷)
async Task<int> GetValueBadAsync()
{
    // 如果只是回傳另一個 Task,不需要 async/await
    return await _service.GetValueAsync();
}

// ✅ 直接回傳 Task(少一層狀態機)
Task<int> GetValueGoodAsync()
{
    // 直接回傳 Task,讓呼叫端去 await
    return _service.GetValueAsync();
}
// ⚠️ 注意:如果有 try-catch 或 using,還是需要 async/await

// 2. 善用 Task.WhenAll 做平行處理
// ❌ 循序執行:每個 await 都要等前一個完成
async Task<DashboardData> GetDashboardBadAsync()
{
    // 三個獨立的查詢,卻是循序執行
    var users = await _db.Users.CountAsync();           // 等 100ms
    var orders = await _db.Orders.CountAsync();         // 再等 100ms
    var products = await _db.Products.CountAsync();     // 再等 100ms
    // 總共 300ms!
    return new DashboardData(users, orders, products);
}

// ✅ 平行執行:三個查詢同時進行
async Task<DashboardData> GetDashboardGoodAsync()
{
    // 先啟動所有 Task(不 await)
    var usersTask = _db.Users.CountAsync();
    var ordersTask = _db.Orders.CountAsync();
    var productsTask = _db.Products.CountAsync();

    // 等待所有 Task 同時完成
    await Task.WhenAll(usersTask, ordersTask, productsTask);
    // 總共只要 100ms(取最慢的那個)

    return new DashboardData(
        await usersTask,    // 已經完成了,不會再等
        await ordersTask,
        await productsTask
    );
}

// 3. ConfigureAwait(false) 在程式庫中使用
// 在沒有 UI 的程式庫中,不需要回到原始的 SynchronizationContext
async Task<string> LibraryMethodAsync()
{
    // ConfigureAwait(false) 告訴執行時不需要回到原始的執行緒
    var data = await _httpClient.GetStringAsync("https://api.example.com")
        .ConfigureAwait(false);
    // 在 ASP.NET Core 中通常不需要(因為沒有 SynchronizationContext)
    // 但在撰寫 NuGet 套件時建議加上
    return data;
}

BenchmarkDotNet 基礎

// 安裝:dotnet add package BenchmarkDotNet

// BenchmarkDotNet 幫你精確測量程式碼的效能
// 它會自動:暖機、多次執行、統計分析、排除雜訊

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

// 標記記憶體分配診斷(可以看到 GC 分配量)
[MemoryDiagnoser]
public class StringBenchmarks
{
    // 設定測試參數
    [Params(100, 1000, 10000)]
    public int N;

    // 測試方法 1:字串串接
    [Benchmark]
    public string StringConcat()
    {
        // 用 + 串接字串
        var result = "";
        for (int i = 0; i < N; i++)
            result += "a";
        return result;
    }

    // 測試方法 2:StringBuilder
    [Benchmark]
    public string StringBuilderAppend()
    {
        // 用 StringBuilder
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++)
            sb.Append("a");
        return sb.ToString();
    }
}

// 在 Program.cs 執行基準測試
// 必須用 Release 模式執行:dotnet run -c Release
// var summary = BenchmarkRunner.Run<StringBenchmarks>();

// 輸出結果範例:
// |            Method |     N |         Mean |     Gen0 |   Allocated |
// |------------------ |------ |-------------:|---------:|------------:|
// |      StringConcat |   100 |     2.814 us |   3.6011 |    15,096 B |
// | StringBuilderApp. |   100 |     0.341 us |   0.0896 |       376 B |
// |      StringConcat | 10000 | 8,234.117 us | 848.9583 | 100,220 KB |
// | StringBuilderApp. | 10000 |    28.193 us |   1.9226 |    40,216 B |

🤔 我這樣寫為什麼會錯?

❌ 錯誤 1:不必要的裝箱(Boxing)

// 裝箱:把 Value Type 放進 Reference Type 的容器
// 這會在 Heap 上分配記憶體,造成 GC 壓力

// ❌ 錯誤:用 ArrayList(非泛型集合),每次 Add 都會裝箱
var list = new System.Collections.ArrayList();
for (int i = 0; i < 10000; i++)
{
    // int(Value Type)被裝箱成 object(Reference Type)
    // 每次都在 Heap 上分配一個新的物件!
    list.Add(i);  // 裝箱!
}
// 取出來還要拆箱
int value = (int)list[0];  // 拆箱!

// ✅ 正確:使用泛型集合,完全不需要裝箱
var genericList = new List<int>();
for (int i = 0; i < 10000; i++)
{
    // int 直接存入,不需要裝箱
    genericList.Add(i);  // 沒有裝箱!
}
// 取出來也不需要拆箱
int value2 = genericList[0];  // 直接取值!

// ❌ 另一個常見的裝箱場景:字串格式化
int count = 42;
// 舊式寫法會裝箱(count 被轉成 object)
string bad = string.Format("數量:{0}", count);  // 裝箱!
// ✅ 字串插值不會裝箱(編譯器會優化)
string good = $"數量:{count}";  // 不裝箱!

❌ 錯誤 2:大物件不斷進入 LOH

// ❌ 錯誤:在迴圈中不斷建立大陣列
for (int i = 0; i < 100; i++)
{
    // 超過 85,000 bytes 的物件會進入 LOH(Large Object Heap)
    // LOH 的回收成本非常高!
    var largeArray = new byte[100_000];  // 100KB,進入 LOH!
    // 處理完就丟掉...但 LOH 回收代價很高
    ProcessData(largeArray);
}

// ✅ 正確:重複使用陣列,或使用 ArrayPool
using System.Buffers;

for (int i = 0; i < 100; i++)
{
    // 從池中租用陣列(不需要每次都 new)
    var rentedArray = ArrayPool<byte>.Shared.Rent(100_000);
    try
    {
        // 使用租來的陣列
        ProcessData(rentedArray);
    }
    finally
    {
        // 用完歸還到池中(不會被 GC 回收,可以重複使用)
        ArrayPool<byte>.Shared.Return(rentedArray);
    }
}

❌ 錯誤 3:Sync over Async(在同步中呼叫非同步)

// ❌ 錯誤:用 .Result 或 .Wait() 強制同步等待
public string GetDataBad()
{
    // .Result 會阻塞執行緒,在 ASP.NET Core 中可能造成死鎖!
    var result = _httpClient.GetStringAsync("https://api.example.com").Result;
    return result;
}

// ❌ 錯誤:用 Task.Run 包裝 async 方法
public string GetDataAlsoBad()
{
    // Task.Run 會佔用一個 ThreadPool 執行緒去等待
    // 浪費資源,而且在高負載時會耗盡執行緒池
    return Task.Run(() => _httpClient.GetStringAsync("https://api.example.com")).Result;
}

// ✅ 正確:async all the way(一路 async 到底)
public async Task<string> GetDataGoodAsync()
{
    // 真正的非同步,不阻塞任何執行緒
    var result = await _httpClient.GetStringAsync("https://api.example.com");
    return result;
}
// 從 Controller 到 Service 到 Repository,全部都用 async/await
// 這是 ASP.NET Core 的最佳實踐

💡 重點整理

概念 說明
Value Type vs Reference Type Stack vs Heap,影響記憶體分配效能
GC 三代回收 Gen 0 最頻繁,Gen 2 最昂貴,減少物件分配可降低 GC 壓力
Span 零複製的陣列片段操作,大幅減少記憶體分配
StringBuilder 大量字串串接時必用,效能可差 100 倍以上
Task.WhenAll 獨立的 async 操作應該平行執行
BenchmarkDotNet 精確的效能測量工具,別用 Stopwatch 猜測
ArrayPool 重複使用大型陣列,避免 LOH 壓力

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊