記事

値型 vs 参照型 — スタック・ヒープと Boxing の隠れたコスト

値型 vs 参照型 — スタック・ヒープと Boxing の隠れたコスト
前提知識 — 先にこちらをご確認ください
TL;DR — 要点まとめ
  • 値型が「スタックに置かれる」という説明は半分しか正しくありません。フィールドの配置先は**そのフィールドを保持するコンテナ**が決定します。クラスフィールド内の値型はヒープに存在し、配列要素の値型もヒープに存在し、ラムダがキャプチャした値型もヒープに存在します
  • Boxing は「object に変換される瞬間」ではなく、**値型が参照契約 (object・非ジェネリックインターフェース) と出会う瞬間**に発生します。`string.Format`・`Dictionary`・`IEnumerable` に対する `foreach` まで、日常コードのあらゆる場所に潜んでいます
  • Boxing された値は元の値の**コピー**であるため、元の値を変更しても Box は変わりません。この非対称性がデバッグしにくいバグを生み出します
  • .NET 10 (Apple M4 Pro, Arm64 RyuJIT) での実測結果、値型の `Equals` は `IEquatable<T>` の実装有無によって**95倍以上の速度差**が生じます。Boxing は単純な GC の問題ではなく、**CPU パフォーマンスの問題**でもあります
Visitors

Hits

序論: 「スタック vs ヒープ」という説明が食い違う理由

C# の教科書の最初のページで、私たちはこう学びます。

“値型 (struct) はスタックに、参照型 (class) はヒープに格納されます。”

この文章は間違いではないのですが、実務で直面するほぼすべての反例を覆い隠してしまいます。クラスのフィールドとして int を宣言すると、その int はスタックではなくオブジェクトが存在するヒープ内に存在します。ラムダがローカルの struct 変数をキャプチャすると、その structヒープに置かれたクロージャ内に存在することになります。JIT が structレジスタのみで扱う場合、スタックにもヒープにも「格納」されません。

「スタック vs ヒープ」という言葉が登場するたびに注意すべきことがあります。「どのコンテナの中にあるか」が本質的な問いだということです。

この記事の目的は2つです。

  1. 値型と参照型を「格納場所」ではなく「コピーのルール」として再定義する
  2. 値型が Boxing される瞬間がどこなのか、なぜコストが高いのか、どう回避するかを具体的に示す

計測は .NET 10 上で BenchmarkDotNet により実測し、IL は RyuJIT が実際に出力するものをそのまま引用しています。ゲームプログラマが IL2CPP で遭遇する Boxing の落とし穴も最後のセクションでまとめます。


Part 1. 値型と参照型の真の違い

1.1 「スタック/ヒープ」ではなく「コピーのルール」

値型と参照型を分ける決定的な違いは、アロケーションされたときに起きることではなく、代入・受け渡し・比較されたときに起きることです。

  • 値型: 代入時に内容全体がコピーされます。新しい変数は独立したコピーを持ちます。
  • 参照型: 代入時にオブジェクトを指すポインタ (参照) のみコピーされます。元の変数とコピーは同じオブジェクトを参照します。
値型 vs 参照型 — コピーの意味 struct Position — 値型 a X=1, Y=2, Z=3 b = a X=1, Y=2, Z=3 (独立コピー) b.X = 999 ⇒ a.X = 1 (変化なし) b.X = 999 class Player — 参照型 a = 0x00AF b = 0x00AF ヒープ 0x00AF X=1, Y=2, Z=3 b.X = 999 ⇒ a.X も 999

「格納場所がどこか」は、このコピーセマンティクスの結果に過ぎません。値型はコピーが軽量であるべきなので主にスタック (またはインライン) に置かれ、参照型は寿命が不確定なのでヒープに置いて参照で管理されます。原因と結果を逆に覚えると、反例に直面したときに対処できなくなります。

1.2 同等性比較も異なります

コピーのルールが異なる以上、同等性の判定も異なります。

  • 値型の Equals: デフォルト実装はリフレクションでフィールドを1つずつ比較します (ValueType.Equals(object))。Boxing + リフレクションの二重コスト
  • 参照型の Equals: デフォルト実装は参照同一性 (ReferenceEquals) を確認します — 同じオブジェクトを指しているかどうかのみを判定

