banner
lMingyul

lMingyul

记录穿过自己的万物
jike
twitter
github
bilibili

今回は本当にシングルトンパターンを理解できるのでしょうか?

一般的デザインパターンを学び、最も「簡単」な入門デザインパターンとして、まずはそれを練習してみましょう。

魂の問い#

シングルトンパターンとは?#

このクラスの唯一のインスタンスオブジェクトのみが存在することを許可します。

どういう意味でしょうか?普段、Java でオブジェクトインスタンスを新しく作成する際は、一般的に以下のようになります。

もし、オブジェクトインスタンスが 1 つだけ許可される場合、つまり new Demo() は 1 回だけ呼び出すことができるということです。
それをどうやって保証するのでしょうか?シングルトンパターンを使用します!

なぜシングルトンパターンが必要なのか#

どのような場合に、オブジェクトが 1 つだけ必要になるのでしょうか?

この問題を考えてみてください。複雑なシステムの中では、毎日大量のデータやメッセージを処理する必要があります。この時、大量のデータやメッセージを一意の識別子で区別する必要があります。そこで、一般的に使用される実装手段は:UUID、カスタムインクリメント ID ジェネレーターなどです。

UUID で生成された ID は長すぎて、保存が難しいです。次に、シングルトンパターンを研究するために、カスタムインクリメント ID ジェネレーターをどのように実装するかを考えてみましょう。以下のコードを見てみましょう。

出力結果:

  • AtomicLong は Java の java.util.concurrent.atomic パッケージにあるクラスで、long 型の値に対して原子操作を行うために使用されます。AtomicLong は、基盤となるロックなしのメカニズム(Java では CAS、Compare and Swap と呼ばれる)を使用して、long 型の変数に対する並行制御を実現します。これは、AtomicLong 内の複数のスレッドが synchronized や Lock を使用せずに安全に操作できることを意味します。
  • incrementAndGet は AtomicLong のメソッドで、現在の値に対して原子加算 1 操作を行い、増加後の値を返します。原子性は、マルチスレッド環境でこの加算操作が他のスレッドによって中断されないことを保証します。
  • System.identityHashCode は、オブジェクトアドレスに基づく(実際のメモリアドレスではない)整数ハッシュコードを返します。このハッシュ値を簡単に使用して、オブジェクトが等しいかどうかを判断できます(ハッシュ衝突の状況を考慮しない)。

IDGenerator クラスがインスタンス化されるとき、AtomicLong クラスもすでにインスタンス化が完了し、id の値は 0 になります。その後、IDGenerator インスタンスを介して 2 回 getId() メソッドを呼び出すことで、ID の自動増加の目的を達成しました。

しかし、今この IDGenerator が複数回インスタンス化された場合、何が起こるでしょうか?AtomicLong クラスも再度インスタンス化されるため、重複した IDが発生します。これではこの ID ジェネレーターは一意の識別子を提供できません!
では、どのような場合に IDGenerator クラスが複数回インスタンス化されるのでしょうか?

  • 異なる人が開発を行い、IDGenerator の実際の使用法を理解せずに、直接インスタンス化して使用する
    • ある人は、この IDGenerator クラスをグローバル静的変数として定義すれば、直接この静的変数を使用することで IDGenerator の多重インスタンス化を回避できると言います
    • これはプログラマー同士の合意に頼るしかなく、あまり信頼できません。インスタンス化を 1 回だけ許可する強制的な手段はないのでしょうか?
  • マルチスレッドの状況では、2 つのスレッドが同時に 1 つのメソッドを実行し、そのメソッド内で ID を生成する呼び出しが行われるため、ID の重複が発生しやすくなります。

次に、マルチスレッドの並行状況を見てみましょう。

まず、サンプルスレッドを定義します。スレッドの実行タスクは、Main クラス内の doSomething() メソッドを呼び出すことです。そして、doSomething() メソッドは ID を生成します。
出力結果:

出力結果からわかるように:

  • hashcode が異なるため、2 つのスレッドがそれぞれ異なる AtomicLong クラスオブジェクトを生成しました。
  • ID が重複して生成されました。
    2 つの異なるスレッドがそれぞれ 2 回 IDGenerator クラスオブジェクトを生成したため、異なる AtomicLong クラスオブジェクトが生成され、ID が重複して生成される現象が発生しました。

では、シングルトンパターンはどのようにして異なるスレッドが使用するために 1 つのインスタンスを生成するのでしょうか?

シングルトンパターンの実装方法#

まず、IDGenerator クラスを修正する必要があります。

修正点:

  • 静的定数を定義し、その静的定数に IDGenerator インスタンスオブジェクトを割り当てます。注意点は、これは定数であり、final修飾子が付いています。
  • IDGenerator のコンストラクタを private に変更します。
  • ユニークな IDGenerator インスタンスオブジェクトを返す公共のアクセス可能なメソッドを追加します。

