한국어 | English | 日本語
Webアプリケーションエンジニア (経験8.8年)
技術・開発
engineering
ウェブフロントエンドと バックエンド開発を扱います

6. シングルトンパターンとレースコンディション

Singleton Comic
コードを書いていると、変数やメソッドを1つだけ定義/作成し、あらゆる場所で共有したい場合があります。これには2つの解決策があります。1つ目は静的変数/メソッド、2つ目は今回学ぶシングルトン「オブジェクト」です。
まず、JVM内のヒープ領域と静的領域の違いを通して静的変数/メソッドを学び、シングルトン「オブジェクト」が先行する静的変数/メソッドとどのような点で異なるかを比較しながら学習します。次に、単一オブジェクトにあまりにも多くのリクエストが一度に集中すると発生するレースコンディションと、その解決策についても一緒に見ていきます。

コードを書いていると、変数メソッドたった一つだけ生成してあらゆる場所で共有したい場合があります。これには、クラス内に静的変数/メソッドを定義して使用する方法と、シングルトンオブジェクトを一つだけ生成する方法があります。

静的変数とメソッド

まず、オブジェクトの初期化なしに、該当クラスの静的変数と静的メソッドを使用する方法です。これは、動的な部分のヒープ領域ではなく静的領域にロードすることで実現され、プログラム内のすべてのスレッドが単一の変数とメソッドにアクセスできるようになります。ヒープ領域と静的領域の違いは、以下で理解できます。

Java、JVMメモリ

JavaはJVM上でプログラムを動作させます。JVMのMはMachineを意味するように、小さなOSに該当し、ガベージコレクションのような独自のメモリ管理体系を持っています。メモリ領域は以下の3つに分けられます。

オブジェクト生成の最も根幹となるクラスは、バイトコード形式で静的領域にロードされます。そのクラスをオブジェクト化するたびに、そのオブジェクトとオブジェクトの変数、メソッドは上記のクラスバイトコードを参照して生成された後、ヒープ領域にロードされます。静的変数およびメソッドは、オブジェクトなしでクラスに存在するものであるため、静的領域に保存されます。

静的領域へのクラスロードおよびオブジェクト生成を担当するものをクラスローダーと呼びますが、このローダーをカスタム変更しない限り、通常JVM上には一つだけ存在します。これは、もし二つのクラスローダーを持つように変更した場合、静的変数がそれぞれのクラスローダーの静的領域にロードされるという意味です。

シングルトンパターン

これまで学んだ静的変数およびメソッドは次のように整理でき、シングルトンとの違いに基づいて理解すると良いでしょう。

静的変数とメソッド

class Calculator {
  // * Public: Can be initialized from outer
  public Calculator() {} // 原文のCaculatorをCalculatorに修正
  // * Static: sum(a, b)
  public static int sum(Integer a, Integer b) { // 原文のInteger a, Integer aをInteger a, Integer bに修正。戻り値のintを追加
    return a + b;
  }
}

シングルトン変数とメソッド = 単一オブジェクト

class Calculator {
  // * Private: Cannot be initialized from outer
  private Calculator() {} // 原文のCaculatorをCalculatorに修正
  // * Non-Static: sum(a, b)
  public int sum(Integer a, Integer b) { // 原文のInteger a, Integer aをInteger a, Integer bに修正。戻り値のintを追加
    return a + b;
  }

  // * Singleton: Can be initialized only once using getInstance()
  private static Calculator uniqueInstance;
  public static Calculator getInstance() {
    if (uniqueInstance == null) {
      uniqueInstance = new Calculator();
    }
    return uniqueInstance;
  }
}

シングルトンは一見単純な概念ですが、問題は、オブジェクトが既に存在するかどうかを判断するgetInstance()関数に多数のスレッドが同時にアクセスした場合、各スレッドがオブジェクトがまだ生成されていないと独立して判断し、複数のオブジェクトを生成してしまう可能性がある点です。つまり、シングルトンオブジェクトが複数生成/存在してしまう可能性があるということです。