この違いは Part 5 のベンチマークで数値として確認します。値型を Dictionary のキーとして使ったり List.Contains で検索したりするとき、IEquatable<T> を直接実装しないと、比較のたびにBoxing とリフレクションの両方のコストを支払うことになります。

1.3 可変性の落とし穴

値型がコピーされるというルールから最も頻繁に発生するバグは、可変 struct をコレクションに入れた後で変更しようとする試みです。

1
2
3
4
5
6
7
8
9
10
11
12
/* 可変 struct の落とし穴 */
struct Counter
{
    public int Value;
    public void Increment() => Value++;
}

var list = new List<Counter> { new Counter() };
list[0].Increment();        /* コンパイルエラー: list[0] は値 (コピー) なので変更不可 */

var copy = list[0];
copy.Increment();            /* copy のみが変わり、リスト内の元の値はそのまま */

多くの C# スタイルガイドが struct は不変 (readonly struct) として使うよう推奨している理由がここにあります。可変 struct は「値」という本質とプログラマの直感が衝突し、不具合を生みます。このテーマは第4回の readonly structref struct で改めて取り上げます。


Part 2. 「値型はスタック」が間違っている3つのケース

教科書の一文を少し言い換えてみましょう。

“値型は自身を保持するコンテナと同じ場所に格納されます。”

この一文の方がはるかに正確です。ローカル変数として宣言された struct のみがスタックに置かれ、それ以外の場合はコンテナに従います。

2.1 クラスフィールド内の値型 → ヒープ

1
2
3
4
5
6
7
8
class Enemy
{
    public Vector3 Position;   /* 値型だがヒープに存在 */
    public int Hp;              /* 同じくヒープ */
}

var e = new Enemy();            /* e はヒープに Enemy オブジェクトをアロケート */
                                /* Position と Hp はそのオブジェクト内にインライン格納 */
class Enemy のメモリレイアウト スタック e (参照, 8バイト) ヒープ — Enemy オブジェクト ObjectHeader + MethodTable (16バイト) Position (struct Vector3) X, Y, Z — ヒープにインライン Hp — ヒープにインライン

PositionVector3 という値型ですが、Enemy という参照型の中にインラインされているため、ヒープ上に置かれます。ここで「値型はスタック」というルールは崩れます。

2.2 配列要素 → ヒープ

1
2
var positions = new Vector3[1000];
positions[0] = new Vector3(1, 2, 3);    /* 値 1000個がヒープ上の配列にインライン */

配列は参照型です (T[])。したがって配列要素はヒープ上の配列オブジェクト内にインライン格納されます。Vector3[1000]スタックバッファではなく、ヒープ上の 12KB の連続領域です。このヒープ上の連続レイアウトこそが、第3回で登場する Span<T> が活躍する基盤となります。

2.3 ラムダキャプチャ → ヒープ (クロージャ)

1
2
3
4
5
6
7
void Setup()
{
    var count = new Counter();              /* ローカル値型 */
    Action handler = () => count.Value++;   /* count をキャプチャ */
                                             /* コンパイラが秘密のクラスを生成し count をそこに格納 */
                                             /* 結果: count はヒープ上のクロージャオブジェクト内に */
}

ラムダがローカル変数をキャプチャすると、コンパイラはキャプチャされた変数をフィールドとして持つ秘密のクラス (display class) を生成します。そのクラスは当然参照型でありヒープにアロケートされるため、元はスタックにあったはずの struct もヒープに移動します。

これら3つのケースを理解すれば、「値型はスタック」が繰り返し食い違う理由が見えてきます。「自身を保持するコンテナと同じ場所」というルール1つの方がはるかに一貫しています。

2.4 JIT の最終反転 — Escape Analysis とスタックアロケーション

ここまではソースレベルのルールです。実際の実行時には JIT がさらに一段ひっくり返します。

.NET の RyuJIT は Escape Analysis で「このオブジェクトがメソッドの外に脱出するか」を判定します。脱出しない場合はヒープアロケーションを省略し、スタックにアロケートします。.NET 9 から本格導入されたこの最適化は .NET 10 でジェネリック・仮想呼び出し境界まで拡張されました。

つまりソースに new SomeClass() と書いても、そのオブジェクトがメソッド内でのみ使われ他の場所に漏れ出さなければ、実際にはスタックにアロケートされる可能性があります。逆に値型を Boxing した瞬間、Boxing されたオブジェクトは必ずヒープに行かなければなりません — 参照が脱出するためです。

