よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

static と synchronized から始めるマルチスレッド入門 〜後編:synchronized の使い方と注意点〜

初めに

本記事は3部構成になっております。

  • 『前編:static とメモリ管理』

static と synchronized から始めるマルチスレッド入門 〜前編〜 - よしたろうブログ

  • 『中編:マルチスレッドの基本的な用語と概念』

static と synchronized から始めるマルチスレッド入門 〜前編〜 - よしたろうブログ

  • 『後編:synchronized の使い方と注意点』

static と synchronized から始めるマルチスレッド入門 〜後編〜 - よしたろうブログ

本記事では実務の中で使用している静的解析ツール(SonarLint)に指摘された警告の修正を入り口に、マルチスレッドについて説明したいと思います。

本記事では、以下の事柄について解説していきます。

  • static の意味
    • メモリ管理の方法
    • メモリ上にプログラムを展開する意味と理由
    • atatic とインスタンスの違い
    • static の使い所と注意点
  • マルチスレッドの基本的な話
    • プロセスとは
    • スレッドとは
    • スレッドセーフとは
    • 並行と並列
  • 同期制御と排他制御の概論と基本
  • アトミック性と可視性
  • synchronizedとは
    • インクリメントは非アトミックな操作
    • synchronized はミューテックス(単一ロック)
    • ミューテックスはパフォーマンスには良くない
    • synchronized メソッドと synchronized ブロックの違い
    • その他の注意点

取り扱わない話

  • 同期制御と排他制御の各論
  • Thread クラス
  • ReentrantLock クラス
  • Atomic クラス
  • wait()/notify()
  • Executorフレームワーク
  • 不変オブジェクト
    • マルチスレッドにおける不変オブジェクトについては以下の過去記事にて解説してます。

目次

前編は1~3、中編は4~5、後編は6~9 です。