次に、外部から ID を生成する呼び出し方法を修正します。

出力結果:

IDGenerator クラスはスレッド DemoTread-2 によって 1 回だけ初期化され、2 つのスレッドが生成した ID は重複しませんでした。

これがシングルトンパターンの役割です:

  1. 静的定数 instance は IDGenerator クラスがロードされるときに初期化され、1 回だけ初期化され、変更できません。これにより、グローバルに 1 つの IDGenerator オブジェクトと AtomicLong オブジェクトしか存在しないことが保証されます。
  2. IDGenerator のコンストラクタを private にすることで、このクラスの外部からのアクセスを拒否し、他の外部クラスが IDGenerator オブジェクトをインスタンス化することを防ぎます。
  3. 唯一の公共アクセスの静的メソッドを提供し、外部からユニークな IDGenerator オブジェクトを返します。

簡単に言うと:あなたが作成することを許可しない、私が唯一のオブジェクトを作成するので、それを使ってください


シングルトンの異なる実装方法#

シングルトンの実装方法は多くあり、包括的には:イーグルスタイル、レイジースタイル、ダブルチェック、静的内部クラス、列挙型があります。

イーグルスタイル#

実際、上記の実装はイーグルスタイルです。なぜイーグルスタイルと呼ばれるのでしょうか?
それは、オブジェクトがクラスがロードされるときにすでに作成されており、オブジェクトが必要になるのを待たずに初期化されているため、非常に急いでいるように見えるからです。だから「イーグルスタイル」と呼ばれます。

イーグルスタイルの典型的な書き方:

イーグルスタイルの利点#

  • スレッドセーフ:オブジェクトはクラスがロードされるときに作成されるため、他のスレッドが複数のオブジェクトを作成することはなく、スレッドセーフは JVM によって保証され、追加のマルチスレッド同期問題を処理する必要はありません。
  • コードがシンプルです。

イーグルスタイルの欠点#

  • リソースの浪費:オブジェクトはクラスがロードされるときに作成されるため、プログラムの実行中に使用しない場合、リソースが浪費されます。
  • クラスのロードが遅くなる:このクラスの初期化操作が複雑な場合、クラスのロードにかかる時間が増加し、プログラムの起動速度に影響を与える可能性があります。
  • 突発的な例外を処理できない:クラスがロードされる過程で、コンストラクタが例外をスローした場合、その例外を捕捉して処理することができません。

事前に初期化することに問題がある場合、遅延ロードする方法はあるのでしょうか?
あります、レイジースタイルです。


レイジースタイル#

イーグルスタイルとは反対に、オブジェクトが使用されるときに初期化を行います。では、どうやって実現するのでしょうか?

修正点:

  • 静的定数 instance を静的変数に変更し、この変数の初期化をgetInstance()メソッドに移動します。
  • getInstance()メソッドに null チェックを追加し、instance 変数が空であれば初期化を行います。
    出力結果

あれ、なぜ 2 つのインスタンスオブジェクトが生成されるのでしょうか?
実際、上記のコードには少し問題があります。見た目上、null チェックを追加して IDGenerator オブジェクトの多重インスタンス化を防ぐように見えますが、マルチスレッドの並行状況では、1 つのスレッドが IDGenerator オブジェクトを初期化している間に、別のスレッドがgetInstance()メソッドを呼び出すと、IDGenerator オブジェクトが複数回初期化されることになります。
出力結果から、DemoTread-2 と DemoTread-1 スレッドが両方とも2023-07-25 21:28:09に IDGenerator オブジェクトの初期化を開始していることがわかります。これにより、エラーが発生します。

では、どうやってこれを避けるのでしょうか?getInstance()メソッドにsynchronizedキーワードを追加して、同時に 1 つのスレッドだけがこのメソッドにアクセスできるようにします。

レイジースタイルの利点#

  • 遅延ロードが可能で、必要なときに作成されるため、不要なリソースの浪費を避けることができます。

レイジースタイルの欠点#

  • スレッドセーフを確保するために Synchronized キーワードを追加する必要があり、これにより毎回アクセス時に同期が必要になり、頻繁にロックを取得・解放することや、並行度が低い問題が発生し、パフォーマンスに影響を与える可能性があります。

では、遅延ロードとパフォーマンスの問題を両立させる方法はあるのでしょうか?あります、ダブルチェックです!


ダブルチェック#

直接コードを見てみましょう。

修正点:メソッドの修飾に使用される synchronized をクラスに変更し、クラスレベルのロックを実現します。