「ソースコードだけを見てアロケーション場所を断定できない」というのが現代の .NET の現実です。信頼できるのは計測だけです。BenchmarkDotNet の [MemoryDiagnoser] が必須である理由がここにあります。


Part 3. Boxing メカニズム

値型と参照型の境界を強引に越えるときに何が起きるかを見ていきます。その出来事を Boxing と呼びます。

3.1 Boxing の定義

Boxing は値型のコピーを新しいヒープオブジェクトの中に包む操作です。逆は Unboxing — ヒープに Boxing された値を取り出してスタック (またはレジスタ) に持ってくる操作です。

Boxing — スタックの値がヒープのオブジェクトにコピーされる過程 ① スタックの値型 int i = 42 スタック, 4バイト ② object 参照が必要 object o = i; コンパイラが box 命令を挿入 ③ ヒープに Box 生成 ヒープ: Box<int> = 42 ヘッダ 16B + 値 4B ≈ 24B ④ Boxing 後に元の値を変更すると? i = 999 (スタック) 元の値のみ変わる Box<int> = 42 (ヒープ) Box はそのまま (コピー) 独立

図の要点は④ のステップです。Boxing された値は元の値と独立したコピーです。元の値を変更しても Box は変わらず、Box を変更しても (可能であれば) 元の値は変わりません。この非対称性が次のセクションで扱う微妙なバグの根源です。

3.2 IL レベルでの確認

C# が生成した IL には、Boxing が boxunbox.any という2つの命令として明示的に残ります。次の2つのメソッドをコンパイルすると、IL はこのようになります (.NET 10 Release ビルド基準)。

1
2
public static object BoxInt(int value) => value;
public static int UnboxInt(object boxed) => (int)boxed;
1
2
3
4
5
6
7
8
9
BoxInt:
  IL_0000: ldarg.0
  IL_0001: box [System.Runtime]System.Int32
  IL_0006: ret

UnboxInt:
  IL_0000: ldarg.0
  IL_0001: unbox.any [System.Runtime]System.Int32
  IL_0006: ret
  • box [T]: スタックの値を取り出してヒープに T 用の Box オブジェクトをアロケートし、値をコピーして格納した後、Box の参照をスタックに積みます
  • unbox.any [T]: ヒープの Box オブジェクトから T の値を取り出してスタックに積みます (型が異なる場合は InvalidCastException)

「object にキャストすると Boxing される」という説明は正しいですが、正確にどの IL 命令がそれを行うかを把握することで、Boxing が「おそらく起きる」ではなく「必ず起きる」と確信できます。

3.3 隠れた Boxing — Equals(object)

Boxing がより暗黙的に見える例をもう一つ示します。

1
2
3
4
5
6
public static bool CompareViaObject(int a, int b)
{
    object oa = a;
    object ob = b;
    return oa.Equals(ob);
}

IL を見ると Boxing が2回発生しています。

1
2
3
4
5
6
7
8
9
CompareViaObject:
  IL_0000: ldarg.0
  IL_0001: box [System.Runtime]System.Int32    /* a をヒープに Boxing */
  IL_0006: ldarg.1
  IL_0007: box [System.Runtime]System.Int32    /* b をヒープに Boxing */
  IL_000c: stloc.0
  IL_000d: ldloc.0
  IL_000e: callvirt instance bool [System.Runtime]System.Object::Equals(object)
  IL_0013: ret

2つの int を比較するだけで 24バイト × 2 = 48バイトのヒープアロケーションが発生します。さらに Equals(object) 呼び出し時に内部で Unboxing + 比較ルーティンが実行されるため、CPU コストも伴います。このコストは Part 5 のベンチマークで具体的な数値として再び登場します。

3.4 Boxing されたコピーは独立している

Boxing がコピーを生成するという事実を IL で確認します。

1
2
3
4
5
6
7
public static object MutateAfterBox()
{
    var p = new Point2D(1, 2);
    object boxed = p;
    p.X = 999;
    return boxed;           /* boxed.X は 1 か 999 か? */
}
1
2
3
4
5
6
7
IL_0000: ldloca.s 0                          /* &p (スタックアドレス) */
IL_0004: call Point2D::.ctor(int32, int32)   /* スタックの p を初期化 */
IL_0009: ldloc.0                             /* p の値をスタックトップへ */
IL_000a: box BoxingIL.Point2D                /* ヒープにコピーの Box を生成 */
IL_000f: ldloca.s 0                          /* &p (再びスタックアドレス) */
IL_0011: stfld int32 Point2D::X              /* スタックの p.X = 999 */
IL_001b: ret                                 /* Box (ヒープ) を返す */

