【C#】シングルトン完全ガイド|6実装パターン比較・Lazy・beforefieldinit・DI・アンチパターンまで

【C#】シングルトンパターンの実装方法と注意点 C#

シングルトンパターンは「アプリ全体でインスタンスを1つだけ持つ」デザインパターンです。C# には static フィールド・Lazy<T>・DI コンテナなど複数の実装手段があり、スレッドセーフ性・遅延初期化・例外処理・テスタビリティという観点でそれぞれ強みと弱みが異なります。誤った実装を選ぶとマルチスレッド環境で複数インスタンスが作られたり、初期化時の例外が隠蔽されたり、テストが書けなくなる落とし穴があります。

本記事では6種類の実装パターンを比較し、Lazy<T>LazyThreadSafetyMode・CLR の beforefieldinit 挙動・古典的な Double-Checked Locking・DI シングルトンとの使い分け・IDisposable 管理・リフレクションによる破壊と対策・アンチパターンまで体系的に解説します。

スポンサーリンク

6つの実装パターン — 比較一覧

パターン スレッドセーフ 遅延初期化 例外伝播 推奨度
①Eager(静的初期化子) ◯(CLR保証) △(部分的) 型初期化で1度だけ ◎ シンプル
②静的コンストラクタ ◯(CLR保証) △(型が参照された時) 型初期化で1度だけ ◯ 明示的でよい
③Lazy<T> ◯(モード選択) ◎(アクセス時) 再アクセスで再スロー可能 推奨
④Double-Checked Locking △(volatile 必須) 例外処理が難しい × 非推奨(Lazy で代替)
⑤Nested Type ◯(型初期化) ◎(内部型参照時) 型初期化で1度 ◯ 古典的だが有効
⑥DI コンテナ ◯(コンテナ保証) ◎(解決時) 解決例外として扱える 実務で最推奨
まず DI を検討、次に Lazy<T>
現代の C# ではDI コンテナによるシングルトン管理が第一選択で、テスト時に差し替えられる・ライフタイムを宣言的に管理できる・依存関係が明示できる、などの利点があります。DI を使わない(ライブラリ内部・スタンドアロンツール等)場面では Lazy<T> が次の選択肢です。Double-Checked Locking を手書きする必要はほぼありません。

パターン① — Eager 初期化(静的フィールド)

最もシンプルな実装
public sealed class EagerSingleton
{
    // 静的フィールドの初期化は CLR が1度だけ・スレッドセーフに実行する
    private static readonly EagerSingleton _instance = new();

    // コンストラクタを private にして外部からの new を禁止
    private EagerSingleton() { }

    public static EagerSingleton Instance => _instance;

    public void DoSomething() => Console.WriteLine("実行");
}

// 使用
EagerSingleton.Instance.DoSomething();

最もシンプルで、CLR の型初期化機構(.cctor)によりスレッドセーフが自動保証されます。欠点は「使われない可能性があっても型が参照されたタイミングで生成される」点。設定読み込み・DB 接続など初期化コストが高い場合は Lazy<T> に切り替えます。

beforefieldinit の罠 — 「いつ初期化されるか」は保証されない

静的フィールド初期化子と静的コンストラクタの違い
// ① 静的フィールド初期化子のみ → beforefieldinit フラグが立つ
public sealed class A
{
    // JIT はこのフィールドを「必要になった時、初めて参照する前までに」初期化する
    // 正確なタイミングは保証されない(型参照の前に生成される可能性もある)
    public static readonly A Instance = new();
    private A() => Console.WriteLine("A created");
}

// ② 静的コンストラクタを明示 → beforefieldinit が立たない
public sealed class B
{
    public static readonly B Instance;
    static B()   // 静的コンストラクタ
    {
        Instance = new B();
        Console.WriteLine("B created");
    }
    private B() { }
}