6. これまでの要約

  1. プログラム実行には実行内容をメモリ上に展開する必要がある

    1. ほとんどのパソコンはノイマン型コンピュータ
    2. HDDなどの補助記憶装置へのアクセスはオーバーヘッドが大きいのでさせない
    3. そのため、これらのコンピュータはプログラムを実行するために、メモリにプログラムを展開する必要がある。
    4. メモリは、プログラム実行中に処理されるデータの一時的な記憶領域。
    5. プロセッサは、プログラムの実行に必要な情報を得るためにメモリにアクセスする。
  2. Javaには、プログラムが実行される際に使用される複数のメモリ領域がある。

  3. 主なメモリ領域は、スタック領域、ヒープ領域、static領域、コンスタントプール。これらのメモリの管理方法やデータの使用目的によって、メモリの空間が定義されている。 static 領域のメモリ空間

  4. static領域の特徴と使い分けは以下の通り

    • static領域は、クラススコープ内で static キーワード付きで変数宣言を行う。プログラムのどの部分からでも参照することが可能。
    • static領域に格納されるデータは、プログラムの開始から終了まで有効。インスタンス化は必要ない。
    • static領域には、プログラム全体で共有する変数や定数を格納する。
      staticメソッドは、インスタンスを生成しなくても呼び出すことができるため、ユーティリティメソッドやファクトリーメソッドなどに利用される。しかし、static変数やメソッドを多用すると、プログラムの拡張性低下・メモリの無駄遣いになるため、必要最小限に使用することが望ましい。
  5. static メソッドの使い所

    • ユーティリティメソッド
      • インスタンスを必要としない共通の処理を実行する場合。例えば、Math クラスの abs メソッドは、引数に渡された数値の絶対値を返す。このような処理は、インスタンス化する必要がないため、static メソッドとして実装される。
    • ファクトリメソッド
      • インスタンスを作成するためのメソッドを static メソッドとして定義する。例えば、Java の Collections クラスには、空のリストやイテレーターなどを作成するための static メソッドが用意されている。
  6. static フィールドの使い所

    • 定数
      • static フィールドを定数として定義することができます。例えば、Java の Math クラスには、πの値を表す static フィールドが定義されている。
    • 共有データ
      • クラスの全てのインスタンスで共有されるデータを static フィールドとして定義することができる。環境変数などが定義されたファイルやインスタンスなどの様に一度決まれば変更されない、もしくは変更される場合にそれを参照する対象全てが同期して欲しいものなど。
  7. static 領域のフィールド・メソッドはどこからでもアクセスできる

    • 他のクラスからアクセスする際には、以下のようにクラス名を指定して参照可能。オブジェクトをインスタンス化する必要はない。MyClass.increment();
  8. static メソッドやフィールドは、オブジェクトの状態に依存しない

    • static 領域にあるメソッドやフィールドは、プログラムの開始から終了まで、常にメモリに展開されていてインスタンス化の必要はない。
    • インスタンスごとに異なる値を持つインスタンスフィールド。異なる値に依存する、つまり、状態に依存するのがインスタンス
  9. staticメソッドの注意点

    • 参照共有・アンスレッドセーフ問題
      • static メソッドは、クラスのすべてのインスタンスで共有されるため、意図しない変更が発生する可能性がある。
      • static メソッドは、他のスレッドから同時に呼び出される可能性があるため、スレッドセーフではない。
  10. プロセス(Process)

    • プログラムの実行単位。OSによって管理される実行中のプログラムのインスタンス
    • 親プロセスからフォークされ、子プロセスがあり、プログラムコード、データ、スタックなどの情報を保持する仮想アドレス空間を持つ image.png
    • メモリ空間やCPUなどのシステムリソースを割り当てられている
    • 複数のスレッドを含めることができる
    • 独立しており、他のプロセスのメモリに直接アクセスできない
      • プロセス間でデータを共有するには、共有メモリやパイプ、ソケット通信などのメカニズムを使用する必要がある
    • データ不整合や競合状態が発生しないように、適切な同期処理が必要
  11. スレッド(Thread)

    • プロセスと同じくプログラムの実行の単位で「軽量なプロセス」。プロセス内で複数のスレッドが同時に実行できる
      • スレッドはプロセスに含まれる
    • 同じプロセスに属するスレッドはメモリ空間を共有するため、スレッド間通信が容易。
      • スレッドはプロセス内で通信することができ、並行処理に使用できる。
    • 同じデータを複数のスレッドが同時に書き換えることによる不整合に注意する必要がある。
    • スレッドセーフかどうかわからない場合は、同期・排他制御、スレッド間データ共有を防ぐか不変オブジェクトの使用などを行う必要がある。
  12. プロセスとスレッドの違い

    • スレッドはプロセスの内部での話であり、プロセスがスレッドを含む。
    • プロセスは複数のスレッドを持つことができ、複数のスレッドが1つのプロセス内で実行されることをマルチスレッドと呼ぶ。
    • プロセスとスレッドの主な違いは、プロセスは独立した実行単位であり、スレッドはプロセス内の依存関係のある実行単位である。
  13. スレッドセーフ(Thread Safe)

    • 複数のスレッドからメソッド、フィールド、または共有データにアクセスしても問題がないことを指す。スレッドセーフではない操作とは、非アトミックな操作を含む。
      • または複数スレッドからアクセスされることを想定し排他制御が行われているようなクラスやメソッドを指す。
    • スレッドセーフでないリソースを複数スレッドからアクセスする場合には、同期・排他制御を行わなくてはならない。
      • 一般的には、リソースのクラスをスレッドセーフにする方法が望ましいとされる。
  14. クリティカルセクション・同期制御・排他制御

    • クリティカルセクションは、複数の処理が同時に実行されると競合状態を起こす単一の共有データであり、コードセクションを指す。
    • 競合状態とは、複数のスレッドが同じデータまたはリソースに同時にアクセスしようとする状況。競合状態は、同じ入力を与えてもプログラム実行のたびに結果が変わってしまう非決定的な動作であり、データの破損またはシステム停止につながる可能性がある。
    • 同期制御は複数のプロセスやスレッドが同時に実行される場合に、実行順序やタイミングを制御すること。
    • 排他制御を行うためには、ロックを取得し解放する必要がある。ただし、デッドロックに注意しなければならず、Javaでは synchronized や ReentrantLock などの方法がある。また、共有データへのアクセスには、スレッド間の排他制御を行い、アトミック性を確保する必要がある。
    • ロックは、クリティカルセクションへのアクセスを制限する排他制御の一種。スレッドがクリティカルセクションにアクセスする前にロックを取得し、アクセスが完了したらロックを解放する。スレッドがクリティカルセクションをロック解除すると、他のスレッドがロックを取得できるようになる。
  15. アトミック性(Atomicity)

    • アトミックな操作とは、クリティカルセクションに対するマルチスレッド操作・複合アクション(インクリメントなど。後述)に対する適切は排他制御がなされている状態を指す。
    • アトミック性とは、、複数のスレッドが共有する変数に、1つのスレッドが操作を開始してから完了するまで他の操作をブロックする性質。これは、複数のスレッドが同じ変数にアクセスするときに重要。不可分操作と呼ぶ。
    • アトミック性が保証されていない場合は、複数のスレッドが同時にクリティカルセクションにアクセスした場合に競合しデータ不整合を引き起こす可能性がある。
    • Java では、synchronized キーワードや Lock インタフェースなどの機能が提供されている。
  16. 可視性(Visibility)

    • 視性とは、複数のスレッドが共有する変数に、1つのスレッドが値を書き込んだ後、他のスレッドがその値をすぐに読み取ることができることを保証する性質。
    • volatile修飾子は、可視性を保証するために使用される。volatile 修飾子が付いた変数は、常にメインメモリから読み書きされるため、キャッシュに値が残っていても最新の値が見えるようになる。これにより、複数のスレッド間での可視性が保証される。
    • ただし、volatile修飾子は複合操作のアトミック性を保証するものではないため、複合操作を排他的に行いたい場合には使えない。