p.X = 999 の代入はスタックの pにのみ作用します (ldloca.s 0 でスタックアドレスを取得)。ヒープの Box は box 命令の直後から完全に独立しているため、boxed.X は依然として 1 です。

このルールが微妙なバグを生み出します。

1
2
3
4
5
/* アンチパターン */
var state = new GameState();
RegisterInterface((IStatefulObject)state);   /* Boxing 発生 (struct state → interface) */
state.Score = 100;                           /* スタックの state のみ変わる */
                                              /* Register に渡した Box は Score=0 のまま */

インターフェースへのキャストは Boxing を引き起こし、その瞬間に値のコピーが独立します。struct をインターフェースコレクションに入れた瞬間、元の変更が反映されない「凍結されたコピー」が生まれます。この問題が第4回の readonly structref struct へと続きます。


Part 4. 日常コードの中の Boxing 落とし穴

Boxing は明示的な object キャストよりはるかに日常的な場所に潜んでいます。頻繁に遭遇する5つのパターンを整理します。

4.1 Dictionary<TKey, TValue> — キーが Equals(object) 経路を通るケース

Dictionary はキーの比較を EqualityComparer<TKey>.Default で処理します。TKeyIEquatable<TKey> を実装していれば Equals(TKey) の直接呼び出し — Boxing なし。実装していなければ ValueType.Equals(object) 経路 — Boxing + リフレクション基準の比較

リーダーボードキャッシュのように複数の enum を組み合わせた複合キーがよく登場するパターンです。同じキャッシュをフィルタ軸ごとに保持したい場合、enum 3〜4個をフィールドとして持つ struct キーが自然な選択です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* Unity/クライアントでよく使うリーダーボードキャッシュキー — 3軸 enum の組み合わせ */
public enum Region : byte { NA, EU, APAC, SA }
public enum Season : byte { Spring, Summer, Autumn, Winter }
public enum Mode : byte { Solo, Duo, Squad }

/* IEquatable なし — 検索のたびに ValueType.Equals(object) → Boxing + リフレクション */
public readonly struct LeaderboardKeyBad
{
    public readonly Region Region;
    public readonly Season Season;
    public readonly Mode Mode;
}

/* IEquatable + GetHashCode オーバーライド — Boxing を完全排除 */
public readonly struct LeaderboardKey : IEquatable<LeaderboardKey>
{
    public readonly Region Region;
    public readonly Season Season;
    public readonly Mode Mode;

    public LeaderboardKey(Region r, Season s, Mode m) { Region = r; Season = s; Mode = m; }

    public bool Equals(LeaderboardKey other) =>
        Region == other.Region && Season == other.Season && Mode == other.Mode;

    public override bool Equals(object obj) => obj is LeaderboardKey o && Equals(o);
    public override int GetHashCode() => HashCode.Combine((int)Region, (int)Season, (int)Mode);
}

/* 使用例 — 検索経路が完全に Boxing フリー */
Dictionary<LeaderboardKey, LeaderboardCache> _caches;
var key = new LeaderboardKey(Region.APAC, Season.Summer, Mode.Squad);
if (_caches.TryGetValue(key, out var cache)) { /* ... */ }

値型を Dictionary のキーとして使う場合は、IEquatable<T> の実装が基本です。struct フィールドを readonly にすると防御コピーも合わせて排除されます (第4回の readonly struct で詳述)。GetHashCode も一貫してオーバーライドする必要がありますが、HashCode.Combine がある以上、手動のシフト・XOR を書く理由はありません。

4.2 非ジェネリック IEnumerable に対する foreach

ジェネリック導入以前のコレクション (ArrayListHashtable) を foreach で反復すると、すべての要素が object として扱われ、反復のたびに Boxing・Unboxing が発生します。

1
2
3
var list = new ArrayList { 1, 2, 3 };
foreach (int i in list)          /* 反復ごとに unbox.any が発生 */
    Console.WriteLine(i);