// 両者の違い: 「他のメソッドを先に呼んでも B は初期化まで待たされる」という厳密性
public static class Caller
{
    public static void Log()
    {
        Console.WriteLine("Log called");
        // ここで B.Instance を参照する直前に B が初期化される
        Console.WriteLine(B.Instance);
    }
}
beforefieldinit の実害はほぼない
純粋な「シングルトンを作る」目的では、beforefieldinit の有無で動作が変わることはまれです。ただし静的コンストラクタを明示すると型の「最初の参照」時に確実に初期化が走るという厳密なセマンティクスになり、「Foo.Instance」と「Foo.StaticMethod()」のどちらで先にアクセスしても同じ初期化順が保証されます。副作用のある初期化(ログ出力・DB 接続等)を静的フィールドで行うなら、明示的な static コンストラクタで囲むのが無難です。

パターン③ — Lazy<T> による遅延シングルトン(推奨)

Lazy でスレッドセーフな遅延初期化
public sealed class AppConfig
{
    private static readonly Lazy<AppConfig> _instance = new(() => LoadFromFile());

    public static AppConfig Instance => _instance.Value;

    private AppConfig(string key) => ApiKey = key;
    public string ApiKey { get; }

    private static AppConfig LoadFromFile()
    {
        // 時間のかかる初期化。初回 .Value アクセスで1度だけ実行される
        var key = File.ReadAllText("apikey.txt").Trim();
        return new AppConfig(key);
    }
}

// 初回アクセスで初期化される
Console.WriteLine(AppConfig.Instance.ApiKey);
LazyThreadSafetyMode で動作を選ぶ
// Lazy<T> のコンストラクタで LazyThreadSafetyMode を指定できる

// ① ExecutionAndPublication(デフォルト)
//    初期化は1スレッドだけが実行し、他は待機する。最も安全
var safe = new Lazy<Foo>(() => new Foo(), LazyThreadSafetyMode.ExecutionAndPublication);

// ② PublicationOnly
//    複数スレッドが同時に初期化関数を呼ぶ可能性あり、先に完了したものの結果が採用される
//    → 関数が副作用なし・安価ならこちら(競争しても無駄になるだけ)
var pub = new Lazy<Foo>(() => new Foo(), LazyThreadSafetyMode.PublicationOnly);

// ③ None
//    スレッドセーフ性を捨てる。単一スレッドからしか参照しない前提
var none = new Lazy<Foo>(() => new Foo(), LazyThreadSafetyMode.None);
モード 複数スレッドから同時アクセス 初期化関数の実行回数 用途
ExecutionAndPublication(既定) 1スレッドが初期化、他は待機 1回 通常これを選ぶ
PublicationOnly 並行に呼ばれる可能性 競争した回数(結果は1つ) 副作用なし・安価な初期化
None スレッドセーフではない 1回 単一スレッドで確実
Lazy<T> の初期化関数が例外を投げたときの挙動
ExecutionAndPublication モードでは、初期化関数が例外をスローするとその例外は記憶され、以降の .Value アクセスで再度スローされます。「1回目は失敗、2回目は成功」というリトライは効きません。再試行可能にしたい場合は LazyThreadSafetyMode.PublicationOnly を使うか、Lazy<T> ではなく自前で Interlocked.CompareExchange を使った実装にします。

パターン④ — Double-Checked Locking(古典、非推奨)

古典的な DCL パターン(手書きは避ける)
public sealed class DclSingleton
{
    private static volatile DclSingleton? _instance; // volatile が必須
    private static readonly object _lock = new();

    private DclSingleton() { }

    public static DclSingleton Instance
    {
        get
        {
            if (_instance is null)                  // 1回目のチェック(ロックなし・速い)
            {
                lock (_lock)
                {
                    if (_instance is null)          // 2回目のチェック(ロック内)
                        _instance = new DclSingleton();
                }
            }
            return _instance;
        }
    }
}
DCL を自分で書くべきではない
見た目はシンプルですが、メモリモデルを深く理解していないと壊れますvolatile の省略・lock 内で別フィールド経由の初期化・派生クラスでの初期化順など、ミスのしやすい箇所が多数あります。.NET では Lazy<T> が内部的に正しい DCL 相当を実装しているので、自前の DCL を書く理由はもはや存在しません。コードレビューで DCL を見かけたら Lazy<T> に置き換える提案をしてください。