7. synchronized の意味

やっと本題のsynchronizedの話ができます。疲れましたね、私も疲れました。

では問題のコードと解決例のコードを再び見てみましょう。synchronizedが追加されているだけです。

今回の SonarLint 警告文は、端的に言えば - 「static フィールドへのアクセスは synchronized で static なメソッドにのみ設定されるべきである」

つまり、static フィールド(クリティカルセクション)の操作をインスタンスメソッドでやるのは、クリティカルセクションprivate static int count = 0; の部分)に対するスレッドセーフではない操作(public static void doSomething())であり、競合を引き起こすプログラムだ、という訳です。

synchronizedをつけると以下の様な状態になります。

  1. 排他制御が行われる様になる
  2. 排他制御により、複数スレッドが同時にsynchronizedブロックにアクセスしてもスレッドセーフとなる。
  3. 複数スレッドが同時にsynchronizedブロックにアクセスしても操作は、一度に単一のスレッドのみとなり、アトミックとなる。
    • スレッドセーフとアトミックは関連性が深い重要な概念だけど、あくまで別の概念である事に注意
      • スレッドセーフはマルチスレッド環境下でも競合せず、期待される処理結果が得られること
        • アトミックはあくまで不可分性であり、操作が一連の不可分な単位として実行されること
  4. synchronizedブロック内でクリティカルセクションの変更結果が他のスレッドに対して可視になる。
    • 1つのスレッドがsynchronizedブロックから抜けるときには、それまでの変更がメモリにフラッシュされ、他のスレッドから読み取れるようになる(Java のメモリモデルに依存した現象)

それではsynchronizedについて説明していきましょう。

7-1. synchronized排他制御しアトミックでスレッドセーフに

定義   1つのスレッドだけが、ある時点で1つのメソッドやブロック(クリティカルセクションを扱う)を実行していることを保証、オブジェクトの整合性を保つための排他制御の手段として使われる。また、複数のスレッドが共有する変数が、スレッド間でどのように見えるかといった可視性も保証する。つまり、synchronizedはアトミック性と可視性の両性質とスレッドセーフを保証する。synchronizedにおけるロックはミューテックスである。

解説
synchronizedクリティカルセクションを扱うコードに対して、ミューテックスによる排他制御を付加しそのコードが行う操作がアトミック操作・可視性があることを保証する。

synchronizedで上記を実現する方法は、以下の二通りです。

  1. synchronizedブロッククリティカルセクションコードを synchronized で囲んで局所的に排他制御する
  2. synchronizedメソッド:メソッド全体を排他制御する

7-1-1. synchronizedブロック

public class MyClass {
    private static int count = 0;
    private static final Object lock = new Object();
    
    public static void doSomething() {
        // 排他制御が不要な処理;
        ....
    
    synchronized (lock) {
        // 排他制御が必要な処理; 
        ....
    }
    
        // 排他制御が不要な処理;
        ....
    }
}