ジェネリック List<int> に変えれば Unboxing がなくなり、JIT は int 専用のループをインライニングします。現代のコードに ArrayListHashtableQueue (非ジェネリック) を使う理由はありませんが、古いライブラリの境界でこれらのコレクションが渡ってくる場合は、一度にジェネリック版に昇格させるのが最も簡単な最適化です。

4.3 string.Format と補間文字列

1
2
3
4
5
6
Console.WriteLine(string.Format("Score: {0}, Time: {1}", score, time));
/* score, time が値型の場合は両方 Boxing (object[] で渡されるため) */

Console.WriteLine($"Score: {score}, Time: {time}");
/* C# 10 以前: string.Format と同等 → Boxing */
/* C# 10+: DefaultInterpolatedStringHandler がジェネリック Append<T> を使用 → Boxing なし */

C# 10 (.NET 6) から導入された補間文字列ハンドラー (DefaultInterpolatedStringHandler) は Boxing を完全に排除します。この機能はコンパイラバージョンに依存するため、LangVersion 設定を合わせるだけですべてのプロジェクトで自動的に恩恵を受けられます。ただし string.Format を直接呼び出した場合は引き続き object[] 経路を通るため、Boxing が残ります。

4.4 ログ・トレースにおける Boxing の連鎖

ロギングライブラリが params object[] を受け取ると、ログ1行あたり値型の数だけ Boxing が発生します。

1
logger.LogInformation("Player {PlayerId} scored {Score} at {Time}", playerId, score, time);

ASP.NET 系であれば Source Generator ベースの LoggerMessage を使えば Boxing が排除されます。

1
2
3
[LoggerMessage(Level = LogLevel.Information,
    Message = "Player {PlayerId} scored {Score} at {Time}")]
partial void LogPlayerScore(long playerId, int score, DateTime time);

Unity の場合は事情が異なります。 Debug.Log/Debug.LogFormat は内部的に string.Format 系を通るため、それ自体が Boxing + ヒープ文字列アロケーションの発生源です。

1
2
3
4
5
6
7
8
9
10
11
12
/* Unity — score が float の場合は Boxing、文字列も毎回アロケート */
Debug.LogFormat("Damage dealt: {0}", damageAmount);

/* Unity 2022 LTS 以下 — 補間文字列も string.Format にリライト → 同等のコスト */
Debug.Log($"Damage dealt: {damageAmount}");

/* Unity 6 (C# 11 LangVersion) — DefaultInterpolatedStringHandler 経路で Boxing 排除 */
Debug.Log($"Damage dealt: {damageAmount}");   /* 内部実装が異なる */