このような致命的な問題にもかかわらず、現場でこれについてそれほど深く考慮されない理由は、シングルトンオブジェクトが内部変数/状態値を持たず、上記のCalculatorの例のようにパラメータを受け取って適切に処理するものがほとんどであるため、たとえシングルトンオブジェクトが複数生成/存在したとしても、大きな問題になることが少ないためです。

しかし、シングルトンオブジェクトが独自の状態値を持つ場合は話が異なります。二つのシングルトンオブジェクトをそれぞれ異なるスレッドが見ていると、全く異なる状態を参照するという恐ろしい状況が発生します。多数が単一のリソースにアクセスする状態を競合状態と呼び、英語ではRace Conditionと呼びます。これの解決には、「さあ、ゆっくり一人ずつ入ってください」という**ロック(Lock)**を適用する必要があります。もちろん、性能低下はおまけです。

レースコンディション

Javaのオブジェクト、変数、メソッドはすべて基本的にノンブロッキングであるため、前述のように、多数のスレッドが単一のシングルトンオブジェクトに同時にアクセスした場合、各スレッドで一貫性のない状態を読み取ってしまう問題が発生します。

シングルトンの例で使用されたCalculatorクラスのgetInstance()関数に、二つのスレッドが同時に侵入したと仮定しましょう。同時にif (uniqueInstance == null)ブロックに侵入した時点で、**どのスレッドも次の行であるnew Calculator()を実行していなかったと仮定すると、両方のスレッドはuniqueInstanceがnullであると判断します。**そして次の行で、両方のスレッドがそれぞれ新しいオブジェクトを生成することになり、こうなると二つのスレッドは一つのオブジェクト関数ではなく、各自のオブジェクト関数を参照することになります。単純な計算オブジェクトであれば大きな影響はありませんが、もし一つの状態を共有しようとするオブジェクトであれば、二つのスレッドが互いに異なる状態を見ているという恐ろしい状況が発生します。