上記のdoSomething()クリティカルセクションを扱うコードで、マルチスレッド環境において競合する可能性があります。その為、各スレッドにこのコードブロックはクリティカルセクションを扱うブロックであるという目印の様なものをつけます。それが以下で説明するロックオブジェクトです。

synchronized ブロックもしくはメソッドは開始時にロックを取得し、ブロック終了時にロックを解放します。ロックのことをロックオブジェクトと呼びます。ロックオブジェクトには、Objectクラスのインスタンスを使用します。Javaにおいて、すべてのクラスは暗黙的にObjectクラスを継承しているので、どのクラスインスタンスでもロックオブジェクトにすることが可能で、無関係なインスタンスでも構いません。目印の様なものですから。

ロックオブジェクトは、スレッドの実行優先権を表し、鍵の様な役割を持ちます。ロックしたい(ミューテックスなので、単一スレッドしかアクセスさせない)コードブロックを複数のスレッドからロックするためのオブジェクトで、ロックを取得したスレッドだけがクリティカルセクションで処理の実行が可能になります。この処理ブロックを実行できるスレッドは、一度にロックを取得した1つのスレッドだけです。synchronized におけるロックは基本的に早い者勝ちです。複数のスレッドがロックを獲得できずに獲得待ちをしている場合、ロックの獲得待ちに並んだ順番にロックを獲得できるわけではなく、次にどのスレッドがロックを獲得できるかは不定になります。キューのような順序管理の機能を期待することはできないため、順序管理を行いたい場合はその様な設定を別途用意する必要があります。

※すべてのロックが、クリティカルセクションへのアクセスを、単一スレッドのみに制限するわけではありません。目的や性質によって変わります。むやみやたらにミューテックスを使用すると、マルチスレッドの利点である効率性を大きく損なう可能性があります。スレッド数が多い処理に単一のスレッドしかアクセスを許さないのであれば、そこがボトルネックになってしまうからです。

synchronized (ロックオブジェクト) {
    ロックされるコードブロック(ここの処理が行えるのは、上記ロックオブジェクトを取得できた1スレッドのみ)
}

7-1-2. synchronizedメソッド

public class MyClass {
    private static int count = 0;
    
    public static synchronized void doSomething() {
        count++;
    }
}

この場合、メソッドを呼び出すオブジェクトがロックオブジェクトになります。そのため、MyClass インスタンスの doSomething() メソッドを複数のスレッドから呼び出した場合、後に呼び出した方は、先に呼び出した方の処理終了を待つことになります(後に呼び出した方はブロックされます)。呼び出し側のオブジェクトがロックオブジェクトとなるので、複数のスレッドから同じオブジェクトの doSomething() メソッドを同時に呼び出した場合は排他制御が行われまが、異なるオブジェクトの doSomething() メソッドを呼び出しても当然ですが排他制御は行われません。

7-1-3. Java における synchronized は再入可能なロック

再入可能とは、同じスレッドが同じロックを解放することなく連続で複数回取得できるという意味です。これは、スレッドがロックを取得した後、ロックを解放せずに(デッドロックを発生させずに)、そのロックで保護されている任意の数のメソッドを呼び出すことができることを意味します。デッドロックとは、2 つ以上のスレッドが互いにロックを待っている状態です。その場合、スレッドはロックを取得できず、システム停止などを引き起こします。

public class MyClass {
    private static int count = 0;
    private static final Object lock = new Object();
    
    public static void doSomething1() {
        synchronized (lock) {
            // 排他制御が必要な処理; 
        }
    }
    
    public static void doSomething2() {
        synchronized (lock) {
            // 排他制御が必要な処理; 
        }
    }
}

このコードでは、同じスレッドが doSomething1() と doSomething2() の両方のメソッドを呼び出すことができます。doSomething1() メソッドが lock オブジェクトを保持している場合でも、doSomething2() メソッドは lock オブジェクトを保持できます。

ロックを解放せずに次のロックが持つメソッドを実行できるので、2 つ以上のスレッドが互いにロックを待機する状態を回避できます。スレッドがロックを保持したまま次の処理に移ることができるのは、再入可能という性質のためです。

