【Effective Java】項目67:過剰な同期は避ける

前回の記事では、同期が不十分な場合にはマルチスレッド環境において予期しないエラーが発生する可能性を説明しました。

hjm333.hatenablog.com

hjm333.hatenablog.com

今回は前回とは逆の視点です。 過剰に同期することによって発生する問題について取り上げます。

過剰な同期による問題

Java において過剰に同期を行うと次の問題が発生する可能性があります。

まずはデッドロックを引き起こす可能性のあるコードについて説明します。

オープンコール

synchronized で同期しているメソッドやブロック内で、制御を他のコードに譲ってはいけません。

例として、リスナーを介して追加の通知が取得できるセットの実装(ObservableSet)を取り上げます。 この実装は項目16で利用した ForwardingSet を利用しています。

/**
 * クライアントが要素を追加した時に、通知を受け取ることが可能。
 * Observer パターンを利用。
 */
public class ObservableSet<E> extends ForwardingSet<E> {
    interface SetObserver<E> {
        void added(ObservableSet set, E element);
    }

    public ObservableSet(Set<E> set) {
        super(set);
    }

    private final List<SetObserver<E>> observers = new ArrayList<>();

    public void addObserver(SetObserver<E> observer) {
        synchronized (observers) {
            observers.add(observer);
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized (observers) {
            return observers.remove(observer);
        }
    }

    private void notifyElementAdded(E element) {
        synchronized (observers) {
            for (SetObserver<E> observer: observers) {
                observer.added(this, element);
            }
        }
    }

    @Override
    public boolean add(E element) {
        boolean added = super.add(element);
        if (added) {
            notifyElementAdded(element);
        }
        return added;
    }
}

この Set は任意のリスナーを登録できます。 要素が追加された際は、そのリスナーを通じて通知を受け取ることができます。

問題は通知を発生させる notifyElementAdded() です。 このメソッドは同期化されており、SetObserver.added() を同期ブロック内で呼び出しています。

SetObserver.added() の実装について ObservableSet は何も知りませんので、どんな処理が行われるか保証できません。 すごく時間のかかる処理をするかもしれないですし、例外を発生させる処理かもしれません。

もし、次のようなコードが書かれているとしたらプログラムはデッドロックしてしまいます。

set.addObserver(new SetObserver<Integer>() {
    @Override
    public void added(final ObservableSet set, Integer element) {
        if (element == 23) {
            ExecutorService executor =
                    Executors.newSingleThreadExecutor();
            final SetObserver<Integer> observer = this;
            // 要素の削除をバックグラウンドスレッドで実行
            try {
                executor.submit(new Runnable() {
                    @Override
                    public void run() {
                        set.removeObserver(observer);
                    }
                });
            } finally {
                executor.shutdown();
            }
        }
    }
});

このデッドロックは SetObserver.added() を同期ブロック内で呼び出しているために発生します。

同期ブロック内で呼び出しなので SetObserver.added() の呼び出しスレッドはロックを取得している状態です。 そのうえで、他のスレッドで ObservableSet.removeObserver() を呼び出そうとしています。

removeObserver() は同期化されているため、SetObserver.added() を呼び出したスレッドが持っているロックを待つ必要があります。 しかし、removeObserver() が終わらない限り SetObserver.added() の処理は終わらないため、ロックは永遠に解放されません。 すなわち、removeObserver() を呼び出したスレッドはロックの取得を永遠に待ち続けるのです。

これがデッドロックの原因です。 このように同期ブロック内で異質なメソッドを呼び出すと、意図しないデッドロックを発生させる可能性があります。

最小限の同期

このような問題を防ぐためのプラクティスを紹介します。 それは「同期された領域内で行う処理は最小限にするべき」というものです。 同期ブロックに入ったら必要な共有データのみを調べ、そして同期ブロックを抜けるのが理想です。

先ほどの ObservableSet では同期ブロック内で SetObserver.added() を呼び出していることが問題でした。 そこで次のようにコードを変更します。

private void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized (observers) {
        // 同期ブロック内ではその時点で observer のコピーを保存
        snapshot = new ArrayList<SetObserver<E>>(observers);
    }
    for (SetObserver<E> observer: snapshot) {
         observer.added(this, element);
    }
}

snapshot にコピーを保存して、異質なメソッド(SetObserver.added())を同期ブロックで呼ばないように変更しました。 こうすればオープンコールは同期化ブロック外で呼び出されますので、デッドロックを回避できます。

Java ライブラリには CopyOnWriteArrayList と呼ばれるスレッドセーフなリストが実装されています。 これを使っても問題ありません。

パフォーマンス

現在の JVM はロックの取得のためには CPU 時間をあまり消費しません。 その代わり、メモリの一貫性を保つために遅延が発生します。 また、同期化によって JVM 上の最適化が除去されてしまいます。

そのため、過剰な同期はパフォーマンスを低下させます。 パフォーマンス上の視点でも過剰な同期は避けるべきです。

外部同期・内部同期

クラスがマルチスレッド環境で利用されるのならば、クラスの内部で同期を行ってクラス自体をスレッドセーフにするべきです。

クラスを内部的に同期するようにすると、次のようなテクニックを使って高い平行性が実現できます。

しかし、内部同期を使った結果として高いパフォーマンスが得られないのであれば、内部同期しないほうがましです。 クラスでは同期を行わずにそのクラスを利用する外部のクライアント側で同期を行うべきです。

Java の初期の頃は内部同期を使っているクラスが多くありました。 たとえば StringBuffer はその代表例です。

しかし、StringBuffer にスレッドセーフ性が必要な場合はほとんどありません。 そこで Java 1.5 からは内部同期を行わない StringBuilder が導入されました。 「必要であれば StringBulder を使うクライアントが外部から同期を行うべき」という考えです。

このように、必要な場合のみ内部的にスレッドセーフを実現するべきです。