C#のList<T>の内部:Capacityの増え方・コレクション式・AsSpanを実測

C#のList<T>はAddのたびに内部配列をどう伸ばすのか。.NET 10でCapacityの変化を実際に出力し、初期容量の有無による時間と割り当ての差、コレクション式、CollectionsMarshal.AsSpanの落とし穴まで確認。

2026年11月改訂: この記事は2021年に韓国語で書いた「ジェネリックコレクション」の講座を、日本語向けに書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、List<T> の例は変数名の大文字・小文字が混ざっていてコンパイルできず(error CS0103 が7件、CS0030 が1件)、Queue<T>・Stack<T> の例は Colsole の打ち間違いで失敗しました(CS0103)。見出しの Dictionary<T> も、実際は型パラメーターが2つの Dictionary<TKey, TValue> です。2021年版は「旧式コレクションのジェネリック版」を並べるだけだったので、その内容は「コレクション入門」に移し、この記事は List<T> が中でどう動いているかに絞って書き直しました。

List<T> は毎日使っているけれど、Capacity、new List<T>(n)、CollectionsMarshal が何を変えるのか確かめたことはない、という人向けの記事です。先に結論を書くと、List<T> は配列1つと Count を持つラッパーで、配列が満杯になると2倍の大きさの配列を作って全部コピーします。そのため件数がわかっているなら、コンストラクターに渡すだけで時間は半分、割り当ては3分の1以下になりました。ジェネリックそのものは「C#ジェネリック」、どのコレクションを選ぶかは「C#コレクションの選び方」で扱っています。

2021年の例がコンパイルできなかった理由

元の List<T> の例は List1 という変数を宣言しておきながら foreach では list1 を使い、逆に list2 を宣言して List2.Add(...) を呼んでいました。C# は大文字と小文字を区別するので、別の名前です。

Program.cs(13,36): error CS0103: 現在のコンテキストに 'list1' という名前は存在しません
Program.cs(18,13): error CS0103: 現在のコンテキストに 'List2' という名前は存在しません
…(19〜23行目に同じエラーが5件)
Program.cs(24,13): error CS0030: 型 'string' を 'int' に変換できません

最後のエラーは、List<string> を foreach (int element in list2) で回した部分です。名前をそろえて foreach (string element …) に直すと、44、33 と、2つ目のリストに残った韓国語の文字2つが1行ずつ出力されます。この CS0030 こそジェネリックコレクションの利点です。同じミスを ArrayList でしていたら、コンパイルは通って実行時に InvalidCastException になっていました。

Queue<int>・Stack<int> の例は Colsole を Console に直すと、それぞれ 30, 4 と 4, 10 を出力しました。Dictionary<string, string> の例はもともとコンパイルできました。元記事は「Dictionary は Hashtable と変わらない」と書いていましたが、存在しないキーを読むと Hashtable は null、Dictionary は KeyNotFoundException という大きな違いがあります。詳しい比較は「コレクション入門」にあります。

Capacity は 0 → 4 → 8 → 16 … と増える

List<T> には、実際の要素数である Count と、内部配列の長さである Capacity が別々にあります。1,100個を入れながら、Capacity が変わった瞬間だけを出力しました。

var list = new List<int>();
Console.WriteLine($"new List<int>(): Count={list.Count} Capacity={list.Capacity}");
int last = -1;
for (int i = 0; i < 1100; i++) {
    list.Add(i);
    if (list.Capacity != last) { Console.WriteLine($"  Add #{list.Count,4}: Capacity={list.Capacity}"); last = list.Capacity; }
}
list.TrimExcess();
Console.WriteLine($"TrimExcess -> Capacity={list.Capacity}");
list.Clear();
Console.WriteLine($"Clear -> Count={list.Count} Capacity={list.Capacity}");
new List<int>(): Count=0 Capacity=0
  Add #   1: Capacity=4
  Add #   5: Capacity=8
  Add #   9: Capacity=16
  Add #  17: Capacity=32
  Add #  33: Capacity=64
  Add #  65: Capacity=128
  Add # 129: Capacity=256
  Add # 257: Capacity=512
  Add # 513: Capacity=1024
  Add #1025: Capacity=2048
TrimExcess -> Capacity=1100
Clear -> Count=0 Capacity=1100