この方法は、なぜ多重の synchronized スレッド同期操作を実行しないのでしょうか?

  • まず、スレッドがgetInstance()メソッドに入ると、現在の状態をチェックします:IDGenerator がすでにインスタンス化されている(現在の instance が null でない)か、IDGenerator がまだインスタンス化されていない(現在の instance が null である)かの 2 つの状態があります。
    • もし instance が null であれば、1 回の synchronized 同期初期化操作を行い、オブジェクトをインスタンス化します。
    • もし instance が null でなければ、直接返却し、synchronized 同期初期化操作を行いません。
      これにより、レイジースタイルの呼び出しごとに同期初期化操作を行う問題が解決されます。

最初の null チェックは何のためにあるのでしょうか?2 回目の null チェックは何のためにあるのでしょうか?削除してもいいのでしょうか?試してみましょう。コードを以下のように変更します。

出力結果

異常な現象が発生しました。2 回目の null チェックを削除すると、オブジェクトが 2 回初期化されました。

  • DemoTread-2 と DemoTread-1 スレッドはほぼ同時に第一層の null チェックを通過しました。
  • その後、DemoTread-2 スレッドがロックを取得し、オブジェクトのインスタンス化を行いました。
  • この時、DemoTread-1 スレッドは DemoTread-2 がロックを解放するのを待っています。DemoTread-2 が新しいインスタンスを作成した後、ロックを解放します。
  • 次に、DemoTread-1 スレッドもロックを取得し、インスタンスを作成します!

したがって、2 回目のチェックif (instance == null)は、現在のスレッドがロックを取得した後に、再度 instance が null であるかどうかを確認するためのものです。もし null でなければ、直接このインスタンスを返し、複数のインスタンスを作成することを避けます。

さて、ここまで来ると、これが完璧なシングルトンパターンの実装だと思いますか?
まだです。上記のコードにはまだ問題があります。静的変数 instance に volatile キーワードを追加する必要があります。この追加は何のためでしょうか?

この静的変数の可視性命令の再配置を禁止します。

  • 可視性:volatile キーワードは、あるスレッドが書き込んだ値を他のスレッドがすぐに見ることができることを保証し、複数のインスタンスオブジェクトが生成されるのを防ぎます。
  • 命令の再配置を禁止:JVM はコードを最適化する際に、オブジェクトの初期化とインスタンスの参照の割り当ての 2 つの操作を再配置する可能性があります。これにより、他のスレッドが instance を読み取るときに、すでに非空のオブジェクトを見てしまうことがありますが、そのオブジェクトはまだ初期化が完了していない(コンストラクタ内の残りのコードロジックが実行されていない)場合があります。他のスレッドが直接使用すると、不完全なオブジェクトを使用することになります。したがって、volatile キーワードを追加して命令の再配置を禁止します。

静的内部クラス#

ダブルチェックの方法は問題を解決できますが、同期操作を追加し、チェックを追加する必要があり、少し複雑です。もっとシンプルなコード実装はありますか?あります、静的内部クラスを使用します。
具体的な実装方法は、シングルトンを実現するクラス内に別の静的内部クラスを定義することです。

これはどのようにしてシングルトンを実現するのでしょうか?

  • 静的内部クラスの特性に基づいて、1 回だけロードされるため、INSTANCE は 1 回だけ初期化されます。
  • 次に、getInstance()メソッドが呼び出されたときにのみロードされるため、遅延ロードも保証されます。
    では、スレッドセーフの問題はないのでしょうか?ありません。静的内部クラスのロードは JVM によって実現されるため、スレッドセーフです。

静的内部クラスを使用してシングルトンを実現する方法は、効率的で不要なスレッド同期操作を避け、遅延ロードを実現し、スレッドセーフを保証します。完璧です!
唯一の欠点は、もう 1 つのクラスを定義する必要があることですが、もっとシンプルな方法はありますか?あります、列挙型です!


列挙型#

直接コードを見てみましょう。

見てください、コードはとてもシンプルです。なぜ列挙型がシングルトンを実現できるのでしょうか?
Java は列挙型を処理する際に、クラスロードのメカニズムを使用して列挙型の一意性とスレッドセーフを保証します。

  • スレッドセーフ:Java のクラスロードプロセスでは、クラスをロードする際に Java 仮想マシンがそのクラスにロックをかけ、他のスレッドが同時にロードするのを防ぎます。クラスのロードが完了するまでこのロックは解放されず、スレッドセーフが保証されます。
  • 一意性:列挙型のインスタンスは、列挙型がロードされるときに一度にすべて作成され、その後は変更されないため、一意性が保証されます。

ただし、この実装はシンプルに見えますが、完璧ではありません。遅延ロードをサポートしていないため、すべてのクラスがクラスロード段階でインスタンス化され、"必要なときにすぐに" 利用できるわけではありません。

したがって、これだけの実装方法があれば、どの方法が最も良いのでしょうか?
最良の方法はなく、最も適した方法があり、異なるシナリオに応じて具体的に分析する必要があります。


参考資料#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。