ただし、再入可能なのは、同じスレッドが同じロックオブジェクトを取得する場合のみです。別々のスレッドから同じロックオブジェクトを取得しようとすれば、当然競合が発生しますし、同じスレッドが複数の異なるロックオブジェクトを取得しようとすると それぞれのロックの状況により獲得できたり待たされたりすることになります。その他、スレッド A がスレッド B が保持しているロックを待っていて、スレッド B がスレッド C が保持しているロックを待っていて、スレッド C がスレッド A が保持しているロックを待っている場合、デッドロックが発生します。この場合、スレッド A、B、C はロックを取得できず、フリーズします。

ちなみに、Go のミューテックスが再入可能ではありません。これもちなみにですが、GOは継承もクラスもありません。面白い設計思想ですね。また、Effective Java では再入可能に関する注意点として以下の様に語られています。

再入可能ロックは、マルチスレッドのオブジェクト指向プログラムの作成を単純化しますが、活性エラーを安全性エラーに変える可能性があります。

活性エラーとは上記で簡単に説明したライブロックのことです。安全性エラーとは、競合状態化における共有データ(クリティカルセクション)のデータ不整合のことで、カウント10000回をマルチスレッドで実行したら6000回くらいの結果になって、期待通り10000回カウントしないとかですね。

活性エラーはデッドロックとして表面化しますが、安全性エラーはコードが動作しているように見えるが正しく動作していないという問題を引き起こします。特に競合状態(race condition)により引き起こされる問題は、現象の再現性も低く、調査が非常に難しいことが多いです。どないせぇっちゅうのや

7−2. synchronized の注意点

7-2-1. synchronizedメソッド と synchronizedブロック の違い

項目 synchronized メソッド synchronized ブロック 解説
ロック対象 メソッドを実行するスレッド ブロックを実行するスレッド 同期領域を適切に定義することで、スレッドの同時アクセスをより柔軟に制御することができる
ロックの取得・解放タイミング メソッドが実行されるたび・メソッドの実行が完了するまで ブロックが実行されるたび・ブロックの実行が完了するまで 両者は異なる方法でロックを取得することができるが、ロックの解放に関しては同様の振る舞いを示す
ロックの公開 メソッドを利用する他のコードにも公開 ロックオブジェクトを取得したコードのみ 公開される範囲を制御。特に、信頼できないコードやオブジェクトを外部に公開する場合は、適切なロックオブジェクトの選択とロックの公開範囲の制限が重要。
ロックオブジェクトの指定 任意のロックオブジェクトを指定することはできない 任意のロックオブジェクトを指定することができる synchronizedブロックの方が柔軟性が高く、特定のオブジェクトに対してのみ同期を行うことができる
パフォーマンス synchronized ブロックよりもパフォーマンスが低下する可能性がある synchronized メソッドよりもパフォーマンスが優れている可能性がある synchronizedメソッドはメソッド全体にロックが適用、メソッド内の全てのコードが同期される。ブロックでは必要な箇所のみロックを取得し、他の箇所では同期されないため、より細かな制御が可能。
使い所 複数のスレッドから呼び出されるメソッド・インスタンスメンバを保護する場合。メソッド内でアトミックな操作を行う場合。 複数のスレッドからアクセスされるオブジェクト(クリティカルセクション)を保護する必要がある場合。同期箇所・ロックオブジェクトの指定による柔軟性とパフォーマンス維持 「7-2-4. 異なるインスタンスをロックオブジェクトにする」で紹介しているインクリメントなブロックたちは synchronized メソッドでまとめてしまえば、スレッドセーフかつアトミックになる。ブロックでは場合によってインスタンスメンバに不整合を引き起こす場合がある。

7-2-2. synchronized はパフォーマンスを低下させる可能性がある

synchronized を使った排他制御ミューテックスです。ミューテックスクリティカルセクションへのアクセスを単一の実行単位しか許しません。そのため、ミューテックスは他のスレッドの実行を停止させてしまうので、パフォーマンスを低下させる可能性があります。スレッドの待機時間が長くなると、マルチスレッドにしたことによるメリットが弱まってしまいます。なるべく他のスレッドの処理を停止させないようにするためには、本当に排他制御が必要なところだけを synchronized で括ってあげるのがいいです。

