C#のコンストラクターとファイナライザー:実行順序・GC・IDisposable

C#のコンストラクター連鎖と静的コンストラクターの実際の実行順序、.NET 10でファイナライザーがいつ動くのか、IDisposableとusingを選ぶ理由、ファイナライザーのコスト35倍の実測まで。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語向けに書き直したものです。サンプルを .NET 10.0.14(C# 14)で動かし直したところ、いちばん大きな違いはデストラクターの例でした。当時は「c 破棄、b 破棄、a 破棄」と出ると書いていましたが、.NET 10 では破棄のメッセージが1行も出ません。 .NET Core 以降のランタイムは、プロセス終了時にファイナライザーを実行しないためです。また「コンストラクターを書かなくても既定のコンストラクターが作られる」という説明は、コンストラクターを1つも宣言していない場合だけ正しい内容です。これらを直したうえで、コンストラクター連鎖と静的コンストラクターの実際の実行順序、ファイナライザーがいつ動くのか、そのコストの実測を加えました。

C#のコンストラクターを学んでいる人や、「デストラクターに後片付けを書いたのに動かない」という問題にぶつかった人向けの記事です。先に結論を書くと、コンストラクターの順序は決まっていて出力で確かめられますが、ファイナライザーがいつ動くかは誰も約束していません。ファイル・ソケット・DB接続のようにすぐ片付けるべきリソースは IDisposable と using で解放し、ファイナライザーは Dispose を呼び忘れたときの保険にとどめます。

なお、C#の仕様や Microsoft Learn では現在、~クラス名() を「デストラクター」よりファイナライザー(finalizer)と呼ぶのが一般的です。C++ のデストラクターのようにスコープを抜けた瞬間に動くわけではないからです。この記事でも以降はファイナライザーと呼びます。

コンストラクターの基本:2021年のサンプルを動かし直す

コンストラクターはクラスと同じ名前を持ち、戻り値の型がなく、new でオブジェクトを作るときに呼ばれるメソッドです。元の Car のサンプルはそのまま動きました(出力の文字列だけ短くしています)。

class Car
{
    private int maxSpeed;
    private int speed = 0;
    private string model;
    public Car(int maxSpeed, string model) { this.maxSpeed = maxSpeed; this.model = model; }
    public void ShowCarInformation() => Console.WriteLine($"{model}: speed {speed}km/h, max {maxSpeed}km/h");
    public void SpeedUp(int increment)
    {
        if (speed + increment > maxSpeed) Console.WriteLine($"Cannot exceed {maxSpeed}km/h.");
        else { speed += increment; Console.WriteLine($"{model}: now {speed}km/h"); }
    }
}
// new Car(325, "Gallardo") のあと SpeedUp(50)、(40)、(210)、(30)
Gallardo: speed 0km/h, max 325km/h
Gallardo: now 50km/h
Gallardo: now 90km/h
Gallardo: now 300km/h
Cannot exceed 325km/h.

ここで当時の説明を1つ訂正します。「コンストラクターを作らなくてもコンパイラーが既定のコンストラクターを作る」のは、コンストラクターを1つも宣言していないクラスだけです。Car のように引数付きのコンストラクターを1つでも書くと、引数なしのコンストラクターはなくなります。

Program.cs(1,15): error CS7036: 'maxSpeed' の必要なパラメーター 'Car.Car(int, string)' に対応する特定の引数がありません

コンストラクターのオーバーロードの例もそのまま動きました。当時は試していなかった引数を2つ足すと、オーバーロードの選ばれ方が見えてきます。

class MyClass
{
    public MyClass() => Console.WriteLine("MyClass()");
    public MyClass(int a) => Console.WriteLine("MyClass(int)");
    public MyClass(double d) => Console.WriteLine("MyClass(double)");
}
_ = new MyClass(); _ = new MyClass(10); _ = new MyClass(25.5);
_ = new MyClass(10L); _ = new MyClass('A');
MyClass()
MyClass(int)
MyClass(double)
MyClass(double)
MyClass(int)