/* 最も安全 — リリースビルドでコンパイルアウト */
[System.Diagnostics.Conditional("UNITY_EDITOR"), System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
static void DevLog(string msg) => UnityEngine.Debug.Log(msg);

ゲームランタイムが毎秒数千行のログを出力する場合、この差が Profiler に現れる GC.Alloc を有意に削減します。リリースビルドでは [Conditional] 属性で呼び出し自体をコンパイラが除去するのが最善です。

4.5 List<T>.Contains — デフォルト比較子経路

List<T>.Contains は内部で EqualityComparer<T>.Default を使用します。TIEquatable<T> を実装していない値型の場合、やはり Boxing 経路を通ります。Dictionary キーのケースとまったく同じ話です。

より広く見ると、値型が「ジェネリックだが比較が必要なコレクション」の要素またはキーになるすべてのケースが該当します。HashSet<T>Dictionary<TKey,TValue>SortedSet<T>List<T>.Contains/IndexOf/Remove… すべてが対象です。

4.6 ゲームイベント構造体と in パラメータ

リアルタイムゲームではイベントバス (オブザーバー・メッセージパイプ等) を通じて毎秒数百〜数千のイベントが流れます。こうしたイベントを class で作るとその発生頻度だけ GC.Alloc が生じ、struct で作ってもフィールドが増えるとコピーコストが Boxing コストを超えることがあります。答えは readonly struct + in パラメータの組み合わせです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 入力イベント — 6フィールド、毎フレーム複数回発行される可能性あり */
public readonly struct DragEvent
{
    public readonly int PointerId;
    public readonly Vector2 ScreenPosition;
    public readonly Vector2 Delta;
    public readonly Vector2 TotalDelta;
    public readonly DragPhase Phase;        /* enum */
    public readonly float Timestamp;

    public DragEvent(int pid, Vector2 pos, Vector2 delta, Vector2 total, DragPhase phase, float ts)
    {
        PointerId = pid; ScreenPosition = pos; Delta = delta;
        TotalDelta = total; Phase = phase; Timestamp = ts;
    }
}

/* 値渡し — 6フィールド (約 36バイト) が呼び出しのたびにコピー */
public interface IDragSubscriber { void OnDrag(DragEvent evt); }

/* in 渡し — 参照で読み取り専用渡し、コピーなし */
public interface IDragSubscriber { void OnDrag(in DragEvent evt); }

イベント本体がないシグナルのみ必要な場合 (状態変更通知等) は 0バイトのマーカー構造体を使います。class シングルトンや static event を使わずにも型ベースのディスパッチが可能です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 0バイトマーカー — Boxing なしでもイベントバスで型識別のみでディスパッチ */
public readonly struct StaminaChangedSignal { }
public readonly struct MatchEndedSignal { }

/* データを持つマーカーは readonly struct で */
public readonly struct ItemStockChanged
{
    public readonly int ItemId;
    public readonly int NewStock;
    public ItemStockChanged(int id, int stock) { ItemId = id; NewStock = stock; }
}

/* MessagePipe のようなジェネリックイベントバスで使えば Boxing 経路が一切ない */
_bus.Publish(new StaminaChangedSignal());
_bus.Publish(new ItemStockChanged(itemId, stock));

このパターンの核心は、「値型 = 速い」ではなく「フィールドが増えるまでは値型、それ以降は in で参照渡し」という段階的な戦略です。in パラメータと readonly struct の相互作用は第4回でベンチマークとともに再び登場します。


Part 5. ベンチマーク — Boxing の実際のコスト

ここからは実測データです。.NET 10.0.100 + BenchmarkDotNet 0.14.0 で3つのシナリオを計測しました。環境は Apple M4 Pro, macOS 26.1, Arm64 RyuJIT AdvSIMD 基準で、計測コードはそれぞれ独立したゲームドメインの例 (リーダーボードキャッシュキー、リスト合算、3D座標比較) として記述しています。元の計測結果とソースはこの記事と同じコミットのベンチマークプロジェクトで確認できます。

5.1 Dictionary 検索 — IEquatable<T> vs デフォルト比較

シナリオ: Region × Season × Mode 3軸 enum をフィールドとして持つ readonly struct をキーとするリーダーボードキャッシュ。48個のキーをすべて検索するコストを計測。

メソッドMeanRatioAllocated
IEquatable 実装あり208.2 ns1.000 B
IEquatable なし (ValueType.Equals)988.8 ns4.793,456 B

IEquatable<T> を実装しない場合、Dictionary はキーを Boxing した上で ValueType.Equals(object) をリフレクション基準で呼び出します。結果は 48個のキー検索単位で4.79倍遅く、3.4KB のヒープアロケーションが追加で発生します。検索頻度が高くなるほど差は線形に拡大します。

5.2 List<int> vs ArrayList — 反復と Unboxing

シナリオ: 整数 10,000個をコレクションに格納して foreach で合算。

メソッドMeanRatioAllocated
List<int> foreach3.604 μs1.000 B
ArrayList foreach13.320 μs3.7048 B
ArrayList for ループ10.943 μs3.040 B

ArrayList の Boxing コストは値そのものにだけかかるわけではありませんforeachIEnumerator を経由することで列挙子の Box (48バイト) を追加でアロケートし、反復のたびに型チェックと Unboxing を行います。for ループに変えると列挙子の Boxing はなくなりますが要素の Unboxing は残るため、依然として3倍遅くなります。ジェネリックコレクションに切り替えることが ArrayList から脱出する唯一の正しい方向です。

5.3 値型の EqualsIEquatable<T> の効果

シナリオ: Point3 (X, Y, Z float) 1,000個の配列からターゲットと同じ要素を数える。3つのバリエーション。

メソッドMeanRatioAllocated
デフォルト ValueType.Equals(object)30,540 ns1.00160,096 B
override Equals (object 引数)2,883 ns0.0932,000 B
IEquatable<T> 実装321.4 ns0.010 B

3段階の差が値型パフォーマンスの本質をそのまま示しています。

  • デフォルト ValueType.Equals(object): リフレクションでフィールドを比較しながら引数はもちろん内部比較ルーティンまで Boxing — 1,000回の比較で 160KB アロケート
  • override Equals(object): リフレクションはなくなりますが引数が object のため Boxing はそのまま — 32KB (値 32B × 1,000)、パフォーマンスは 10倍改善
  • IEquatable<T>.Equals(T): Boxing が完全に消えることでデフォルト比で 95倍override 比でさらに 9倍の追加改善。JIT によるインライニングも可能に

10行程度の IEquatable<T> 実装が値型パフォーマンスの上限を決定します。

Boxing コスト 3シナリオ — 最適 (1.0x) 基準の相対実行時間・対数スケール

5.4 この数値が示すこと

3つのベンチマークに共通するパターンです。

  1. Boxing は GC の問題であると同時に CPU の問題です。ヒープアロケーションが増え、ポインタのデリファレンスが増え、callvirt が型チェックを伴うため、CPU サイクルも線形に増加します
  2. IEquatable<T> の実装は「後で最適化するもの」ではなく「値型のデフォルト」です。10行にも満たないコードが数倍のパフォーマンス差を生み出します
  3. コレクションに値型を入れる前に「このコレクションは要素・キーをどのように比較・ハッシュ・反復するか」を一度は確認する必要があります

Part 6. Unity / IL2CPP の視点 — ゲームプログラマの視点から

値型と Boxing は Unity ランタイム (Mono・IL2CPP) において CoreCLR とは異なる圧力を受けます。同じコードが同じ意味で動作しますが、ボトルネックの生じ方が異なります。

6.1 Boxing は IL2CPP でも依然として GC Alloc

IL2CPP は IL を C++ に変換してネイティブコンパイルしますが、GC は依然として保守的 GC (Boehm ベース) を使用します。Boxing されたオブジェクトはヒープアロケーションの対象であり、Unity Profiler では GC.Alloc として計上されます。

問題はモバイルの GC です。Unity のデフォルト GC は Stop-the-world 方式のため、コレクションがトリガーされるとフレーム全体が停止します。iOS・Android の実機でフレームスパイクが発生する典型的な原因が毎フレーム発生する Boxing です。

6.2 Unity でよく見られる Boxing パターン

Unity プロジェクトで Profiler → GC Alloc を開いたときに上位に頻繁に現れる原因と、それぞれの修正パターンです。

① ユーザー定義 struct キーに IEquatable<T> が未実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Profiler で Hashtable.Equals → ValueType.Equals → Boxing が検出される */
public struct EnemyKey { public int Id; public int Level; }
Dictionary<EnemyKey, EnemyStats> _stats;

/* IEquatable<T> + HashCode.Combine */
public readonly struct EnemyKey : IEquatable<EnemyKey>
{
    public readonly int Id;
    public readonly int Level;
    public EnemyKey(int id, int lv) { Id = id; Level = lv; }
    public bool Equals(EnemyKey other) => Id == other.Id && Level == other.Level;
    public override bool Equals(object obj) => obj is EnemyKey o && Equals(o);
    public override int GetHashCode() => HashCode.Combine(Id, Level);
}

UnityEvent<T> の引数に値型

UnityEvent<T> はインスペクタバインディングのために内部的に object[] 経路を混在させます。値型の引数を使うたびに Boxing が発生する可能性があります。

1
2
3
4
5
6
7
/* UnityEvent<int> に Invoke → Boxing 発生の可能性 */
public UnityEvent<int> OnScoreChanged;
OnScoreChanged.Invoke(currentScore);

/* ジェネリックイベントバス (MessagePipe, UniRx Subject<T> 等) — Boxing なし */
readonly Subject<int> _scoreChanged = new();
_scoreChanged.OnNext(currentScore);

Debug.LogFormat と高頻度ロギング

4.4 で扱ったパターンの Unity 版。リリースビルドでは [Conditional("UNITY_EDITOR")] ラッパーで呼び出し自体をコンパイルアウト。

④ 旧 Mono の foreach Boxing

Unity 2020 以前の Mono では、List<T>.Enumerator が構造体でも特定の経路で foreach が Boxing を引き起こすバージョンがありました。Unity 2022.3 LTS 以降の Mono ではほぼ解決されていますが、サードパーティコレクション (Unity 以外の dll として配布されるデータ構造等) では依然として発生する可能性があります。疑わしい場合は Deep Profile で System.Collections.IEnumerator.MoveNext のコールスタックを確認するのが早道です。

struct のインターフェースへのキャスト

3.4節で見た「凍結されたコピー」パターン。Unity では IEnumerableIComparable 等に値型を暗黙的にキャストするコードが残りやすく、その瞬間に Boxing が発生し、コピー独立性のバグも伴います。

6.3 Profiler での Boxing 探索方法

Unity Profiler で Boxing を発見するための実務手順です。

1
2
3
4
5
6
7
8
9
10
11
12
/* ProfilerMarker で疑わしい区間を分離 — Editor・Development Build で動作 */
using Unity.Profiling;

static readonly ProfilerMarker s_TickEnemies = new("Gameplay.TickEnemies");

void Update()
{
    using (s_TickEnemies.Auto())
    {
        foreach (var e in _enemies) e.Tick();
    }
}

このマーカーを付けると Profiler で該当区間の GC.Alloc バイト数とコールスタックを一緒に確認できます。次のオプションを有効にすると Boxing 探索の効率が大幅に向上します。

  • Deep Profile + GC.Alloc フィルタ — Boxing の直接原因となるコールスタックを追跡。ただし Deep Profile 自体のオーバーヘッドが大きいため、疑わしいシーン・機能にのみ限定して有効化
  • Allocation Callstacks — GC.Alloc がどのメソッドで何バイトアロケートしたかをスタック全体で記録。Unity 2020.2+ で提供
  • Memory Profiler パッケージ — スナップショット間の差分で特定フレームに発生した Boxing オブジェクトの種類 (Boxing された Int32Vector3 等) を直接確認可能
  • IL2CPP ビルド vs Mono ビルド の比較 — 同じ Boxing でもバックエンドごとにコスト差があるため、最適化の検証はデプロイ対象のバックエンドで直接計測して実際の効果を確認する必要があります

6.4 readonly structin パラメータ — 次の記事の予告

Unity コードで大きな struct (例: 6〜10フィールドのイベントデータ) を毎フレームハンドラーに渡す際、コピーコストが Boxing コストを超えることがあります。このコストは in パラメータ (.NET の参照で読み取り専用渡し) で排除します。

1
2
3
4
5
/* 値コピー — 6フィールドが呼び出しのたびにコピー */
void OnDrag(DragEventData data) { ... }

/* 参照渡し — コピーなし、読み取り専用 */
void OnDrag(in DragEventData data) { ... }

in は第4回で readonly structref struct とともに本格的に取り上げます。この記事の目標は Boxing を認識して IL レベルで確認するところまでです。


まとめ

この記事の要点を4つに整理します。

  1. 「値型 = スタック」ではなく「値型 = コンテナに従う」と覚えることで、反例に直面しても揺らぎません。クラスフィールド・配列要素・ラムダキャプチャにおいて値型はヒープに存在し、JIT の escape analysis はその逆方向にも作用します
  2. Boxing は box / unbox.any という IL 命令として明示的に発生します。C# ソースで「object にキャスト」といった曖昧な表現よりも、IL で実際に何が挿入されるかを基準に判断する方が確実です
  3. Boxing された値は元の値と独立したコピーであるという事実が微妙なバグを生み出します。元の変更が Box に反映されないため、struct をインターフェースコレクションに入れるパターンは凍結されたコピーを生み出します
  4. IEquatable<T> の実装は値型における選択肢ではなくデフォルトです。これ一つが DictionaryHashSetList.Contains のパフォーマンスを数倍単位で左右します

シリーズの接続: 次の記事の予告

この記事で残した2つの問題が次の記事へと続きます。

  • Boxing は避けたが struct 自体のコピーコスト: 第3回 Span<T> / ReadOnlySpan<T> で「コピーではなくビュー」として解決します
  • 長期保持が必要なバッファ、非同期境界: 第4回 Memory<T> + ArrayPool<T> でプーリングと非同期互換を扱います
  • コピーコスト自体をなくすパラダイム: 第5回 readonly struct / ref struct / in パラメータ

C# メモリシリーズ第1回はここまでです。


参考資料

1次ソース・公式ドキュメントおよび標準

ブログ・詳細分析

測定ツール

ゲームランタイムの視点

この記事は著者の CC BY 4.0 ライセンスの下で提供されています。