このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。マルチスレッド環境でSingletonの実装がなぜ壊れてしまうのか、Holderパターンとenumの方式、Spring singletonの概念を合わせて整理しています。
Singleton、そのまま実装したらいけないの?#
「nullをチェックするだけでいいんじゃないの?」#
Singletonを初めて学ぶと、大抵こう実装します。
public class Settings {
private static Settings instance;
private Settings() { }
public static Settings getInstance() {
if (instance == null) {
instance = new Settings();
}
return instance;
}
}論理的には完璧に見えます。
シングルスレッドであれば、instanceがなければ作り、あればそれを返すのでインスタンスは1つだけ生成されるように見えます。
ところがこのコードはマルチスレッド環境ではSingletonを保証できません。
どこで問題が発生するのか?#
スレッドAとスレッドBが同時にgetInstance()を呼び出す状況を考えてみましょう。
スレッドA → if (instance == null) をチェック → nullだ、true!
スレッドB → if (instance == null) をチェック → nullだ、true! ← 同時に通過
スレッドA → instance = new Settings() を実行
スレッドB → instance = new Settings() を実行 ← 2つ目のインスタンスが生成!2つのスレッドが同時にnullチェックを通過すると、それぞれがインスタンスを作ってしまいます。
共有フィールドinstanceに対する同期がないため、Singletonの保証が崩れる可能性があります。
「じゃあsynchronizedをかければいいんじゃないの?」#
その通りです。同期化するとThread-safeになります。
public static synchronized Settings getInstance() {
if (instance == null) {
instance = new Settings();
}
return instance;
}しかしこの方法ではgetInstance()のすべての呼び出しが同じmonitorを通ることになります。
インスタンスが既に生成された後でも毎回同期化の経路を通らなければならないため、呼び出しが多くて競合が発生すると不要なcontentionが生じる可能性があります。
「じゃあ必要な瞬間だけロックをかけたらどうだろう?」#
そのアイデアがDouble-Checked Lockingです。
public static Settings getInstance() {
if (instance == null) { // 1回目のチェック — ロックなし
synchronized (Settings.class) {
if (instance == null) { // 2回目のチェック — ロックあり
instance = new Settings();
}
}
}
return instance;
}インスタンスが生成された後はロックをかけないので呼び出しの経路は軽くなります。
しかしこのコードもvolatileなしには安全ではありません。
Javaのメモリモデルは、同期化されていない共有の読み書きに対して予期せぬ実行を許容します。
オブジェクトの生成は概念的に以下の順序で行われると考えられます。
1. メモリ空間の確保
2. コンストラクターの実行
3. instance変数への参照の公開volatileなしでDCLを使うと、instanceへの書き込みとその後の読み取りの間に必要なhappens-before関係がなく、
他のスレッドが生成が完全に終わる前の参照を観測する実行が許容される可能性があります。
これを防ぐにはvolatileキーワードが必要です。
private static volatile Settings instance; // DCLではvolatileが必要結局コードがどんどん複雑になります。ミスも犯しやすくなります。
より良い方法があります — Holderパターン#
public class Settings {
private Settings() { }
private static class SettingsHolder {
private static final Settings INSTANCE = new Settings();
}
public static Settings getInstance() {
return SettingsHolder.INSTANCE;
}
}synchronizedもなく、volatileもありません。
それなのにどうやってThread-safeを保証するのでしょうか?
核心はJVMのクラス初期化メカニズムです#
JVMはクラスやインターフェースを初めて能動的に使用するタイミングで初期化し、
その初期化手続きを同期化します。これはJLS §12.4.1、§12.4.2が保証している動作です。
Holderパターンはこのメカニズムをそのまま活用します。
Settingsクラスはロードされても SettingsHolderはまだ初期化されない
↓
getInstance() が初めて呼ばれる
↓
SettingsHolder.INSTANCE に初めてアクセス
↓
SettingsHolder の初期化が始まる
↓
INSTANCE = new Settings() — 初期化手続きの中で一度だけ実行
↓
以降のgetInstance()呼び出し → そのまま返すだけ(追加のロックなし)ロックなしでも安全で、実際に必要なタイミングまで初期化を遅らせることができ、実装の複雑さも低いです。
方式別比較#
| 実装方式 | 遅延初期化 | Thread-safe | パフォーマンス | 実装の複雑さ |
|---|---|---|---|---|
| 基本nullチェック | ✅ | ❌ | ✅ | 低 |
| synchronized | ✅ | ✅ | ❌ | 低 |
| Double-Checked Locking | ✅ | ✅(volatile必須) | ✅ | 高 |
| Holderパターン | ✅ | ✅ | ✅ | 低 |
その他知っておくといいこと#
Enum Singleton#
《Effective Java》でよく紹介される方式です。
public enum Settings {
INSTANCE;
}
// 使用
Settings.INSTANCE;最も短く、enum定数はシリアライズ時に名前ベースで特別扱いされるため、別途readResolveのような考慮が不要になります。
また、通常のリフレクションによるインスタンス化も許容されません。
ただし「アプリケーション起動と同時に必ず生成される」と一般化すると正確ではありません。
enum定数もそのenumタイプが初期化されるときに生成されるので、HolderパターンのようにgetInstance()呼び出し直前まで遅らせる形とは異なります。
Spring環境では?#
Springを使うなら、自分でSingletonパターンを実装するよりも、コンテナが管理するbean scopeを活用するケースが多いです。
@Component
public class Settings {
// Spring IoCコンテナがデフォルトのsingleton scopeで管理します。
}Spring Beanのデフォルトスコープはsingletonです。
ただしこれはGoFのSingletonのようにJVM全体でクラスあたり1つという意味ではなく、Spring IoCコンテナ内でbean definitionあたり1つという意味です。
そのためSpringを使っても状態の共有問題がなくなるわけではありません。
状態を持つフィールドがあると依然として並行性の問題が発生しうるので、通常はbeanをできるだけステートレスに設計します。
まとめ#
最初に見たコードが間違いである理由はロジックが単純だからではなく、
共有状態を複数のスレッドが同時に読み書きできるという点を考慮していなかったからです。
自分でSingletonクラスを実装する必要があり、遅延初期化が必要な場合はHolderパターンをまず検討する価値があります。
JVMが保証するクラス初期化手続きを活用するため、synchronizedやDCLよりも実装ミスが少なくなります。
遅延初期化が特に必要なく、enumの制約が問題なければenum方式も良い選択肢です。
「複雑な問題を複雑に解くのは実力ではない。
既にある保証を巧みに活用することが本当の実力だ。」
参考資料#
- Java Language Specification, SE 21, §12.4.1 When Initialization Occurs: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.1
- Java Language Specification, SE 21, §12.4.2 Detailed Initialization Procedure: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.2
- Java Language Specification, SE 21, §17.1 Synchronization: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.1
- Java Language Specification, SE 21, §17.4.5 Happens-before Order: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4.5
- Java Object Serialization Specification, §1.12 Serialization of Enum Constants: https://docs.oracle.com/en/java/javase/25/docs/specs/serialization/serial-arch.html#serialization-of-enum-constants
- Oracle Java Tutorials, Reflection API, Troubleshooting: IllegalArgumentException When Attempting to Instantiate an Enum Type: https://docs.oracle.com/javase/tutorial/reflect/special/enumTrouble.html
- Spring Framework Reference, Bean Scopes / The Singleton Scope: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html#beans-factory-scopes-singleton