long(10L)は int へ暗黙に変換できないので double 版が、char('A')は int へ変換できるので int 版が呼ばれます。メソッドのオーバーロードと同じ規則で、詳しくはC#のメソッドにまとめています。クラス名の直後に引数を書く C# 12 のプライマリコンストラクターはC#のクラスで扱っています。

コンストラクターの連鎖と実際の実行順序

: this(...) は同じクラスの別のコンストラクターを、: base(...) は基底クラスのコンストラクターを先に呼びます。全体の順序を見るため、すべての手順でログを出しました。

class Base
{
    static int sField = Log.Say("  Base static field initializer");
    static Base() => Log.Say("  Base static ctor");
    int field = Log.Say("  Base instance field initializer");
    public Base(string tag) => Log.Say($"  Base({tag}) body");
}
class Derived : Base
{
    static int sField = Log.Say("  Derived static field initializer");
    static Derived() => Log.Say("  Derived static ctor");
    int field = Log.Say("  Derived instance field initializer");
    public Derived() : this("default") => Log.Say("  Derived() body");
    public Derived(string name) : base(name) => Log.Say($"  Derived({name}) body");
}
// new Derived("Kan"); 続けて new Derived();
Main start
  Derived static field initializer
  Derived static ctor
  Derived instance field initializer
  Base static field initializer
  Base static ctor
  Base instance field initializer
  Base(Kan) body
  Derived(Kan) body
--- second object
  Derived instance field initializer
  Base instance field initializer
  Base(default) body
  Derived(default) body
  Derived() body

直感に反する点が3つあります。

  1. 派生クラスのフィールド初期化子が、基底クラスのコンストラクターより先に動きます。C#のコンパイラーはフィールド初期化のコードを、コンストラクターの先頭、base(...) 呼び出しよりも前に置きます。
  2. 静的な部分も Derived が Base より先です。new Derived がまず Derived の静的コンストラクターを起こし、そのあと Derived のコンストラクターが base(...) を呼ぶ時点で Base の静的コンストラクターが動きます。
  3. Derived() から this("default") に渡すとき、フィールド初期化子は1回だけ動きます。this(...) で別のコンストラクターに渡す側には、フィールド初期化のコードが入らないからです。
9手順の流れ図。new Derived("Kan") のあと、Derived の静的フィールド初期化子、Derived の静的コンストラクター、Derived のインスタンスフィールド初期化子、Base の静的フィールド初期化子、Base の静的コンストラクター、Base のインスタンスフィールド初期化子、Base のコンストラクター本体、最後に Derived のコンストラクター本体。静的な4手順には「初回のみ」と書かれている。
.NET 10 で最初の new Derived("Kan") を実行したときの実際の順序。「初回のみ」の手順は型ごとに1回だけで、2つ目のオブジェクトではそれ以外の4手順だけが動いた。

静的コンストラクター:型ごとに1回だけ

静的コンストラクターはアクセス修飾子も引数もなく static クラス名() と書きます。型ごとに1回、最初に使われる直前にランタイムが呼び、自分のコードから呼ぶことはできません。

static class Config
{
    public static readonly string Value = Load();
    static Config() => Console.WriteLine("  Config static ctor");
    static string Load() { Console.WriteLine("  Config.Load()"); return "ok"; }
}
// Config.Value を2回読む
  Config.Load()
  Config static ctor
Config.Value read: ok
Config.Value again: ok

静的フィールド初期化子(Load())が静的コンストラクター本体より先に動き、2回目のアクセスでは何も再実行されません。設定の読み込みやキャッシュの準備に便利ですが、注意点もあります。静的コンストラクターで例外を投げるようにして型に2回アクセスしてみると、静的コンストラクターは1回しか動かず、2回とも TypeInitializationException(中身は InvalidOperationException)になりました。再試行はされないので、失敗しうるネットワークやファイルの I/O は静的コンストラクターに入れないほうが安全です。

ファイナライザーはいつ動くのか

元のデストラクターの例を .NET 10 で、Debug ビルドで3回、Release ビルドで3回動かしました(文字列だけ日本語にしています)。

