前の記事で System.GC クラスを整理しました。GC は基本的にマネージドメモリしか面倒を見てくれないため、アンマネージドリソースを扱うクラスには 「自分が回収される直前に最後の後始末をする」 仕組みが必要になります。その仕組みが Finalizable Object(ファイナライズ可能なオブジェクト) です。
この記事では System.Object.Finalize() の役割、C# のデストラクタ構文、そしてファイナライザを書く「べき場面」と「避けるべき場面」を整理します。
Finalizable Object とは
Finalizable Object とは、Finalize() メソッドを実装しているオブジェクト のことです。Finalize() は GC によって、そのオブジェクトが回収される直前に呼び出されます。
つまり、
- いつ呼ばれるかは GC 次第
- 確実に呼ばれる保証はない(プロセス強制終了時など)
- しかし、回収される 直前のラストチャンス で何かを実行できる
これが「ファイナライザ」の本質です。
System.Object.Finalize()
すべてのクラスの基底である System.Object には、次のような protected virtual メソッドが定義されています。
namespace System
{
public class Object
{
~Object() { } // 概念的には Finalize() に相当する
// protected virtual void Finalize();
}
}
ポイントは次の 3 点です。
protected:外から呼び出すことはできない(GC のみが呼び出す)virtual:派生クラスでオーバーライドできる- 戻り値なし、引数なし:純粋な「終了処理フック」
ただし C# では Finalize() を 直接オーバーライドすることが言語仕様で禁止 されています。代わりに「デストラクタ構文」を使います。
C# のデストラクタ構文
C# でファイナライザを定義するには、C++ のデストラクタに似た ~ClassName() 構文を使います。
public class NativeBuffer
{
private IntPtr _ptr;
public NativeBuffer(int size)
{
_ptr = Marshal.AllocHGlobal(size);
}
// ファイナライザ(実体は Finalize() のオーバーライド)
~NativeBuffer()
{
if (_ptr != IntPtr.Zero)
{
Marshal.FreeHGlobal(_ptr);
_ptr = IntPtr.Zero;
}
}
}
このデストラクタ構文は、コンパイラによって次のような Finalize() のオーバーライドに展開されます(概念的にはこのイメージ)。
protected override void Finalize()
{
try
{
// ~NativeBuffer() の中身
}
finally
{
base.Finalize();
}
}
base.Finalize() への伝播も自動で行われます。C++ のデストラクタとは似ているようでまったく別物 で、呼び出しタイミングを制御できないという根本的な違いがあります。
ファイナライザが GC に与える影響
ファイナライザを持つオブジェクトは、GC にとってコストの高い特別な存在 です。仕組みを理解すると、なぜ「むやみに書くな」と言われるのかが見えてきます。
ファイナライズキュー
new でファイナライザ持ちオブジェクトが生成されると、ランタイムはそれを ファイナライズキュー に登録します。GC が「このオブジェクトはもう到達不能」と判断したとき、
- 通常の参照不能オブジェクトは即座に回収される
- ファイナライザ持ちは 回収されず、ファイナライズキューから F-Reachable Queue に移される
- 専用のファイナライザスレッドが順次
Finalize()を呼び出す - 次の GC でようやくメモリが回収される
つまり、最低でも 2 回の GC を生き延びる ことになります。
世代昇格を引き起こす
GC は世代別(Gen0 / Gen1 / Gen2)になっており、生き残ったオブジェクトは上位世代に昇格します。ファイナライザ持ちは「最低 2 回の GC を生き延びる」ため、自然と Gen2 に昇格しやすく、Gen2 GC(フル GC)の頻度を押し上げる 原因になります。
ファイナライザスレッドは 1 本
Finalize() を実行するスレッドは(既定では)1 本だけです。重い処理や、ロックを取る処理、I/O を伴う処理を書くと、ファイナライザスレッドが詰まり、後続のオブジェクトの解放が止まる という最悪のシナリオを招きます。
ファイナライザを書くべき場面
ここまで見てきた通り、ファイナライザは「GC を重くする要因」です。それでも書くべき理由はただ一つ、
アンマネージドリソースを直接保持しているとき、Dispose の呼び忘れに対する最後の保険として
です。
public class NativeHandle
{
private IntPtr _handle;
public NativeHandle()
{
_handle = NativeMethods.Open();
}
~NativeHandle()
{
// Dispose を呼び忘れたとしても、最後にここで解放される
if (_handle != IntPtr.Zero)
{
NativeMethods.Close(_handle);
_handle = IntPtr.Zero;
}
}
}
ポイントは次の通りです。
IntPtrや OS ハンドルなど、GC の管轄外 のものを直接持っているクラスでのみ書くDisposeが呼ばれていれば不要なので、GC.SuppressFinalize(this)で抑制する(次の記事で詳述)SafeHandleやCriticalFinalizerObjectを使えば、自前のファイナライザは不要になることが多い
ファイナライザを書くべきでない場面
逆に、次のようなケースでファイナライザを書くのは アンチパターン です。
マネージドリソースの解放
FileStream や SqlConnection など、内部に IDisposable を持つフィールドを Finalize() で Dispose() してはいけません。
// アンチパターン
~MyClass()
{
_innerStream.Dispose(); // 危険
}
理由は、ファイナライザが呼ばれる時点で、フィールドが指すオブジェクトが既にファイナライズされている可能性がある からです。GC は「到達不能」になったオブジェクト群をまとめて処理するため、_innerStream の Finalize() が先に走っているかもしれません。
ロギング・通知
「オブジェクトが回収されたらログを出す」という用途も避けるべきです。
- 呼ばれる順序が保証されない
- プロセス終了時には呼ばれない
- ファイナライザスレッドを詰まらせる
ロギングが目的なら、明示的な Dispose() でやるべきです。
例外を投げる
ファイナライザ内で発生した未処理例外は、.NET 2.0 以降では プロセスを終了させます。Finalize() の中では一切例外を投げてはいけません。書くなら try/catch で握りつぶすことになりますが、それ自体「ファイナライザは限定的にしか使えない」ということの裏返しです。
まとめ
| 項目 | 内容 |
|---|---|
| 定義 | System.Object.Finalize() の protected virtual メソッド |
| C# 構文 | ~ClassName() デストラクタ構文 |
| 呼び出し主体 | GC のファイナライザスレッド |
| タイミング | 不定。プロセス終了時には呼ばれないこともある |
| コスト | ファイナライズキュー経由 → 世代昇格 → GC 負荷増 |
| 書くべき場面 | アンマネージドリソースの「最後の保険」 |
| 避けるべき場面 | マネージドリソースの解放、ロギング、重い処理、例外 |
ファイナライザは 「Dispose の呼び忘れに対する保険」 であって、本来の解放手段ではありません。本来の解放手段である IDisposable / Dispose() については、次の記事で整理します。