何事においてもそうですが、使用する適切な方法は、具体的な要件やコンテキストに依存します。

7-2-3. スレッドセーフとアトミックの違い

スレッドセーフは、複数のスレッドが同時にクリティカルリソースにアクセスする際に、正しい結果を得ることが保証されることを意味します。スレッドセーフなデータ構造やアルゴリズムは、適切な同期やロック機構を使用して複数のスレッドからのアクセスを制御し、データ整合性を保ち、不整合を防ぎことが目的になります。

一方、アトミック性は、特定の操作が不可分(不分割)であることを意味します。つまり、その操作が一度に完全に実行されるか、または実行されないかのいずれかであることを保証します。アトミックな操作は、スレッドが同時にその操作にアクセスしても競合状態が発生しないことを意味します。

public class MyClass {
    private static int count = 0;
    
    public static void doSomething() {
        //...
        count++;  // compliant
    }
}

上記コードはスレッドセーフではないといった問題のほかに、実はインクリメントやデクリメントが、アトミックな操作ではないという問題があります。インクリメントの様な操作を複合アクションと呼びます。複合アクションにはインクリメントなどの他、チェック・ゼン・アクト操作があります。   (参考:フリー百科事典『ウィキペディア(Wikipedia)』 競合状態)。

インクリメント操作は以下の三つのアクションが組み合わさった複合アクションで、RMW:Read-Modify-Write操作と呼ばれます。

  1. 変数の読み込み→取得
  2. その値に1を加える
  3. 書き込み

といった3つのアクションが行われるのですが、他のスレッドからはそれらの操作は別々の操作に見えています。操作が複数のステップから構成される為、スレッドでの各アクションの間に、他のスレッドからのアクションが割り込むことができてしまいます。このような場合、競合状態やデータの整合性の問題が発生する可能性があります。

操作が複数のアクションに分割されている様な箇所の操作についてはアトミックな処理が必要です。割り込まれてしまうと、期待する処理結果が出ません。1にインクリメントしたら勿論1になるところが、3になるかもしれないのです。競合状態です。

インクリメント操作における非アトミックなマルチスレッド操作のイメージです。

処理実行順序

1

2

3

4

5

6

スレッド1 変数の値を読む(0) 読み取った値に1を足す 変数に値を設定する(1)
スレッド2 変数の値を読む(0) 読み取った値に1を足す 変数に値を設定する(1)

素直に考えれば、スレッド1がインクリメントし変数は1になるので、スレッド2でインクリメントをしたら2が帰ってくると考えるでしょう。マルチスレッドでは、クリティカルセクションである変数を同時に参照し、その値に対して処理を行なってしまうことが発生します。そして、取得したその値に対して1を加え、新しい値を書きこむが可能になります。つまり、二つのスレッドで、同じ値が戻り値として繰り返し返されてしまうわけです。このままスレッドが増えていった場合、どの様な結果になるのか予想がつきません。これが競合です。競合は非アトミックな操作で行われ、それを防ぐ為には排他制御が必要です。

本来は以下の様になってほしい訳です。

処理実行順序

1

2

3

4

5

6

スレッド1 変数の値を読む(0) 読み取った値に1を足す 変数に値を設定する(1)
スレッド2 変数の値を読む(1) 読み取った値に1を足す 変数に値を設定する(2)

この様な、アトミックで決定的な操作を実現する為には、java.util.concurrent.atomicパッケージを使用して、アトミック操作を提供するクラスを用います。 AtomicIntegerにはincrementAndGet()やaddAndGet(int delta)などのメソッドがあり、スレッドセーフにインクリメント・デクリメントが可能です。もし、インクリメント・デクリメントの非アトミックな操作をアトミックにしたい場合、以下の書き方でアトミックかつスレッドセーフになります。

import java.util.concurrent.atomic.AtomicInteger;

public class MyClass {
    private static AtomicInteger count = new AtomicInteger(0);
    
    public static void doSomething() {
        //...
        count.incrementAndGet();
    }
}

7-2-4. ロックオブジェクトが異なるインスタンス・スレッドセーフだが非アトミック