パターン⑤ — Nested Type(Jon Skeet パターン)

内部クラスの静的初期化を利用した遅延シングルトン
public sealed class NestedSingleton
{
    private NestedSingleton() { }

    public static NestedSingleton Instance => Holder.Value;

    // 内部クラスは「外側のクラスの他のメンバが参照された」時点では初期化されない
    // → Instance プロパティが呼ばれた瞬間に Holder が初めて参照され初期化が走る
    private static class Holder
    {
        internal static readonly NestedSingleton Value = new();

        // static コンストラクタを書くと beforefieldinit が外れてさらに厳密に
        static Holder() { }
    }
}

Java でも有名な「Initialization-on-demand holder」イディオムを C# で再現した形です。Lazy<T> が登場する前は遅延&スレッドセーフの最も洗練された書き方でしたが、現在は Lazy<T> で同じ効果が得られるため、新規コードで採用する必要性は低いです。既存コードでこのパターンを見つけたら、そのまま残すか Lazy<T> に置き換えるかは好みの問題です。

パターン⑥ — DI コンテナのシングルトン(実務推奨)

Microsoft.Extensions.DependencyInjection
// ASP.NET Core / Worker Service / Console 全てで使える
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IApiClient, ApiClient>();
builder.Services.AddSingleton<MetricsCollector>();

var app = builder.Build();

// インターフェース経由で使うのでテスト時に差し替え可能
public class OrderService(IApiClient api)
{
    public Task ProcessAsync(Order o) => api.SendAsync(o);
}

// テスト: Fake/Mock を注入
[Fact]
public async Task Test_Process()
{
    var fake = new FakeApiClient();
    var svc  = new OrderService(fake);
    await svc.ProcessAsync(new Order());
    Assert.Equal(1, fake.SendCount);
}
観点 手書きシングルトン DI シングルトン
依存の明示 利用側が Foo.Instance で隠れて参照 コンストラクタ引数として明示
テスト差し替え 困難(静的フィールドは固定) 簡単(別実装を注入)
ライフタイム管理 自前で管理(Dispose 等) コンテナが自動管理
設定注入 静的変数で持つしかない IOptions<T> で型安全に
循環依存検出 できない コンテナが検出
向いている場面 ライブラリ内部の軽量状態 アプリケーション全般

IDisposable なシングルトンの管理

シングルトンの寿命と Dispose
// シングルトンが IDisposable なら、アプリ終了時に Dispose を呼ぶ必要がある
public sealed class DbConnectionPool : IDisposable
{
    private static readonly Lazy<DbConnectionPool> _instance = new(() => new());
    public static DbConnectionPool Instance => _instance.Value;

    private readonly SemaphoreSlim _lock = new(1, 1);
    private DbConnectionPool() { }

    public void Dispose()
    {
        _lock.Dispose();
        // 管理している全コネクションもクローズ
    }
}

// 手書きシングルトンの場合: AppDomain.CurrentDomain.ProcessExit などで呼ぶ
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
    if (DbConnectionPool.Instance is IDisposable d) d.Dispose();
};

// DI シングルトンなら Host の停止時に自動 Dispose される
// builder.Services.AddSingleton<DbConnectionPool>();
// → app.StopAsync() や using var host で自動
ProcessExit は実行時間制限がある
AppDomain.ProcessExit ハンドラは .NET ランタイムから最大で約2〜3秒の実行時間しか与えられません。DB への大量バッファフラッシュ・ネットワーク通信など時間のかかる後処理はこのハンドラでは行わず、通常のアプリケーションライフサイクル(IHostApplicationLifetime.ApplicationStoppingIAsyncDisposable・DI コンテナの自動 Dispose)で扱うべきです。

リフレクションによる破壊と対策

private コンストラクタもリフレクションからは呼べてしまう
// シングルトンのつもりでも…
var type = typeof(EagerSingleton);
var ctor = type.GetConstructor(
    BindingFlags.Instance | BindingFlags.NonPublic,
    binder: null, types: Type.EmptyTypes, modifiers: null);

