程式語言 · 泛型

泛型在執行時長什麼樣:值型別各翻一份,參考型別共用一份(.NET JIT,附 Go 對照)

Chance Lu · 學習文章 · 2026-11-04 20:51
Chance Lu

你寫了一個小小的 Shelf<T>(一個放東西的架子),程式裡用了四次:Shelf<int>、Shelf<double>、Shelf<string>、Shelf<object>。原始碼只有一份。那 CPU 真正在跑的程式碼,有幾份?

直覺會猜一份或四份。實際是三份:int 一份、double 一份,string 跟 object 合用一份。「動手做」會讓你親眼看到。

一句話:值型別每種大小不同,所以每種各做一份機器碼;參考型別的格子裡都是一樣大的地址,所以共用一份。

第一步:CPU 跑的不是你寫的 C#

dotnet build 產出的不是 CPU 能直接執行的指令,而是一份「中間碼」(給 .NET 看的通用指令)。CPU 只看得懂機器碼,中間碼總得被翻譯。這一步認識負責翻譯的人。

它叫 JIT(just-in-time,「臨時才翻」的編譯器)。程式跑起來後,某個方法第一次被呼叫,JIT 才把它翻成機器碼。翻好的留在記憶體,下次直接用。

你寫的 C#Shelf<T>,一份 編譯 中間碼還是一份,T 還空著 JIT 翻譯 機器碼CPU 直接跑的指令 JIT:方法第一次被呼叫時才翻;翻好的留著,之後直接用

圖 A:泛型在中間碼裡只有一份,T 還沒決定(點圖可放大)

一句話:要幾份機器碼,是 JIT 翻譯時才決定的。

第二步:值型別,每種各翻一份

機器碼是很具體的指令,像「從這裡搬 4 個 bytes」。但 T 是 int 時一格 4 bytes,是 double 時一格 8 bytes,同一段指令沒辦法兩種都對。這一步看 JIT 怎麼解決。

先認識一個詞:封閉型別,就是 T 已經填好的具體型別,例如 Shelf<int>。Shelf<T> 像模具,Shelf<int> 是用模具做出來的成品。

JIT 第一次碰到 Shelf<int>,就把 T 換成 int 翻一份專用的;碰到 Shelf<double>,再翻另一份。之後所有 Shelf<int> 都用同一份。

Shelf<int> 1 2 3 4 每格 4 bytes,int 直接放在格子裡 Shelf<double> 1.5 2.5 3.5 每格 8 bytes 要搬幾個 bytes 不一樣 → 機器碼得各翻一份

圖 B:同一個 Shelf<T>,T 不同,格子大小就不同(點圖可放大)

這也是上一篇〈泛型與裝箱〉留下的答案:int 版機器碼知道格子就是 int,所以 int 直接放進陣列,不用開盒子。

一句話:值型別大小各不同,所以各翻一份;專用版裡值直接放,不用裝箱。

第三步:參考型別,大家共用一份

那 string、object、你寫的 class 呢?一個程式的 class 可能上百個,每個都翻一份太浪費。這一步看 JIT 怎麼省。

參考型別的格子裡放的是地址,不管指向 string 還是你的 class,地址都一樣大(64 位元是 8 bytes)。搬地址的指令一模一樣,所以 JIT 只翻一份,大家共用。

這份共用版叫 __Canon 版(canonical,「標準版」的意思)。T 只要是參考型別,都用它。

程式用到的型別 JIT 第一次碰到時才翻 記憶體裡的機器碼 Shelf<int> Shelf<double> Shelf<string> Shelf<object> int 版 double 版 __Canon 版所有參考型別共用 ① 第一次用 Shelf<int>:JIT 翻出 int 專用版格子每格 4 bytes,int 直接放 ② 第一次用 Shelf<double>:大小不同,再翻一份 double 版格子每格 8 bytes ③ 第一次用 Shelf<string>:翻出 __Canon 版格子裡放的是地址,每格 8 bytes ④ 用到 Shelf<object>:不再翻,直接共用 __Canon 版一樣是地址、一樣大,指令完全相同

圖 C:四個封閉型別,JIT 只翻了三份(動畫會循環,點圖可放大)

那共用的程式碼裡寫 typeof(T),怎麼知道這次是 string 還是 object?執行時會另外帶著一份「這次 T 是誰」的資訊(官方叫 generic dictionary),需要時去查。(這裡我簡化了,細節見參考來源的 runtime 設計文件。)

一句話:地址都一樣大,所以參考型別只翻一份 __Canon 版。

第四步:程式碼共用,型別還是各自獨立

Shelf<string> 跟 Shelf<object> 用同一份機器碼。那它們是同一個型別嗎?泛型類別的 static 欄位也只有一份嗎?

共用的只有機器碼。.NET 執行時替每個封閉型別各準備一份「型別資料」,記著它叫什麼、T 是誰。static 欄位跟著這份資料走,所以每個封閉型別各有一份,Microsoft 文件也這樣寫。

型別資料:每個封閉型別各一份 機器碼:可以共用 Counter<int>static Count = 2 Counter<string>static Count = 1 Counter<object>static Count = 0 int 版 __Canon 版string、object 共用

圖 D:左邊三份資料各自獨立,右邊只有兩份機器碼(點圖可放大)