空のリストは配列を作らず(Capacity 0)、最初の Add で4つ分、以降は満杯になるたびに2倍になります。1,100個を入れる間に配列を10回作り直し、最後の配列(2,048個分)はほぼ半分が空いています。TrimExcess() は余った分を詰め、Clear() は Count を0にするだけで配列は残します。大きなリストを空にして使い回せば割り当てが起きない一方、メモリはすぐには戻らない、ということでもあります。

流れ:Add(x) は Count と Capacity が等しいかを調べる。等しくなければ既存の配列に x を書く。等しければ容量2倍(最初の Add では4)の新しい配列を確保し、全要素をコピーし、古い配列はゴミになり、そのあと x を書く。
内部配列が満杯のときに List<T>.Add がすること:2倍の配列を確保し、全要素をコピーし、古い配列は GC に任せる。.NET 10 で観測した Capacity は 0, 4, 8, 16, 32 …

2倍ずつ増えるので、要素1つを追加する平均コストは一定です(償却 O(1))。ただし増える瞬間には全体のコピーが起き、古い配列はゴミになります。このコストを後の節で測りました。

コレクション式と容量

C# 12 のコレクション式は List<T> にも使えます。LangVersion 11 でコンパイルすると、こう拒否されます。

Program.cs(1,15): error CS9058: 機能 'コレクション式' は C# 11.0 では使用できません。12.0 以上の言語バージョンをお使いください。