var another = (EagerSingleton)ctor!.Invoke(null);
// ↑ 2つ目のインスタンスが作られてしまう

// 対策: コンストラクタ内で「2回目以降は例外」とガード
public sealed class GuardedSingleton
{
    private static readonly GuardedSingleton _instance = new();
    private static int _ctorCount;

    public static GuardedSingleton Instance => _instance;

    private GuardedSingleton()
    {
        if (Interlocked.Increment(ref _ctorCount) > 1)
            throw new InvalidOperationException("Singleton を2回目の生成しようとした");
    }
}

// ただし「リフレクションで壊される可能性」を気にするより、
// 最初から DI 経由で使う・sealed にする・Enum-based Singleton にする方が現実的

Singleton vs Static Class — どちらを使うか

観点 シングルトンクラス static クラス
インターフェース実装 可能 不可(static はインターフェースを実装できない)
継承・仮想メソッド 可能(通常クラスなので) 不可
DI 登録 可能 不可(Foo.Method() で直接呼ぶしかない)
状態(フィールド) 持てる 持てるが全員で共有(制御が必要)
遅延初期化 可能 CLR による型初期化のみ
テスト差し替え DI 経由なら可能 不可
向いている場面 状態を持つ・差し替え可能性あり 純粋な関数の集まり(MathFile
判断のフローチャート
// ① 完全にステートレスな関数群なら → static class
public static class StringHelper
{
    public static string Truncate(string s, int max)
        => s.Length <= max ? s : s[..max] + "...";
}

// ② 状態を持つが差し替える可能性がない・内部利用のみ → Lazy<T> シングルトン
public sealed class LogBuffer
{
    private static readonly Lazy<LogBuffer> _instance = new(() => new());
    public static LogBuffer Instance => _instance.Value;
    private readonly ConcurrentQueue<string> _buffer = new();
    private LogBuffer() { }
    public void Add(string s) => _buffer.Enqueue(s);
}

// ③ 状態を持ち、テストで差し替える可能性がある → インターフェース + DI シングルトン
public interface IMetricsCollector
{
    void Increment(string name);
}

public sealed class MetricsCollector : IMetricsCollector
{
    private readonly ConcurrentDictionary<string, long> _counters = new();
    public void Increment(string name) =>
        _counters.AddOrUpdate(name, 1, (_, v) => v + 1);
}

// 登録: builder.Services.AddSingleton<IMetricsCollector, MetricsCollector>();

よくあるアンチパターン

アンチパターン① — グローバル可変状態
// NG: シングルトンを「どこからでも書き換えられる共有変数」として使う
public sealed class GlobalState
{
    public static GlobalState Instance { get; } = new();
    public string CurrentUser { get; set; } = "";   // 誰でも書き換えられる
    public int RequestCount  { get; set; }           // レース条件あり
}

// Controller からも Service からも Repository からも書き換え可能
// → デバッグ困難・競合状態・テスト不能

// OK: 不変データ + スレッドセーフな状態管理
public sealed class AppMetrics
{
    private long _requestCount;
    public long RequestCount => Interlocked.Read(ref _requestCount);
    public void IncrementRequest() => Interlocked.Increment(ref _requestCount);
}
アンチパターン② — Singleton を Service Locator 化
// NG: シングルトン内から別サービスを直接引いて依存を隠す
public sealed class OrderService
{
    public static OrderService Instance { get; } = new();

    public void Process(Order o)
    {
        // 依存関係が見えない・テスト時に差し替え不可
        Logger.Instance.Log("start");
        Database.Instance.Save(o);
        Notifier.Instance.Send(o.UserId, "完了");
    }
}

// OK: 依存をコンストラクタで受け取る(DI)
public sealed class OrderService2(ILogger logger, IDatabase db, INotifier notifier)
{
    public void Process(Order o)
    {
        logger.Log("start");
        db.Save(o);
        notifier.Send(o.UserId, "完了");
    }
}
アンチパターン③ — ジェネリックシングルトン基底クラス
// NG: 継承でシングルトン機能を提供しようとする
public abstract class SingletonBase<T> where T : SingletonBase<T>, new()
{
    private static readonly Lazy<T> _instance = new(() => new T());
    public static T Instance => _instance.Value;
}

public sealed class MyService : SingletonBase<MyService> { }

// 問題:
//  - new() 制約で private コンストラクタが書けない(public になり保証が弱まる)
//  - 継承できない型(record / struct / sealed)に使えない
//  - OOP の単一継承を無駄に消費する

// OK: 型ごとに Lazy<T> を直接書く(わずか3行)
public sealed class MyService
{
    private static readonly Lazy<MyService> _instance = new(() => new MyService());
    public static MyService Instance => _instance.Value;
    private MyService() { }
}

よくある質問

QLazy<T> と static フィールド、どちらを使うべき?
A「即初期化で問題ない・初期化コストが小さい」なら static readonly フィールド、「使われるか分からない・初期化コストが高い・起動時間を短くしたい」なら Lazy<T> を使います。Lazy<T> は内部でキャッシュするためオーバーヘッドは無視できるレベルで、迷ったら Lazy<T> でも問題ありません。
QDI を使えない場面(ライブラリ内部)ではどう書く?
ALazy<T> を使った public static Instance が定番です。ただし公開 API としてシングルトンを晒さないのが望ましく、ライブラリ内部の private static にとどめて、外部にはインスタンスを渡す API(メソッド引数・コンストラクタ引数)を提供します。グローバルな XxxManager.Instance.Do() パターンはライブラリ利用者のテスタビリティを下げるため避けるべきです。
Qシングルトンでスレッドセーフな状態を持つには?
A内部の可変状態を ConcurrentDictionaryConcurrentBagInterlockedReaderWriterLockSlim で保護してください。単純なカウンタなら Interlocked.Increment、キー-値マップなら ConcurrentDictionary、コレクションの一括走査が必要なら ImmutableList<T> + CompareExchange のロックフリー更新が定石です。lock を使うよりも専用の並行データ構造を使う方が性能・シンプルさの両面で優れています。
Qシングルトンにスコープド依存を持たせる方法は?
ADI シングルトンが Scoped(例: DbContext)に依存するのは Captured Dependenciesという典型的な問題です。コンストラクタで直接 Scoped を受け取るのではなく、IServiceScopeFactory を注入してメソッド内で using var scope = factory.CreateScope(); としてその都度スコープを作成します。詳しくは依存性注入(DI)完全ガイドを参照してください。
QAssembly Load Context が複数ある場合にシングルトンはどう振る舞う?
Aアセンブリが別の AssemblyLoadContext(ALC)で読み込まれると、型も別物として扱われ、各 ALC ごとに独立したシングルトンインスタンスが作られます。プラグインアーキテクチャ(プラグインごとに別 ALC)では「シングルトン」という前提が崩れることがあるため、本当に全体で1つにしたいデータはデフォルトの ALC に存在する型に持たせてください。

まとめ

判断軸 推奨実装
DI コンテナがある環境 DI シングルトン(AddSingleton
DI がない・ライブラリ内部 Lazy<T> + private コンストラクタ
初期化コストが小さい static readonly フィールド(Eager)
副作用のある初期化を厳密に制御 静的コンストラクタ(beforefieldinit 抑制)
ステートレスな関数群 シングルトンではなく static class
Double-Checked Locking 書かずに Lazy<T> で代替
IDisposable DI コンテナに任せるか IHostApplicationLifetime で管理
スレッドセーフな状態 InterlockedConcurrent*Immutable* を内部で使う
テスト容易性 インターフェースに抽象化し DI 経由で差し替え
避けるべきパターン グローバル可変状態・Service Locator・ジェネリック継承基底

関連する設計手法は以下を参照してください。依存性注入(DI)完全ガイドで DI シングルトンの詳細、static完全ガイドstatic class との使い分け、IDisposable・using完全ガイドでライフタイム管理、インターフェースと抽象クラス完全ガイドでテスタブルな設計を解説しています。