IT戦記

プログラミング、起業などについて書いているプログラマーのブログです😚

Effective Java 読書会 11 日目 「Java マルチスレッド難しいいい」

はじめに

順番が前後しますが、都合上「並行性」の章を先に書きたいと思います。

読んだところ

251 ページ 〜 268 ページ

同期とは何か

同期 = 原子性 + 可視性

  • 原子性(アトミック性)
    • データの状態遷移の過渡的な不整合な状態が(どのスレッドからも)見えないという性質。
    • 適切に相互排他することでデータの原子性を保証できる。(保護されたコードを実行できるスレッドは一つだけ。)
  • 可視性(ビジビリティ)
    • (どのスレッドからも)同じ値が見えるという性質。
    • 普通、変数やフィールドの値はスレッドごとにキャッシュ(レジスタなど)されるなどしていて、スレッド間での同値性は保証されない。

同期するというのは、原子性を保証することだけではなく、可視性も保証することだということを忘れてはいけない!!!

long, double 以外の変数への読み書き

  • 原子性は保証されている!
  • しかし、可視性の保証はない!
    • メモリモデルで定義されている

同期されないフィールドへの値の設定、参照

したがって、以下のような例では stopRequested の可視性の保証はされない。

import java.util.concurrent.TimeUnit;

public class Main {

    private static boolean stopRequested;
    
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                int i = 0;
                
                // 値の参照
                while (!stopRequested) {
                    i++;
                }
            }
        }).start();
        TimeUnit.SECONDS.sleep(1);
        
        // 値の設定
        // (この値の変更が、いつもう一つのスレッドに反映されるかは分からない)
        // (もう一つのスレッドのコードが巻き上げの最適化をされてしまうかもしれない!!)
        stopRequested = true;
    }
}

ということになる。
で、実際試してみるとスレッドは止まらない。

活性エラー

上の例のように、プログラムが先に進めなくなるエラー

ちなみに:Thread.stop は使ってはいけない

データを破壊する可能性がある。
スレッドを停止する推奨される方法は、同期されたフラグのポーリングだそうです。

同期させてみる

以下のように、値の設定と参照を synchronized メソッドで囲めばいい。

import java.util.concurrent.TimeUnit;

public class Main {

    private static boolean stopRequested;
    
    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        // synchronized に入るときにそれまでの変更がこのスレッドに確実に反映される。
        // (stopRequested への可視性が保証される)
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                int i = 0;
                    
                // 値の参照
                while (!stopRequested()) {
                    i++;
                }   
            }   
        }).start();
        TimeUnit.SECONDS.sleep(1);
            
        // 値の設定
        requestStop()
    }   
}

volatile

synchronized は原子性と可視性を保証する。
boolean への読み書きは、元々原子性は保証されているので、可視性だけを保証すればいい。
で、変数の可視性を保証する方法が「volatile」。
volatile を使ってさっきの例を書き換えると

import java.util.concurrent.TimeUnit;

public class Main {

    private static volatile boolean stopRequested;
        
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            public void run() {
                int i = 0;
                    
                // 値の参照
                while (!stopRequested) {
                    i++;
                }   
            }   
        }).start();
        TimeUnit.SECONDS.sleep(1);
            
        // 値の設定
        stopRequested = true;
    }   
}

すっきり!

volatile だけじゃだめな場合

public class Main {

    // count の原子性、可視性は保証されているが
    private static volatile int count = 0;
    
    public static int getCount() {
        // ++ は原子性がないのでダメ
        return count++;
    };
}

count++ は

  • 値の読み出し
  • 加算
  • 書き戻し

の 3 つの操作を行う
たとえば、以下のように二つのスレッドが走った場合、不整合な値が返ってしまう。

  • Thread1: 値の読み出し
  • Thread2: 値の読み出し
  • Thread1: 加算
  • Thread2: 加算
  • Thread1: 書き戻し
  • Thread2: 書き戻し

安全性エラー

上の例のようにプログラムが誤った結果を返すエラー

synchronized で書き直し

こんな感じですかね。 synchronized なので、 long も使えるよ!