class MyClass
{
    private string name;
    public MyClass(string name) { this.name = name; Console.WriteLine(name + " を生成"); }
    ~MyClass() { Console.WriteLine(name + " を破棄"); }
}
// Main: new MyClass("a"); new MyClass("b"); new MyClass("c");
a を生成
b を生成
c を生成

6回とも同じで、「破棄」は一度も出ませんでした。 .NET Framework はプロセス終了時に残ったファイナライザーを実行していましたが、.NET Core 以降はそうしません。2021年の出力(c、b、a の順に破棄)は当時の .NET Framework 環境の結果と考えられ、いまのランタイムでは再現しません。

ファイナライザーが動く様子を見るには、GC を強制的に走らせ、ファイナライザースレッドが終わるのを待つ必要があります。このときオブジェクトは別のメソッドで作ります。

[MethodImpl(MethodImplOptions.NoInlining)]
static void Make(string name) => _ = new MyClass(name);

Make("a"); Make("b"); Make("c");
Console.WriteLine("before GC.Collect");
GC.Collect();
Console.WriteLine("after GC.Collect");
GC.WaitForPendingFinalizers();
Console.WriteLine("after WaitForPendingFinalizers");
a created (thread 1)
b created (thread 1)
c created (thread 1)
before GC.Collect
after GC.Collect
a finalized (thread 2)
c finalized (thread 2)
b finalized (thread 2)
after WaitForPendingFinalizers

分かったことは次のとおりです。

  • ファイナライザーはメインスレッド(1)ではなくファイナライザースレッド(2)で動きます。ファイナライザーの中で UI やスレッドに結び付いたオブジェクトに触ってはいけない理由です。
  • 順序は a、c、b でした(5回とも同じ)。作った順でも逆順でもなく、順序は保証されません。
  • 同じオブジェクトを別メソッドにせず Main のローカル変数として作ると、GC.Collect() と WaitForPendingFinalizers() を呼んでも、Debug・Release とも何もファイナライズされませんでした。今回の実行では、ローカル変数はメソッドを抜けるまで生きているものとして扱われました。ファイナライザーのテストが「動かない」ときは、たいていこれが原因です。

ファイナライザーは継承・オーバーロード・直接の呼び出しができません。直接呼ぶと error CS0122: 'Res.~Res()' はアクセスできない保護レベルになっています、構造体に書くと error CS0575: クラスのみがデストラクターを含むことができます になります。

後片付けは IDisposable と using で

いつ動くか分からないファイナライザーの代わりに、片付けのタイミングをコードが決める仕組みが IDisposable です。using ブロックや C# 8 の using 宣言を使えば、スコープを抜けた瞬間に(例外が起きても)Dispose() が呼ばれます。

sealed class Resource : IDisposable
{
    private readonly string name;
    private bool disposed;
    public Resource(string name) { this.name = name; Console.WriteLine($"  open {name}"); }
    public void Dispose()
    {
        if (disposed) return;
        disposed = true;
        Console.WriteLine($"  Dispose {name}");
        GC.SuppressFinalize(this);   // 片付け済みなのでファイナライザーは不要
    }
    ~Resource() => Console.WriteLine($"  finalizer {name}: Dispose was not called!");
}

using (var r = new Resource("file1")) { Console.WriteLine("  working with file1"); }
static void UseDeclaration()
{
    using var r = new Resource("file2");   // C# 8 の using 宣言
    Console.WriteLine("  working with file2");
}   // ここで Dispose
  open file1
  working with file1
  Dispose file1
  open file2
  working with file2
  Dispose file2
--- forgot Dispose
  open file3
  finalizer file3: Dispose was not called!

file3 は Dispose を呼び忘れたケースです。今回は GC を強制したのでファイナライザーが保険として働きましたが、実際のプログラムではいつ動くか分からず、先にプロセスが終われば動きません。実務での判断基準は次のとおりです。

  • マネージドリソース(FileStream、HTTP のレスポンス、DB 接続)だけを持つクラスなら、ファイナライザーは書かず、自分の Dispose からそれらの Dispose を呼びます。
  • OS のハンドルなどアンマネージドリソースを直接持つなら、ファイナライザーを自作するより SafeHandle で包むことを Microsoft Learn は推奨しています。
  • using と try/finally の関係はC#の例外処理を参照してください。