ロックを取得中のスレッドがあれば、ロックを取得しようしたスレッドはブロックされます。ロックが開放されたら、後続のスレッドがロックを取得できます。つまり、ロックされる処理ブロックでは、スレッドの実行が同期化(排他制御)することを意味します。 変数 lock をロックオブジェクトにした場合、そのロックオブジェクトのインスタンス単位でロックを行います。これにより、指定した処理ブロックがスレッドセーフであることが保証されます。ただし、複数のインスタンスを生成し、バラバラにロックオブジェクトに指定した場合はスレッドセーフではなくなります。

以下のコードの各 synchronized なメソッドはアトミック、つまり不可分操作(それ以上切り分けられない)であるべきな複合アクションを表現しています。インクリメント(もしくはデクリメント)操作です。上記で説明した様に、ロックオブジェクトを異なるインスタンスで指定してしまうと、スレッドセーフでもなければアトミックでもありません。

public class Increment {
    private static int count = 0;
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    private static final Object lock3 = new Object();

    private void read () {
        synchronized (lock1) {
            readObject = count;
            add(readObject);
        }
    }

    private void add (int readObject) {
        synchronized (lock2) {
            addObject = readObject + 1;
            set(addObject);
        }
    }

    private void set (int addObject) {
        synchronized (lock3) {
            count = addObject;
        }
    }
}

このコードの問題点は2つです。

  1. 各メソッドで異なるロックオブジェクトが指定されているため、複数のスレッドが同時に同じメソッドを実行している場合、データの競合が発生する可能性がある。
    • 2つのスレッドが同時にread()メソッドを実行している場合、スレッドAがcount変数を読み取り、スレッドBがcount変数に書き込む可能性があり、その場合データ競合が発生する可能性がある。
  2. 各メソッドでロックを解放する前に、次のメソッドを呼び出しているため、他のスレッドがこれらのメソッドを実行できなくなる可能性がある。
    • スレッドAが read() メソッドを実行、スレッドBが add() メソッドを実行している状態。スレッドAがlock2オブジェクトを保持しているため、スレッドBはadd()メソッドを実行できない。これにより、スレッドがブロックされ、パフォーマンスの問題が発生する可能性がある。

同じロックオブジェクトをすべてのメソッドで指定すれば、データ競合を防ぎことができます。つまりスレッドセーフですし、本来一気通貫で実行されるべき複合アクション、つまりアトミックであるべきインクリメントが正しくアトミックに行われます。

また、上記で紹介した以下のコードも同じくスレッドセーフでアトミックです。

import java.util.concurrent.atomic.AtomicInteger;

public class MyClass {
    private static AtomicInteger count = new AtomicInteger(0);
    
    public static void doSomething() {
        //...
        count.incrementAndGet();
    }
}

7-2-5. synchronized メソッドで this をロックオブジェクトにする場合

thisをロックオブジェクトに使用するのは注意が必要です。this参照とは、オブジェクト自身への参照です。個人的には this は使わない方がいいと思っています。

public class MyClass {
    private static int count = 0;
    
    public static void doSomething1() {
        synchronized (this) {
            // 排他制御が必要な処理; 
        }
    }
    
    public static void doSomething2() {
        synchronized (this) {
            // 排他制御が必要な処理; 
        }
    }
}

ロックオブジェクトが this のため、new MyClass() が複数回行われるなどして複数のインスタンスが生成された場合、ぞれぞれのインスタンスにて this は別物になります。なので、MyClass インスタンス間での排他制御は行われなくなります。

// 以外のコードはスレッド間で排他制御されない
new MyClass().doSomething1();
new MyClass().doSomething2();

7-2-6. 「LCK00-J. 信頼できないコードから使用されるクラスを同期するにはprivate finalロックオブジェクトを使用する

このガイドラインは、信頼できないコードから使用されるクラスを同期する際のセキュリティガイドラインです。

『7-2-1. synchronizedメソッド と synchronizedブロック の違い』で紹介した下記が関係します。

ロックの公開 メソッドを利用する他のコードにも公開 ロックオブジェクトを取得したコードのみ 公開される範囲を制御。特に、信頼できないコードやオブジェクトを外部に公開する場合は、適切なロックオブジェクトの選択とロックの公開範囲の制限が重要。