Thread1: getInstance()
  if (uniqueInstance == null) {         // 2019-03-03 00:00:01
    uniqueInstance = new Calculator();  // 2019-03-03 00:00:03 - Calculator オブジェクト 1 生成 (Thread1)
Thread2: getInstance()
  if (uniqueInstance == null) {         // 2019-03-03 00:00:02
    uniqueInstance = new Calculator();  // 2019-03-03 00:00:04 - Calculator オブジェクト 2 生成 (Thread2)

これを解決するために最も単純に考えられるのは、関数単位のブロッキングです。

関数単位のブロッキング - Synchronized

多数のスレッドが一つの関数にアクセスしようとした場合、一つのスレッドがその関数を実行している間は、他のスレッドが待機するようにブロッキングする手法です。Javaが提供するsynchronizedキーワードを使用すると、簡単に該当関数の呼び出しをブロッキングできます。これで、Thread 1が該当関数を呼び出して終了するまで、Thread 2はその関数呼び出しを待ち続ける必要があります。これにより、二つのスレッドが一つの関数を同時に呼び出すことはなくなるように見えます。

class Calculator {
  ...

  public static synchronized Calculator getInstance() {
    if (uniqueInstance == null) {
      uniqueInstance = new Calculator();
    }
    return uniqueInstance;
  }
}

しかし、シングルトンのgetInstance()関数が上記の例のロジックよりも複雑で実行時間が長い場合、他のスレッドは一つのスレッドがgetInstance()呼び出しを完了するその長い時間中、停止しなければならないという性能上の問題があります。このため、関数単位のブロッキングではなく、関数内のその変数だけをピンポイントでブロッキングする方が良いでしょう。

変数生成単位のブロッキング - Volatile (DCL)

本来の目的は「変数」のスレッド間共有であるため、関数単位のブロッキングを使用して、変数以外の残りの長いロジック実行の時間まで手をこまねいて性能問題まで発生させる理由は特にありません。賢いプログラマーたちの悩みの結果、「関数」ではなく「変数」単位のブロッキングを考案し、これを**DCL (Double Checked Locking)**と呼びます。なぜ名称がDouble Checkedなのかは、以下のコードを見ると、オブジェクト生成ロジックに入る前と、入った後、生成する前にもう一度nullチェックを行うことから推測できます。

  private static Calculator uniqueInstance;
  public static synchronized Calculator getInstance() {
    if (uniqueInstance == null) {
      uniqueInstance = new Calculator();
    }
    return uniqueInstance;
  }
  private volatile static Calculator uniqueInstance;
  public static Calculator getInstance() {
    if (uniqueInstance == null) {
      synchronized (Calculator.class) { // Classオブジェクトで同期
        if (uniqueInstance == null) {
          uniqueInstance = new Calculator();
        }
      }
    }
    return uniqueInstance;
  }

従来の関数ブロッキング方式ではgetInstance()関数にsynchronizedが付いているのに対し、変数生成単位のブロッキングでは変数にvolatileが追加され、該当関数内ではvolatileクラスをsynchronizedで指定していることが分かります。

可視性(Visibility)問題

すべてのプログラムおよびスレッドはCPUを介して演算を実行し、演算に必要な変数値は「メインメモリ」からCPUのすぐ隣にある「キャッシュ」に取得されて使用されます。もし二つのスレッドがそれぞれ異なるCPU(マルチコア環境)で一つのシングルトンオブジェクトを共有した場合、何が起こるでしょうか?

Race Condition

二つのスレッドが共有する一つのオブジェクトは、基本的に「メインメモリ」にロードされています。 各スレッドが各CPUで値を変更する場合、

二つのスレッドが同時に変数の値にアクセスする場合、最初のスレッドが自分が割り当てられたCPU内のキャッシュの変数を先に変更したにもかかわらず、まだメインメモリに書き込んでいないため、二番目のスレッドは変更された値を知らないまま、自分のCPUで独立して値の変更を実行するという問題が発生します。このスレッド間の変数同期または不一致の問題は、あるスレッドの値の更新を他のスレッドでは見ることができないという意味で可視性(Visibility)問題と呼ばれます。

また、複数のスレッドが単一のCPUで実行される場合でも、JITコンパイラによってアセンブリレベルのコード再配置(Reorder)が発生し、スレッド間で参照する変数値が異なる可能性があるという記事も見た記憶があります。

DCL (Double Checked Locking) - Volatileの意味

可視性問題を解決するために、「キャッシュ」と「メインメモリ」間で**読み取られた(READ)値が一致するように強制するのがvolatile**キーワードです。変数にvolatileキーワードを追加すると、その変数はCPUが「キャッシュ」の値を読み取る際に同時に「メインメモリ」の値をReadすることを保証します。あるスレッドが値を変更した場合、すぐにメインメモリに適用され、他のスレッドが値を読み取るときに最新の値を読み取ることができるようになるのです。

しかし、二つのスレッドが同じメインメモリの値を取得して変更する場合は依然として問題であるため、値を書き込む作業には避けられずブロッキングをかける必要があります。**値を変更(WRITE)する関数にsynchronized**キーワードを通じてブロッキングをかけます。これにより、あるスレッドが書き込みを行っている間は、他のスレッドは待機し、前のスレッドが書き込みを終えるとすぐにメインメモリから値を読み取って次の書き込みを進めることになります。ちなみに、これはトランザクションの最高レベルの隔離段階であるIsolation段階に該当する概念でもあります。

変数使用単位のブロッキング - Lazy Holder

本当にこの記事を読んでいる読者の皆様には申し訳ありませんが、残念ながら、変数生成単位のブロッキングによって単一生成が「完全に」保証されたわけではありません。まさか、CPUキャッシュまで考慮したのに、一体何をまた見なければならないのでしょうか。トランジスタレベルまで見なければならないのでしょうか?まだ一段階残っています。

DCLによってオブジェクトの単一生成自体は保証されました。ただし、単一オブジェクト生成の直後に他のスレッドがその変数をすぐに使用しようとすると、まだ完全に生成されていない不完全なオブジェクトを使用してしまう可能性があるという問題が存在します。単一生成を開始すると、該当クラスのuniqueInstance = new Calculator()を通じてコンストラクタが実行されますが、コンストラクタが少しでも複雑な場合、完全なオブジェクトが作成されるまでにはもう少し時間がかかるでしょう。しかし、そのオブジェクトにアクセスする他のスレッドは、該当行uniqueInstance = new Calculator()が実行されたことを認識するだけで、オブジェクトが完全に作成されたかどうかは待ちません。この時、まだ完全に生成されていない不完全なオブジェクトを他のスレッドが取得して使用してしまうのです。これをout-of-order write問題と呼びます。

解決策は、該当オブジェクトが単に生成されたか否かではなく、完全に生成されたことを保証することです。これを保証する方法は、さらに賢いプログラマーたちによって本当に多様に提案されてきましたが、独創的なものもありますが、その中でも最も理解しやすいのは以下の通りです。

public class Calculator {
  ...
  private static class LazyHolder {
      private static final Calculator UNIQUE_INSTANCE = new Calculator();
  }
  public static Calculator getInstance() {
      return LazyHolder.UNIQUE_INSTANCE;
  }
}

static finalで定義されたUNIQUE_INSTANCEは、クラスローダーによってプログラム開始時に最も早く静的領域に直接ロードされます。これは、静的変数/メソッドの方式を組み合わせたものです。これにより、getInstance()が呼び出されるUNIQUE_INSTANCE = new Calculator()が必ず実行され、オブジェクトの存在を保証できるようになります。

私はC#を少し使ったことがありますが、シングルトンオブジェクトを定義するためにクラスを作成する方法を参照していたとき、なぜ以下のように複雑に定義するのか理解できませんでした。今回シングルトンオブジェクトを学ぶことで、マルチスレッド環境でのオブジェクト生成および使用に関するブロッキングを保証するためだったのだと理解できるきっかけとなりました。

public sealed class Singleton
{
    private static readonly Lazy<Singleton> lazy = new Lazy<Singleton>(() => new Singleton());
    public static Singleton Instance { get { return lazy.Value; } }
    private Singleton() {}
}

実際、シングルトンを理解するためには、本記事のシングルトンパターンだけ読めば十分です。わざわざレースコンディション、可視性問題、DCL、Volatile、LazyHolderといった概念まで頭が破裂するほど知る必要はありません。しかし、C#を使用するユーザーであれば、なぜLazyHolder構文を使用するのかを知っておくべきですし、他の言語開発においても状態を内包するクラスのシングルトンオブジェクトを生成するケースが全くないとは言い切れません。


  1. https://gampol.tistory.com/entry/Double-checked-locking%EA%B3%BC-Singleton-%ED%8C%A8%ED%84%B4
  2. http://thswave.github.io/java/2015/03/08/java-volatile.html
6. シングルトンパターンとレースコンディション
Author
Aaron
Posted on
Licensed Under
CC BY-NC-SA 4.0
CC BY-NC-SA 4.0
同じカテゴリーの関連記事
最新記事
LLMフィルターが奪う会話の筋肉とコミュニケーション様式
会話における無礼さを濾過し、洗練された回答を生成するLLMツールが日常化した現代において、私たちは本当に思慮深い会話をしているのだろうか?リアルタイムのコミュニケーションにおける数多くの失敗を通じて磨かれるべき会話能力が、外部ツールに依存することで退化している現象と、それがもたらす社会的な不安や世代間の行動様式の変化について考察する。
シニア採用における年俸交渉の最適なタイミングと戦略
年俸交渉は単なる数字の交換ではなく、心理的な駆け引きとタイミングが重要です。本稿では、企業側にとって、候補者が計算的な態度を取りがちな最終合格後よりも、採用プロセスの初期段階から段階的に交渉を進めることが、なぜより効率的であり、率直な情報の共有に繋がるのかを考察します。
法治主義の限界と人間の多様性
全ての人間の行為を単一の法体系で規制できるという信念は、傲慢であるかもしれない。この記事は、中世の階層的な統制から脱却し、現代の無限の自由を手に入れた人類が直面する法治主義の逆説と、多様性という名のもとに深化する社会的強制力と他者への悪魔化現象を鋭く分析する。
토스트 예시 메세지