public class Main {

    private static long count = 0;
    
    public static synchronized long getCount() {
        if (count == Long.MAX_VALUE) {
            throw new ArithmeticException("too large to increment");
        }
        return count++;
    };
}

もっといい例は

java.util.concurrent.atomic を使う!

import java.util.concurrent.atomic.AtomicLong;

public class Main {

    private static AtomicLong count = new AtomicLong();
    
    public static long getCount() {
        return count.getAndIncrement();
    };
}

わおわお!
すっきりですね

可変データはなるべく共有しない

同期しなくていい最善の方法は、可変データを共有しないこと。
不変データ最強伝説。

可変データでも、変更しなけれ

事実上不変!(effetively immutable)

オブジェクトの参照を他のスレッドに転送する

安全な公開!(safe publication)

過剰な同期

過剰に同期しすぎると、以下のような振る舞いをする可能性がある

デッドロック

お互いがお互いのスレッドを待っている状態。
二つの synchronized メソッドを持つオブジェクト A, B があって

  • A の synchronized なメソッドから B の synchronized なメソッドを呼ぶ
  • B の synchronized なメソッドから A の synchronized なメソッドを呼ぶ

この二つのパターンがあるような場合で、複数スレッドから A, B を扱うとデッドロックが発生する

オブザーバーパターン

オブザーバーパターンや、イベントリスナーのように、オブジェクトの呼び出し階層が上下逆になるような場合にデッドロックが発生しやすい。
その本質的な原因は「異質なメソッド」(コールバックのように中で何が行われるかわからないメソッド)を synchronized から呼ぶことである

class Observable {
    private List<Observer> observers = new ArrayList<Observer>();
    
    public void addOvserver(Observer o) {
        synchronized (observers) {
            observers.add(o);
        }
    }

    private void notifyUpdate(String e) {

        // ロックの中で!!
        synchronized (observers) {
            for (Observer observer : observers) {

                // 異質なメソッド!!
                // この update はどのような処理が行われるか分からない(Observer の実装は、この class を使う側が決めるから!!)
                // 中でデッドロックするかも!!!!
                observer.update(this, e);
            }
        }
    }

    // ...
}

interface Observer {
    public void update(Observable o, String e);
}

オープンコールで解決

ロックの中で呼ばないように、配列を一旦コピーすれば解決する

class Observable {
    private List<Observer> observers = new ArrayList<Observer>();
    
    public void addOvserver(Observer o) {
        synchronized (observers) {
            observers.add(o);
        }
    }

    private void notifyUpdate(String e) {
        
        // 状態をコピーする
        List<Observer> snapshot = null;
        synchronized (observers) {
            snapshot = new ArrayList<Observer>(observers);
        }

        for (Observer observer : snapshot) {
            // 異質なメソッド!
            // でも安心
            observer.update(this, e); 
        }
    }

    // ...
}

interface Observer {
    public void update(Observable o, String e);
}

このような、ロックの外からの呼び出しをオープンコールという。
オープンコール重要。

スナップショットを取るのもいいけど、変更したときに丸々コピーする方が効率いい

イテレーションの度にコピーするより、オブジェクトに変更があったときに内部の状態をコピーするという方法もある。
Observer が追加されたら、今まで使ってたリストは破棄して一個追加された状態のリストを新たに作るような感じ。
この操作を抽象化したリストが CopyOnWriteArrayList 、オブザーバーパターンのときは使おう!

class Observable {
    private List<Observer> observers = new CopyOnWriteArrayList<Observer>();
    
    public void addOvserver(Observer o) {
        // ここで内部の配列がコピーされるので
        // (前の状態が破壊されずに残っているので)
        observers.add(o);
    }

    private void notifyUpdate(String e) {
        for (Observer observer : observers) {
            // ここでは、ロックもスナップショットもいらずに
            // オープンコールできる
            observer.update(this, e);
        }
    }

    // ...
}

interface Observer {
    public void update(Observable o, String e);
}

Thread より

Executor を使おう!

wait, notify より

コンカレンシーユーティリティを使おう!

まとめ

スレッド難しい><