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つあります。
- 派生クラスのフィールド初期化子が、基底クラスのコンストラクターより先に動きます。C#のコンパイラーはフィールド初期化のコードを、コンストラクターの先頭、
base(...)呼び出しよりも前に置きます。 - 静的な部分も Derived が Base より先です。
new Derivedがまず Derived の静的コンストラクターを起こし、そのあと Derived のコンストラクターがbase(...)を呼ぶ時点で Base の静的コンストラクターが動きます。 Derived()からthis("default")に渡すとき、フィールド初期化子は1回だけ動きます。this(...)で別のコンストラクターに渡す側には、フィールド初期化のコードが入らないからです。
静的コンストラクター:型ごとに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 |
オブジェクトの大きさは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まで下がりました。