オブジェクトをパブリックに公開すると、不特定多数からアクセスされる可能性があります。このような場合は、synchronized メソッドは脆弱性を伴います。信頼できないコードから使用される可能性のある synchronized メソッドをもつクラスを同期するには注意が必要です。信頼できないコードとは、信頼できないソースコードから生成されたコードや、信頼できないユーザーによって実行されるコードのことです。信頼できないコードから使用されるクラスを同期するためには、以下を実施する必要があります。

  • private finalなロックオブジェクトを使用すること
  • private static final なロックオブジェクトを使用すること
    • クラスフィールドやクラスメソッドを保護する場合

private final なロックオブジェクトを使用すると、信頼できないコードはロックオブジェクトを取得することができず、クラスの同期が破綻することはありません。信頼できないコードにオブジェクトを公開する場合は、オブジェクトのロックを無期限に獲得され競合状態やデッドロック・サービス運用妨害 (DoS) を引き起こすおそれがあるため、private final なロックオブジェクトを使うようにしてロックを露出しないようにするべきということです。

また、クラスフィールドやクラスメソッドを保護する場合には、private static final なロックオブジェクトを使用することが重要です。これにより、同期化の範囲を制限し、複数のインスタンス間での同期化が行われるのを防ぐことができます。

参考:「LCK00-J. 信頼できないコードから使用されるクラスを同期するにはprivate finalロックオブジェクトを使用する」(JPCERT)

8. 最後のまとめ 『LCK05-J. 信頼できないコードによって変更されうる static フィールドへのアクセスは同期する』

それでは、最後にこちらのセキュリティガイドラインを紹介して終わりにします。今回、ソナーに警告されたのはこのガイドラインの遵守違反でした。このガイドラインがなんの目的で制定されていて、どの様に順守するのか、そしてそれは何故なのか?

何故なのかというところから、static・マルチスレッド・排他制御・アトミック性と可視性、そして synchronized の話をしてきました。このガイドラインを真に理解するための記事でした。長く、深く、遠い道でした。それでもなお、考慮不足なところや大量に端折ったところがあります。もう無理です(笑)

「LCK05-J. 信頼できないコードによって変更されうる static フィールドへのアクセスは同期する」というルールは、信頼できないコードによって変更されうる static フィールドへのアクセスを同期する必要性を示しています。

このルールの要点は以下の通りです:

  1. 信頼できないコードによって変更されうる static フィールドへのアクセスは同期する必要がある。
  2. 信頼できるコード内での static フィールドへのアクセスも同期することが望ましい。

このルールの目的は、複数のスレッドが同時に static フィールドへアクセスする場合に、データ競合や不正確な値の読み書きを防ぐことです。信頼できないコードや複数のスレッドからのアクセスが発生する場合は、適切な同期機構を使用してスレッド間の競合を回避する必要があり、具体的な対策として以下が挙げられます。

  1. synchronized キーワードを使用してメソッド全体または対象のコードブロックを同期化する。
  2. Atomic クラスや volatile 修飾子を使用してアトミックな操作を行う。
  3. ReentrantLock クラスやその他の同期機構を使用して明示的なロックを取得・解放する。

これらの同期手法を使用することで、信頼できないコードによる static フィールドへのアクセスにおいてもデータの整合性を確保し、競合状態や予期しない結果の発生を防ぐことができます。

/* このクラスはスレッドセーフである(アトミックではない) */
public final class CountHits {
    private static int counter;
    private static final Object lock = new Object();
    
    public void incrementCounter() {
        synchronized (lock) {
            counter++;
        }
    }
}

本記事では 1 を中心に 2 も若干紹介しました。3 の様なマルチスレッド用のクラスがたくさんあるのですが、本記事では最も基本的な部分を抑えるにとどめました。いつか応用編を書いてみたいですね。マルチスレッドのデザインパターンもあるのです。使う様なプロダクトやってみたいです。

参考:経営情報システム学特論1 11~12.Javaマルチスレッドパターン

9. 終わりに

以下の過去記事はマルチスレッドに対する不変オブジェクトの有用性についての記事です。
よかったらご覧ください。最後までお読みいただき有難うございました!!

本記事は3部構成になっております。

  • 『前編:static とメモリ管理』

static と synchronized から始めるマルチスレッド入門 〜前編〜 - よしたろうブログ

  • 『中編:マルチスレッドの基本的な用語と概念』

static と synchronized から始めるマルチスレッド入門 〜前編〜 - よしたろうブログ