実測:ファイナライザー付きオブジェクトのコスト

2021年版は「不要なデストラクターは性能低下を招くだけ」と書いただけでした。どのくらいなのかを測りました。int フィールド1つのクラスを100万個作ってすぐ捨て、続けて GC.Collect() → WaitForPendingFinalizers() → GC.Collect() まで終えた時間です。Release ビルド、.NET 10.0.14、Apple Silicon Mac、ウォームアップ1回のあと3回計測。

クラス 生成ループ(3回) GC とファイナライズ(3回) 1個あたり 動いたファイナライザー
普通のクラス 3.9 / 3.9 / 3.9 ms 1.2 / 1.2 / 1.2 ms 24 B 0
ファイナライザー+Dispose(SuppressFinalize) 32.6 / 32.4 / 31.9 ms 3.6 / 3.5 / 3.6 ms 24 B 0
ファイナライザーのみ(Dispose しない) 169.8 / 173.4 / 156.4 ms 14.7 / 14.7 / 14.9 ms 24 B 1,000,000
棒グラフ。普通のクラス 5.1ms。ファイナライザー付きで Dispose が GC.SuppressFinalize を呼ぶクラス 35.9ms。ファイナライザー付きで Dispose しないクラス 181.3ms で、普通のクラスの約35倍。
小さなオブジェクトを100万個作り、そのあと完全GCと WaitForPendingFinalizers を実行した合計時間。ウォームアップ後の3回の平均。.NET 10.0.14、Release ビルド、Apple Silicon Mac。短いほど速い。

オブジェクトの大きさは3つとも24バイトで同じなのに、合計時間は5.1msから181.3msへ、約35倍になりました。ファイナライザーを持つオブジェクトは作るたびにランタイムのファイナライズキューに登録され、GC が不要と判断してもすぐには回収されず、ファイナライザースレッドを通ったあと次の GC でようやくメモリが解放されるためです。GC.SuppressFinalize で登録を外すと35.9msまで下がりますが、登録のコスト自体は残り、普通のクラスの約7倍です。要らないファイナライザーは最初から書かないのがいちばん速い、という結果です。

まとめ

やりたいこと 使うもの
作成時に必須の値を受け取る コンストラクター(または required)
複数のコンストラクターで初期化を共通化 : this(...) の連鎖
型ごとに1回だけ準備 静的コンストラクター/静的フィールド初期化子
ファイルや接続をすぐ片付ける IDisposable + using
アンマネージドのハンドル SafeHandle(ファイナライザーを自作しない)
Dispose 忘れの保険 ファイナライザー+ Dispose で GC.SuppressFinalize

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

よくある質問

プログラムが終わるとき C# のデストラクター(ファイナライザー)は呼ばれる?

.NET Core 以降(.NET 5〜10 を含む)では呼ばれません。2021年のサンプルを .NET 10 で動かすと「生成」の3行だけが出て、「破棄」は1行も出ませんでした。終了時に片付けが必要なリソースは IDisposable と using で明示的に解放します。

静的コンストラクターはいつ、何回動く?

型ごとに1回、その型の静的メンバーに初めて触れる直前か、最初のインスタンスを作る直前に動きます。静的フィールド初期化子が静的コンストラクター本体より先に動くこと、派生クラスを初めて作ると派生クラスの静的コンストラクターが基底クラスより先に動くことを、実際の出力で確かめました。

ファイナライザーを付けるとどれくらい遅くなる?

小さなオブジェクトを100万個作ってGCを終えるまで、普通のクラスは5.1ms、ファイナライザー付きのクラスは181.3msで約35倍でした(.NET 10、Release、Apple Silicon Mac)。Dispose で GC.SuppressFinalize を呼ぶと35.9msまで下がりました。

  • #C#
  • #コンストラクター
  • #ファイナライザー
  • #デストラクター
  • #IDisposable

t.laboratory

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

運営者情報 · アプリ

↑