一句話:機器碼可以共用,型別不會合併。static 欄位屬於型別,所以每個封閉型別各有一份。

常見的坑

名詞白話

名詞白話
JIT程式跑起來後,方法第一次被呼叫時才把它翻成機器碼的翻譯員
封閉型別T 已經填好的具體型別,例如 List<int>
__Canon 版所有參考型別共用的那一份機器碼
GC shape(Go)Go 依「記憶體長相」分組,同一組共用一份機器碼

動手做

先開一個練習資料夾:

mkdir lab && cd lab && dotnet new console

每個練習都是把 Program.cs 整個換掉再執行。先猜、再跑、再對答案。輸出是在 .NET 10 實際跑出來的。

練習 1:偷看 JIT 翻了幾份

建檔把 Program.cs 換成:

var a = new Shelf<int>();    a.Put(1);
var b = new Shelf<double>(); b.Put(1.5);
var c = new Shelf<string>(); c.Put("hi");
var d = new Shelf<object>(); d.Put(new object());

class Shelf<T>
{
    private T[] _items = new T[4];
    private int _count;
    public void Put(T item) => _items[_count++] = item;
}

先猜四個封閉型別,Put 會被翻成幾份機器碼?

執行設環境變數 DOTNET_JitDisasmSummary=1,JIT 每翻一個方法就印一行(macOS / Linux 寫法):

DOTNET_JitDisasmSummary=1 dotnet run -c Release | grep Put
   3: JIT compiled Shelf`1[int]:Put(int) [Tier0, IL size=30, code size=116]
   5: JIT compiled Shelf`1[double]:Put(double) [Tier0, IL size=30, code size=116]
   7: JIT compiled Shelf`1[System.__Canon]:Put(System.__Canon) [Tier0, IL size=30, code size=108]

你會看到Put 只出現三次:int、double、__Canon,沒有 string 版和 object 版(`1 表示有 1 個型別參數;Tier0 等標記先忽略)。這就是圖 C。

練習 2:static 欄位有幾份?

建檔把 Program.cs 換成:

Counter<int>.Count++;
Counter<int>.Count++;
Counter<string>.Count++;

Console.WriteLine($"Counter<int>.Count    = {Counter<int>.Count}");
Console.WriteLine($"Counter<string>.Count = {Counter<string>.Count}");
Console.WriteLine($"Counter<object>.Count = {Counter<object>.Count}");

class Counter<T>
{
    public static int Count;
}

先猜string 和 object 共用機器碼,那 Counter<object>.Count 會是 1 嗎?

執行dotnet run:

Counter<int>.Count    = 2
Counter<string>.Count = 1
Counter<object>.Count = 0

這證明每個封閉型別各有一份 Count(圖 D)。

練習 3:共用程式碼,還認得出 T 嗎?

建檔把 Program.cs 換成:

Console.WriteLine(typeof(List<int>) == typeof(List<double>));
Console.WriteLine(typeof(List<string>) == typeof(List<object>));
Console.WriteLine(WhoAmI<string>());
Console.WriteLine(WhoAmI<object>());

static string WhoAmI<T>() => typeof(T).Name;

先猜第二行是 True 嗎?WhoAmI 兩次跑同一份 __Canon 版,印出來一樣嗎?

執行dotnet run:

False
False
String
Object

這證明型別永遠分開;共用的程式碼也能靠隨身那份資訊,認出這次的 T 是誰。

Go 對照

Go 1.18 的泛型做法很像,叫 GC shape stenciling:把型別依記憶體長相(GC shape)分組,同一組只產一份機器碼,再用隱藏參數 dictionary 告訴它真正的型別。

C#(.NET)Go
什麼時候產機器碼執行時,JIT 第一次用到編譯時
怎麼分組值型別每種一份;參考型別合成一份底層型別相同的同一組;所有指標型別同一組
怎麼知道 T 是誰執行時帶著的型別資訊隱藏參數 dictionary

想法一樣:長得一樣就共用。但 Go 的 string 不是指標,不會跟 *User 同組。

✔ 過關標準

能不看筆記回答下面 4 題,這篇就過了。

  1. 為什麼 List<int> 不用裝箱?跟 JIT 有什麼關係?看答案收合
    JIT 替 List<int> 翻了一份 int 專用的機器碼,它知道格子就是 int,值直接放進陣列。
    • int、double 格子大小不同,指令不同,所以值型別每種各翻一份。
  2. List<string> 和 List<object> 跑的是同一份機器碼嗎?為什麼可以?看答案收合
    是,兩個都用 __Canon 版。
    • 格子裡都是一樣大的地址,指令完全相同。
  3. Counter<string> 和 Counter<object> 共用機器碼,為什麼 static Count 還是各一份?看答案收合
    共用的只有機器碼;每個封閉型別各有自己的型別資料,static 欄位跟著型別走。
    • 想讓所有 T 共用,要放到非泛型的類別。
  4. Go 的 GC shape stenciling 跟 .NET 哪裡像、哪裡不同?看答案收合
    像:都是「記憶體長得一樣就共用一份」,指標/參考型別合成一份。
    • 不同:Go 編譯時就產好;.NET 執行時第一次用到才翻。

參考來源