banner
lMingyul

lMingyul

记录穿过自己的万物
jike

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

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

魂の問い#

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

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

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

// クラス名 インスタンス名 = new クラス名();
Demo demo = new Demo();

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

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

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

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

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

public static void main(String[] args) {
    IDGenerator idGenerator = new IDGenerator();
    long firstId = idGenerator.getId();
    long secondId = idGenerator.getId();
    printWithTreadName("firstId: " + firstId);
    printWithTreadName("secondId: " + secondId);
}

public static void printWithTreadName(String msg) {
    System.out.println("TreadName: " + Thread.currentThread().getName() + ", " + msg);
}
public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    public IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

出力結果:

TreadName: main, IDGenerator init
TreadName: main, id hashcode: 1808253012
TreadName: main, id value: 0
TreadName: main, firstId: 1
TreadName: main, secondId: 2
  • 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 つのスレッドが同時にメソッドを実行し、そのメソッド内で ID を生成する呼び出しが行われると、ID の重複が発生しやすくなります。

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

public class Main {
    public static void main(String[] args) {
        testWithoutSingleton();
    }

    public static void testWithoutSingleton() {
        // 2つのスレッドを作成
        DemoTread t1 = new DemoTread();
        DemoTread t2 = new DemoTread();
        t1.start();
        t2.start();
    }

    public static void doSomething() {
        printWithTreadName("generator id: " + new IDGenerator().getId());
    }

    public static void printWithTreadName(String msg) {
        System.out.println("TreadName: " + Thread.currentThread().getName() + ", " + msg);
    }
}
public class DemoTread extends Thread {

    static int threadNo = 1;

    public DemoTread () {
        super("DemoTread-"  + threadNo++);
    }

    @Override
    public void run () {
        doSomething();
    }
}

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

TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-2, id hashcode: 359513719
TreadName: DemoTread-1, id hashcode: 2107584424
TreadName: DemoTread-1, id value: 0
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-1, generator id: 1
TreadName: DemoTread-2, generator id: 1

出力結果からわかること:

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

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

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

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

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);
    // 唯一のIDGeneratorインスタンスオブジェクトを初期化
    private static final IDGenerator instance = new IDGenerator();
    // コンストラクタをprivateに変更
    private IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }
    // 唯一のIDGeneratorインスタンスオブジェクトを返す公共アクセスメソッドを追加
    public static IDGenerator getInstance() {
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

修正点:

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

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

public static void doSomething() {
    printWithTreadName("generator id: " + IDGenerator.getInstance().getId());
}

出力結果:

TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-2, id hashcode: 359513719
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-2, generator id: 1
TreadName: DemoTread-1, generator id: 2

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

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

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

簡単に言えば:あなたに作成させない、私が唯一のオブジェクトを作成するから、それを使ってください


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

シングルトンの実装方法は多くあり、饿汉式、懒汉式、二重検査、静的内部クラス、列挙型などがあります。

饿汉式#

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

饿汉式の典型的な書き方:

public class IDGenerator {

    private static final IDGenerator instance = new IDGenerator();

    private IDGenerator() {}

    public static IDGenerator getInstance() {
        return instance;
    }
}

饿汉式の利点#

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

饿汉式の欠点#

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

事前に初期化することに問題がある場合、遅延ロードを実現する方法はあるのでしょうか?
はい、懒汉式です。


懒汉式#

鶏と卵のように、オブジェクトが使用されるときに初期化されるのを待つ方法です。では、どうすればよいのでしょうか?

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    private static IDGenerator instance;

    private IDGenerator() {
        Instant instant = Instant.ofEpochMilli(System.currentTimeMillis());
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
                .withZone(ZoneId.systemDefault());
        String formattedTime = formatter.format(instant);
        printWithTreadName("現在の時間:" + formattedTime);
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

    public static IDGenerator getInstance() {
        if (instance == null) {
            instance = new IDGenerator();
        }
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

修正点:

  • 静的定数 instance を静的変数に変更し、この変数の初期化をgetInstance()メソッドに移動しました。
  • getInstance()メソッドに null チェックを追加し、instance 変数が null の場合に初期化を行います。
    出力結果
TreadName: DemoTread-2, 現在の時間:2023-07-25 21:28:09
TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-1, 現在の時間:2023-07-25 21:28:09
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-1, id hashcode: 1457402119
TreadName: DemoTread-2, id hashcode: 822717100
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-2, generator id: 1
TreadName: DemoTread-1, generator id: 1

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

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

public static synchronized IDGenerator getInstance() {
    if (instance == null) {
        instance = new IDGenerator();
    }
    return instance;
}

懒汉式の利点#

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

懒汉式の欠点#

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

では、遅延ロードとパフォーマンスの問題を両立させる方法はあるのでしょうか?はい、二重検査です!


二重検査#

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

public static IDGenerator getInstance() {
    if (instance == null) {
        synchronized (IDGenerator.class) {
            if (instance == null) {
                instance = new IDGenerator();
            }
        }
    }
    return instance;
}

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

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

  • まず、スレッドがgetInstance()メソッド内に入ると、現在の状態を確認します:IDGenerator がすでにインスタンス化されている(現在の instance が null でない)、または IDGenerator がまだインスタンス化されていない(現在の instance が null である)という 2 つの状態があります。
    • instance が null の場合、synchronized による初期化操作を行い、オブジェクトをインスタンス化します。
    • instance が null でない場合、直接返しますので、synchronized による初期化操作は行いません。
      これにより、懒汉式の呼び出しごとに同期初期化操作を行う問題が解決されます。

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

public static IDGenerator getInstance() {
    if (instance == null) {
        printWithTreadName("第一层 null 判断に入る");
        synchronized (IDGenerator.class) {
            printWithTreadName("synchronized コードブロックに入る");
            instance = new IDGenerator();
        }
    }
    return instance;
}

出力結果

TreadName: DemoTread-2, 第一层 null 判断に入る
TreadName: DemoTread-1, 第一层 null 判断に入る
  
TreadName: DemoTread-2, synchronized コードブロックに入る
TreadName: DemoTread-2, 現在の時間:2023-07-26 00:48:24
TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-2, id hashcode: 26262847
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-1, synchronized コードブロックに入る
TreadName: DemoTread-1, 現在の時間:2023-07-26 00:48:24
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-1, id hashcode: 1846522864
TreadName: DemoTread-1, id value: 0
TreadName: DemoTread-1, generator id: 1

異常な現象が発生しました。2 回目のチェックを削除すると、オブジェクトが 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 キーワードを追加する必要があります。これにはどのような利点があるのでしょうか?

private static volatile IDGenerator instance;

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

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

静的内部クラス#

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

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    private static class SingletonHolder {
        private static final IDGenerator INSTANCE = new IDGenerator();
    }

    private IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

    public static IDGenerator getInstance() {
        return SingletonHolder.INSTANCE;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

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

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

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


列挙型#

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

public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

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

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

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

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


参考資料#

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