.NET 10(C# 14)では、件数がわかる場合はコンパイラがちょうどの容量で作ってくれました。

List<int> ce = [1, 2, 3];
List<int> spread = [..ce, 4, 5];
List<int> empty = [];
var ec = new List<int>(); ec.EnsureCapacity(100);
[1,2,3] Capacity=3; [..ce,4,5] Count=5 Capacity=5
[] Capacity=0
EnsureCapacity(100) -> 100

new List<int> { 1, 2, 3 }(コレクション初期化子)は空のリストに Add を3回呼ぶコードなので Capacity は4になりましたが、コレクション式は3でした。スプレッド .. も元の件数がわかれば一度で確保します。すでにあるリストにまとめて入れる直前なら、EnsureCapacity(n)(.NET 6)が同じ役目を果たします。

初期容量あり・なしの実測

int を1,000万個入れる時間と割り当て量を測りました。3つ目の方法は、CollectionsMarshal.SetCount(.NET 8)で Count を一度に決め、Span 経由で直接書き込むやり方です。

static long Fill(int n, bool presize) {
    var l = presize ? new List<int>(n) : new List<int>();
    for (int i = 0; i < n; i++) l.Add(i);
    return l.Count;
}
static long FillSetCount(int n) {
    var l = new List<int>();
    CollectionsMarshal.SetCount(l, n);
    var s = CollectionsMarshal.AsSpan(l);
    for (int i = 0; i < s.Length; i++) s[i] = i;
    return l.Count;
}

.NET 10.0.14、Release ビルド、Apple Silicon Mac、ウォームアップ後に3回実行しました。

方法 1回目 2回目 3回目 割り当て gen2 GC
new List<int>() + Add 18.7 ms 18.2 ms 17.3 ms 128.0 MB 3回
new List<int>(N) + Add 8.9 ms 9.0 ms 8.4 ms 38.1 MB 0回
SetCount + AsSpan 5.8 ms 6.0 ms 5.7 ms 38.1 MB 0回
棒グラフ:new List<int>() と Add で18.1ms、new List<int>(N) と Add で8.8ms、SetCount と AsSpan で5.8ms。最初に容量を決めると時間は半分、割り当ては128MBから38MBに減る。
List<int> に int を1,000万個入れる時間、3回の平均。割り当ては初期容量なしで128.0MB、ありで38.1MB。.NET 10.0.14 Release ビルド、Apple Silicon Mac。

38.1MBは int 1,000万個(40,000,000バイト)そのものです。128.0MBは 4、8、16 … 16,777,216個分の配列を順に作った合計で、計算上は約 4バイト × 2 × 16,777,216 ≈ 1億3,400万バイトになり、測定値と合います。大きな配列はラージオブジェクトヒープ(LOH)に置かれるため、第2世代 GC が3回起きました。

1,000万個は極端なので、よくある大きさでも測りました。1,000個のリストを10万回作った結果です(初回は JIT の階層型コンパイルの影響が大きいため除外)。

方法 10万回の所要時間 1リストあたりの割り当て
new List<int>() 113〜145 ms 8,424 B
new List<int>(1000) 80〜103 ms 4,056 B

小さなリストでも割り当ては半分以下、時間は約30%減りました。件数がわかっているのに渡さない理由はありません。逆に、件数がわからないのに大きな値を当て推量で渡すのはメモリの無駄なので、Count や Length のような確かな値があるときだけ渡しましょう。

CollectionsMarshal.AsSpan:速いが鋭い

CollectionsMarshal.AsSpan(list)(.NET 5)は、内部配列の Count までを Span<T> として返します。コピーせずに要素を直接書き換えられます。

var nums = new List<int> { 10, 20, 30 };
Span<int> span = CollectionsMarshal.AsSpan(nums);
span[0] = 99;
Console.WriteLine($"after span[0]=99 -> nums[0]={nums[0]}");
nums.Add(40);   // Capacity 4:まだ同じ配列
nums.Add(50);   // Capacity 4 → 8:新しい配列
span[1] = -1;
Console.WriteLine($"after realloc, span[1]=-1 -> nums[1]={nums[1]} (span.Length={span.Length})");
after span[0]=99 -> nums[0]=99
after realloc, span[1]=-1 -> nums[1]=20 (span.Length=3)

2行目が落とし穴です。Add で配列が替わったあと、span は捨てられた古い配列を指しているので、書いた値はリストに反映されず、例外も出ませんでした。Span を持っている間は、そのリストに Add・Remove・Insert をしない、というのがルールです。List<T> を foreach で回しながら Add すれば InvalidOperationException で教えてくれます(実際に確認しました)が、Span にはそのチェックがありません。

速度はどうでしょうか。1,000万個の合計を20回平均しました。

方法 1回目の計測 2回目の計測
for (i < l.Count) s += l[i] 6.32 ms 6.40 ms
foreach (var x in list) 6.56 ms 6.43 ms
foreach (var x in AsSpan(list)) 6.42 ms 8.05 ms

差はありませんでした。プログラムをもう一度実行したときも、3つとも6.4〜7.0msの範囲でした。.NET 10 の JIT は List<T> の for と foreach をすでに十分最適化しているので、単純なループを速くするために AsSpan を使う理由はありません。AsSpan が意味を持つのは、ReadOnlySpan<T> を受け取る API にコピーなしで渡すとき、大きな構造体の要素を ref で書き換えるとき、そして上の SetCount のように Add の手順そのものを省くときです。

まとめ:List を使うときのルール

状況 すること
入れる件数がわかっている new List<T>(count) か EnsureCapacity
決まった値から始める コレクション式 [a, b, c](容量がちょうどになる)
一度埋めたら長く持つ TrimExcess() か ToArray()
大きなリストを空にして使い回す Clear()(配列は残る)
Span を受け取る API に渡す CollectionsMarshal.AsSpan、持っている間は Add しない
単純な合計やループ 普通の for/foreach(実測で差なし)
件数が最後まで変わらない 配列(「C#の配列」参照)

配列と List<T> の違いや、ループの中の Array.Resize がなぜ遅いかは、「C#の配列」の記事に実測があります。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定値)、macOS(Apple Silicon Mac)。

よくある質問

List<T> の Capacity はどう増える?

new List<int>() の Capacity は0で、最初の Add で4、その後は満杯になるたびに2倍(8、16、32 …)になります。.NET 10 で1,100個入れながら出力すると、5番目、9番目、17番目 … 1,025番目の Add で Capacity が変わりました。増えるたびに新しい配列を作って全要素をコピーします。

new List<int>(n) のように初期容量を渡すとどれくらい速くなる?

int 1,000万個で、初期容量なしは17.3〜18.7ms・割り当て128.0MB、容量ありは8.4〜9.0ms・38.1MBでした。1,000個のリストを10万回作る場合も113〜145ms 対 80〜103ms、1リストあたりの割り当ては8,424バイト対4,056バイトでした(.NET 10、Release、Apple Silicon Mac)。件数がわかっているなら渡すだけの、ほぼタダの最適化です。

CollectionsMarshal.AsSpan を使うとループは速くなる?

.NET 10 で1,000万個の合計は、for が6.3〜6.8ms、foreach が6.4〜7.0ms、AsSpan の foreach が6.4〜8.1msで差はありませんでした。AsSpan の利点は速度より、要素をコピーせずに書き換えたり Span を受け取る API に渡したりできることです。その後 Add で内部配列が替わると、Span は古い配列を指したままになるので注意が必要です。

  • #C#
  • #List<T>
  • #ジェネリックコレクション
  • #Capacity
  • #Span
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