概要
負荷試験の最中に、1回しか出ないはずの「設定読み込み完了」ログが2行並んでいた――synchronized を省いた lazy 初期化の Singleton は、開発機のシングルスレッドでは何年でも正しく動き、並列度が上がった瞬間に複数インスタンスを生みます。この記事では Eager Initialization・Initialization-on-demand Holder・Enum Singleton の3方式を、スレッド安全性・遅延初期化・シリアライズ耐性の3点で比較します。double-checked locking に volatile が欠かせない理由と、デシリアライズで別インスタンスが生まれる罠への readResolve() による対処を、実行して結果を確かめられるコードで確認します。新規に書くなら Holder か Enum――結論を先に示したうえで、その根拠を順に説明します。
使いどころ
夜間バッチの起動時に設定ファイルを1回だけ読み込み、数十個の処理クラスから同じ値を参照させる(毎回ファイルを読むと I/O が処理件数分発生する)
DB コネクションプールの管理クラスを1インスタンスに固定し、接続数の上限管理を一箇所に集約する(複数インスタンスができると上限設定が意味を失う)
アプリ全体で共有する採番やキャッシュの入口を Singleton にし、可変な状態の置き場所を一箇所に限定する
コード例
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class SingletonPatternSample {
// Holder パターン: JVM のクラス初期化が排他を保証するので synchronized 不要
static class AppConfig implements Serializable {
private static final long serialVersionUID = 1L;
private final String env = "prod";
private AppConfig() {
System.out.println("AppConfig 初期化(1回だけ出るはず)");
}
private static class Holder {
static final AppConfig INSTANCE = new AppConfig();
}
public static AppConfig getInstance() {
return Holder.INSTANCE;
}
public String getEnv() { return env; }
// これを消すと、デシリアライズのたびに「別インスタンス」が生まれる
private Object readResolve() {
return getInstance();
}
}
// Enum Singleton: シリアライズもリフレクションも JVM 側で防がれる
enum Sequence {
INSTANCE;
private int value = 0;
public synchronized int next() { return ++value; }
}
// シリアライズ→デシリアライズの往復(キャッシュ保存やセッション複製の擬似)
static Object roundTrip(Object obj) throws Exception {
var buf = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(buf)) {
out.writeObject(obj);
}
try (var in = new ObjectInputStream(
new ByteArrayInputStream(buf.toByteArray()))) {
return in.readObject();
}
}
public static void main(String[] args) throws Exception {
// どこから呼んでも同じインスタンス
var a = AppConfig.getInstance();
var b = AppConfig.getInstance();
System.out.println("getInstance 同士: " + (a == b)); // true
// デシリアライズ経由。readResolve があるので同一のまま
var restored = roundTrip(a);
System.out.println("デシリアライズ後: " + (a == restored)); // true(readResolve を消すと false)
// Enum Singleton は仕組み上、往復しても必ず同一
var seqRestored = roundTrip(Sequence.INSTANCE);
System.out.println("Enum の復元: "
+ (Sequence.INSTANCE == seqRestored)); // true
System.out.println("採番: " + Sequence.INSTANCE.next()
+ ", " + Sequence.INSTANCE.next()); // 採番: 1, 2
}
}Version Coverage
var による型推論で呼び出しコードが簡潔になる。record と組み合わせた設定値の保持も選択肢に入る。
// Java 17: var で型推論 var s1 = EagerSingleton.getInstance(); var s2 = EagerSingleton.getInstance(); System.out.println(s1 == s2); // true // Enum Singleton + record で設定値を構造化 EnumSingleton.INSTANCE.increment();
Library Comparison
注意点
Lazy Initialization を synchronized なしで書くと、マルチスレッドで2つ以上のインスタンスが生まれる。テスト環境では再現しにくいため本番で初めて発覚しやすい
Double-Checked Locking では volatile 修飾子が必須。付け忘れると命令の並び替えによって初期化途中のオブジェクトが返る可能性がある
Serializable を implements した Singleton は、デシリアライズ時に新しいインスタンスが生まれる。readResolve() で INSTANCE を返すか、Enum Singleton を使えば回避できる
リフレクションで private コンストラクタを呼び出されると Singleton が壊れる。Enum Singleton はこの攻撃にも耐性がある
実務で見かける誤実装の多くは「volatile なし双重チェック」か「static フィールドだから安全と思ってのスレッド安全性の見落とし」。Singleton を新規実装する場合は Holder パターンか Enum Singleton を選ぶのが最も安全。
FAQ
Effective Java(Item 3)でも推奨されている通り、一般に Enum Singleton が最も安全とされています。シリアライズ・リフレクション攻撃にも耐性があります。ただし継承が必要な場合は Holder パターンを選んでください。
Singleton 自体にインターフェースを切り、テスト時はモック実装に差し替える方法が一般的です。DI コンテナを使えばフレームワーク側で切り替えられます。
Spring 管理下のクラスなら @Component で十分です。ただし、DI コンテナ外で動くユーティリティやライブラリ層では、自前の Singleton が依然有用です。