banner
lMingyul

lMingyul

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

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

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

魂の問い#

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

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

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

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

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

どのような状況で、オブジェクトが一つだけ必要なのでしょうか?

この問題を考えてみてください。複雑なシステムの中で、毎日大量のデータやメッセージを処理する必要があります。この時、大量のデータやメッセージを一意の識別子で区別する必要があります。この時、私たちの主な一般的な実装手段は: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 の多重インスタンス化を回避できると言います。
    • これはプログラマー同士の合意に頼るしかなく、あまり信頼性がありません。インスタンス化を一度だけ許可する強制的な手段はないのでしょうか?
  • マルチスレッドの状況で、2 つのスレッドが同時に 1 つのメソッドを実行し、そのメソッド内で ID を生成する呼び出しが行われると、ID が重複する現象が発生しやすくなります。

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

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

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

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

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

シングルトンパターンはどのように実現されるのか?#

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

修正点:

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

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

出力結果:

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

これがシングルトンパターンの効果です:

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

簡単に言えば:あなたが作成することを許可しません。私がユニークなオブジェクトを作成しますので、それを使ってください


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

シングルトンの実装方法には多くの種類があり、例えば:イーグルスタイル、レイジーロード、ダブルチェック、静的内部クラス、列挙型などがあります。

イーグルスタイル#

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

イーグルスタイルのクラシックな書き方:

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

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

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

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

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


レイジーロード#

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

修正点:

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

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

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

レイジーロードの利点#

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

レイジーロードの欠点#

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

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


ダブルチェック#

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

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

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

  • まず、スレッドがgetInstance()メソッドに入ると、現在の状態をチェックします:2 つの状態しかありません。IDGenerator がすでにインスタンス化されている(現在の instance が null でない)、または IDGenerator がまだインスタンス化されていない(現在の instance が null である)。
    • もし instance が null であれば、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 キーワードを追加します。

静的内部クラス#

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

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

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

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


列挙型#

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

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

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

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

したがって、これだけの実装方法がある中で、どの方法が最も良いのでしょうか?
最良の方法はありません。最も適した方法があります。異なるシーンに応じて具体的に分析してください。


参考資料#

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