C#のボックス化(boxing)は、intやstructなどの値型をobject型やインターフェイス型として扱ったときに、値をマネージドヒープ上の新しいオブジェクトへコピーする変換です。キャストを書かなくても起きるため、ArrayListへの追加やstructのEquals、string.Formatの引数といった普通のコードでも、ボックス化による割り当てが発生します。今回の計測では、ArrayListへのint追加が1回24バイト、IEquatable<T>を実装していないstructのEqualsが1回48バイトで、string.Formatではボックスに加えて文字列の割り当ても出ました。この記事では、起きる場面ごとの割り当て量を.NET 10.0.12で実測した結果をもとに、どこで起き、どう避けるか、ボックス化解除で何が例外になるかを整理します。
まとめ
- ボックス化は値型を
object型・インターフェイス型へ変換する処理で、値はヒープ上の新しいオブジェクトへコピーされます。x64ではintやlongの1回が24バイト、decimalやGuidは32バイトでした(.NET 10.0.12で実測)。 - 起きる主な場面は、
ArrayListなど非ジェネリックコレクション、object型の引数、インターフェイス型への代入、IEquatable<T>を実装していないstructのEqualsとDictionaryのキー、string.Formatの引数です。 - 回避の基本はジェネリックです。
List<int>、ジェネリック引数T、制約where T : IComparable<T>はいずれも割り当て0バイトでした。structはIEquatable<T>を実装するかreadonly record structにします。 - ボックス化解除(unboxing)は割り当てを伴いません。非Nullable値型への変換では、
nullならNullReferenceException、型違いならInvalidCastExceptionです。isパターンで確かめてから取り出します。 - .NET 9以降のJITは、メソッドの外へ出ないと証明できたボックスをスタックに置けます。ただし条件付きの最適化なので、ホットパスでは頼らずに書き方で避けるのが確実です。
ボックス化とボックス化解除の仕組み
C#の型は、値そのものを持つ値型(int・double・bool・enum・struct)と、オブジェクトへの参照を持つ参照型(class・string・配列など)に分かれます。すべての型は最終的にobject(System.Object)から派生しているため、値型もobject型の変数へ代入できます。このとき参照型の変数が指せるように、ランタイムがヒープにオブジェクトを確保して値をコピーします。これがボックス化です。
int i = 123;
object o = i; // ボックス化(IL では box 命令)
int j = (int)o; // ボックス化解除(IL では unbox.any 命令)
ボックス化は暗黙に行われ、ボックス化解除には明示的なキャストが要ります。ボックスの中身は元の変数とは別のコピーです。インターフェイス経由で値を書き換えても、元のstructには反映されません。
var c = new Counter();
ICounter ic = c; // ここでボックス化(コピー)
ic.Increment(); ic.Increment();
Console.WriteLine($"{c.Value} {ic.Value}"); // 出力: 0 2
interface ICounter { int Value { get; } void Increment(); }
struct Counter : ICounter
{
public int Value { get; private set; }
public void Increment() => Value++;
}
「値型はスタック、参照型はヒープ」という説明をよく見かけますが、正確には値型は変数が置かれた場所に値を直接持ちます。値型のローカル変数も、常にスタックに置かれるとは限りません。JITがレジスタで扱う場合や、ラムダに捕捉されて生成クラスのフィールドになる場合があります。クラスのインスタンスフィールドでは、そのオブジェクト内に値を保持します。ボックス化の本質は「スタックからヒープへの移動」ではなく、「参照で指せる独立したオブジェクトを新しく作ること」にあります。
ボックス化が起きる場面と割り当て量(.NET 10.0.12で実測)
同じ処理を1000回実行したときのヒープ割り当てを、GC.GetAllocatedBytesForCurrentThread()で測りました。環境は.NET SDK 10.0.401(ランタイム10.0.12・LTS)、osx-x64、Releaseビルドで、JITの段階コンパイルが最適化済みコードへ切り替わるまでウォームアップしてから計測しています。入力は0〜999のint、IEquatable未実装のstructはintフィールド2個、Dictionary検索は登録済みキーの検索です。object引数・ジェネリック引数・インターフェイス経由の比較は、呼び出し先を[MethodImpl(MethodImplOptions.NoInlining)]でインライン化させない条件にしています。
| コード | 1000回の割り当て | ヒープ上のボックス数/回 |
|---|---|---|
ArrayList.Add(i) |
24,000 B | 1 |
List<int>.Add(i) |
0 B | 0 |
object引数のメソッド |
24,000 B | 1 |
ジェネリック引数Tのメソッド |
0 B | 0 |
IComparable c = i; c.CompareTo(5) |
24,000 B | 1 |
where T : IComparable<T>で比較 |
0 B | 0 |
struct のEquals(IEquatable未実装) |
48,000 B | 2 |
struct のEquals(IEquatable実装) |
0 B | 0 |
Dictionary検索(キーがIEquatable未実装) |
72,000 B | 3 |
Dictionary検索(IEquatable実装 / record struct) |
0 B | 0 |
struct のGetHashCode(未オーバーライド) |
24,000 B | 1 |
string.Format("{0}", i) |
55,920 B | 1+文字列 |
$"{i}" |
31,920 B | 0+文字列 |
Enum.HasFlag |
0 B | 0 |
(int)o(ボックス化解除) |
0 B | 0 |
List<int>はnew List<int>(1000)の初期確保(List本体32バイトと内部配列4,024バイト、合計4,056バイト)を除いたAddの分です。1個のボックスはint・long・byteで24バイト、16バイトの値を持つdecimalとGuidで32バイトでした。以下、場面ごとに理由を見ていきます。
非ジェネリックコレクションとobject型引数でのボックス化
ArrayListやHashtableは要素をobjectとして保持するので、Add(42)の時点でボックス化されます。自作メソッドでも、引数をobject型にすると呼び出し側で同じことが起きます。List<T>やジェネリックメソッドは値型ごとに専用のコードがJITで生成され、値型のまま受け渡されるため割り当てが出ません。
インターフェイス型への代入とジェネリック制約
intはIComparableを実装していますが、IComparable c = i;と書くとインターフェイス型の変数が参照を持つ必要があるためボックス化します(1000回で24,000バイト)。同じ比較をstatic int Compare<T>(T x, T y) where T : IComparable<T> => x.CompareTo(y);と書けば0バイトです。インターフェイスの型としての役割はインターフェースとは?意味と種類・コードと通信の契約を実装目線で解説で扱っています。
structの等価比較とDictionaryのキー
最も気づきにくいのがここです。EqualsをオーバーライドしていないstructはSystem.ValueTypeの既定実装を使います。Microsoft Learnは、この既定実装が「using reflection to compare the values of every field in the type」(型の全フィールドをリフレクションで比べる)と説明しています。引数がobject型なので比較対象もボックス化され、実測では1回の比較で2個(48バイト)でした。
Dictionary<TKey, TValue>はEqualityComparer<T>.Defaultでキーを比べます。キーがIEquatable<T>を実装していなければObject.EqualsとObject.GetHashCodeを使う比較器になるため、検索1回でGetHashCodeとEqualsの分として3個(72バイト)のボックスが出ました。structを既定の比較器のまま辞書のキーにするなら、IEquatable<T>の実装とGetHashCodeのオーバーライドを基本にしてください。IEquatable<T>だけ実装してGetHashCodeを放置すると、ハッシュ計算の側で割り当てが残ります(実測で検索1000回あたり24,000バイト)。型を変更できない場合は、Dictionaryのコンストラクターへ専用のIEqualityComparer<T>を渡す方法もあります。リフレクションそのものの仕組みはリフレクションとは?実行時にクラス情報を操る仕組みを言語横断で解説にまとめています。
string.Formatと補間文字列のボックス化・割り当て量の違い
string.Format("{0}", i)は引数をobjectで受けるため、文字列の生成に加えてボックスが1個出ます。C# 10以降で.NET 6以上を対象にすると、補間文字列$"{i}"はDefaultInterpolatedStringHandlerへ変換され、ジェネリックなAppendFormatted<T>で値を追記します。.NET公式ブログは「value types will no longer be boxed in order to be appended」と説明しています。実測の差は1000回で24,000バイトで、ちょうどボックス1000個分でした。
見えにくい場面:インターフェイス経由のforeachとEnum.HasFlag
List<int>をIEnumerable<int>型の引数で受けてforeachすると、構造体の列挙子List<int>.Enumeratorがインターフェイス型として返るためボックス化します。.NET 10.0.12の既定設定では0バイトでしたが、JITのオブジェクトのスタック割り当てをDOTNET_JitObjectStackAllocation=0で止めると1000回で40,000バイトに戻りました。JITが消しているだけで、コード上はボックス化が起きています。
Enum.HasFlagはSystem.Enumのメソッドで引数がEnum型のため、かつては呼ぶたびにボックス化していました。.NET Core 2.1でJITの組み込み処理(intrinsic)になり、公式ベンチマークの割り当ては48バイトから0バイトになっています。今回の実測でも最適化済みコードでは0バイトでした。ただしウォームアップを5回にとどめた計測では1000回で48,000バイトと出ており、JITの最適化が済むまでは割り当てが残ります。
ボックス化のコストが効いてくる条件
Microsoft Learnの.NET Framework向け「パフォーマンスのヒント」は、値型のボックス化が単純な参照代入の「up to 20 times longer」(最大20倍)かかり、ボックス化解除のキャストは代入の「four times as long」(4倍)かかりうると書いています。ただし1回あたりは短い処理で、問題になるのは回数です。1回24バイトでも、毎秒100万回通るループなら毎秒24MBのごみを生み、GCの頻度が上がります。
逆に、画面の初期化や設定の読み込みのように数回しか通らないコードでは、ボックス化の割り当てを測定できても、処理全体への影響は小さいことがあります。ボックス化を気にするのは、プロファイラやベンチマークで割り当てが目立つと確認できたホットパスに絞るのが合理的です。可読性を落としてまで全箇所を潰す必要はありません。ボックス化解除自体は割り当てを伴わず、実測でも0バイトでした。
.NET 9・.NET 10のJITによるスタック割り当て
.NET 9でJITのオブジェクトのスタック割り当て(object stack allocation)が始まり、参照が現在のメソッドの外へ出ないと証明できる限られたケースで、ボックスをヒープではなくスタックに置けるようになりました。.NET 10ではエスケープ解析の適用範囲が広がっています。前節のIEnumerable<int>の列挙子が0バイトになったのはこの効果です。
ただし、ボックスをobject引数でインライン化されないメソッドへ渡すと参照が外へ出るため、.NET 10.0.12でも24,000バイト/1000回のままでした。段階コンパイルを無効にして動的PGOが働かない構成(DOTNET_TieredCompilation=0)では、列挙子のボックスも40,000バイトで残りました。JITの最適化は条件がそろったときの上乗せで、コードの書き方で避けるのが前提です。.NET 10全体の変更点は.NET 10(LTS)とは?サポート期限・新機能・C# 14と.NET 8/9からの移行を解説にあります。
ボックス化を避ける書き方
ジェネリックと制約によるボックス化の回避
非ジェネリックコレクションはList<T>・Dictionary<TKey, TValue>へ置き換えます。objectやインターフェイス型の引数は、型パラメータと制約に書き換えます。制約を付ければ、インターフェイスのメソッドを値型のまま呼べます。
// ボックス化する:インターフェイス型で受ける
static int CompareBoxed(int x, int y) { IComparable c = x; return c.CompareTo(y); }
// ボックス化しない:制約付きジェネリックで受ける
static int Compare<T>(T x, T y) where T : IComparable<T> => x.CompareTo(y);
LINQの中間処理でも割り当ては出ます。割り当てそのものを抑えたい場合の選択肢として、ZLinqとは?C#のゼロアロケーションLINQ|.NET・Unityでの導入と性能・制約も参考になります。
structの等価比較:IEquatable<T>の実装
Microsoft LearnのObject.Equalsのガイドラインは、値型にIEquatable<T>を実装するよう勧め、「Calling the strongly typed IEquatable<T>.Equals method avoids boxing the obj argument.」と書いています。Equals(object)とGetHashCodeも合わせてオーバーライドし、==と!=も用意します。
readonly struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) { X = x; Y = y; }
public bool Equals(Point other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is Point p && Equals(p);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Point a, Point b) => a.Equals(b);
public static bool operator !=(Point a, Point b) => !a.Equals(b);
}
手書きが面倒ならreadonly record struct Point(int X, int Y);で足ります。コンパイラがIEquatable<Point>の実装とEquals・GetHashCode・==を生成し、実測でもtypeof(IEquatable<Point>).IsAssignableFrom(typeof(Point))はTrue、Dictionaryの検索は0バイトでした。
文字列の組み立ては補間文字列かToString
ログやメッセージの組み立ては、string.Formatやstring.Concat(object, object)ではなく補間文字列を使います。単独の値を文字列にするだけならi.ToString()が最も少なく、実測で1000回22,400バイト(文字列本体のみ)でした。
ボックス化解除の例外とNullable<T>の扱い
ボックス化解除時のInvalidCastExceptionとNullReferenceExceptionの発生条件
ボックス化解除は、ボックスの中身と同じ値型へのキャストだけが成功します。intを包んだボックスをlongで取り出すと、数値としては変換できそうでもInvalidCastExceptionです。nullを非Nullable値型へ取り出すとNullReferenceExceptionになります。Nullable値型への変換では例外にならず、nullになります。ECMA-335(CLIの仕様)のunbox命令の規定どおりで、原因が違うので例外の種類も違います。
// 以下は各変換を個別に試す例です。例外が出る行を実行すると、後続の行には進みません。
object boxed = 42;
long a = (long)boxed; // InvalidCastException
long b = (long)(int)boxed; // OK:int で取り出してから変換する
object? nothing = null;
int c = (int)nothing!; // NullReferenceException
object day = DayOfWeek.Friday;
int d = (int)day; // OK:5(enum は基になる整数型で取り出せる)
例外の例外として、enumのボックスは基になる整数型で取り出せます。ECMA-335が、ボックス化解除したenumは基になる型と自由に相互変換できると定めているためです(実測でも5が返りました)。
ボックス化解除前のisパターンによる型確認
中身の型が確実でないobjectは、キャストではなくisパターンで型を確かめながら取り出します。型が合わなければfalseになるだけで、例外は出ません。
if (boxed is int value)
{
Console.WriteLine(value); // 42
}
ボックス同士の比較も注意点です。object o1 = 1, o2 = 1;のとき、o1 == o2は参照の比較なのでFalse、o1.Equals(o2)は値の比較なのでTrueでした。
Nullable<T>のボックス化
int?(Nullable<int>)は特別扱いされます。Microsoft Learnによると、HasValueがfalseなら「the boxing operation returns the null reference」、つまりボックスは作られずnull参照になります。値がある場合はNullable<int>ではなく中のintがボックス化されます。実測でもobject o = (int?)5;のo.GetType()はSystem.Int32でした。このためint?から作ったボックスは、intでもint?でも取り出せます。
自分のコードでボックス化を見つける方法
まずは疑わしい処理の前後で割り当て量を測ります。今回の表もこの方法で作りました。
static long Measure(Action action)
{
for (int r = 0; r < 3; r++)
{
for (int w = 0; w < 60; w++) action(); // 最適化済みコードへ切り替わるまで回す
Thread.Sleep(300);
}
long before = GC.GetAllocatedBytesForCurrentThread();
action();
return GC.GetAllocatedBytesForCurrentThread() - before;
}
ウォームアップが足りないと、最適化前のコードの割り当てを拾います。5回だけ回して測ったときは、Enum.HasFlagが48,000バイト、IComparable経由の比較が48,000バイト(十分に回した後は24,000バイト)と、本番と違う数字になりました。継続的に比べるならBenchmarkDotNet(2026年9月29日確認時点のNuGet最新安定版は0.15.8)に[MemoryDiagnoser]を付け、結果のAllocated列を見ます。ベンチマークの組み方と落とし穴はマイクロベンチマークとは何か?基本概念と重要性を徹底解説、メリット・デメリットや活用例も紹介で扱っています。
コンパイル結果のILにbox命令があるかを見る方法もあります。ただし、前節のとおりJITが後から消すボックスもあるので、「ILにboxがある=実行時に割り当てがある」とは限りません。判断は実測を優先してください。
よくある質問
ボックス化とボックス化解除の違いは何ですか?
ボックス化は値型をobject型やインターフェイス型へ変換し、ヒープ上のオブジェクトに値をコピーする処理で、暗黙に起きます。ボックス化解除はその逆で、int j = (int)o;のように明示的なキャストで値を取り出します。割り当てが出るのはボックス化の側だけです。
C#のobject型とは何ですか?
objectはSystem.Objectの別名で、C#のすべての型の基底型です。参照型なので、値型をobject型の変数に入れるとボックス化が起きます。何でも入る型として便利ですが、値型を扱う場面ではジェネリックに置き換えるのが基本です。
List<int>にintを追加するとボックス化しますか?
しません。List<int>は内部のint[]に値のまま格納し、実測でもAddの割り当ては0バイトでした。ボックス化するのは要素をobjectで持つArrayListで、1回24バイトです。
record structならIEquatable<T>を自分で書かなくてよいですか?
はい。record structはコンパイラがIEquatable<T>とEquals・GetHashCode・==を生成します。今回のintフィールド2個のrecord structでは、Dictionary検索の割り当ては0バイトでした。ただし、自動生成される比較・ハッシュ計算は各フィールドの既定比較器を使うため、フィールドの型によっては割り当てが発生します。普通のstructをキーにする場合だけ、自分で実装が必要です。
Unityでもボックス化は同じように起きますか?
ボックス化が起きる場面はC#とCLIの仕様で決まるので、Unityでも同じコードで起きます。ただしUnityはMonoまたはIL2CPPで動き、この記事の数値を測ったCoreCLR(.NET 10)とはJITもGCも違います。.NET 10のスタック割り当てのような最適化は期待せず、ジェネリックとIEquatable<T>で避けておくのが確実です。