読者です 読者をやめる 読者になる 読者になる

遅延初期化とvolatileにまつわるエトセトラ

Java

f:id:Naotsugu:20160824000252p:plain

JSON Processing

Java API for JSON Processing (JSR 353) の RI 実装を見ていたらこんなコードがありました。

abstract class JsonNumberImpl implements JsonNumber {

    // ・・・略

    // Optimized JsonNumber impl for int numbers.
    private static final class JsonIntNumber extends JsonNumberImpl {
        private final int num;
        private BigDecimal bigDecimal;  // assigning it lazily on demand

        // ・・・略

        @Override
        public BigDecimal bigDecimalValue() {
            // reference assignments are atomic. At the most apiImpl more temp
            // BigDecimal objects are created
            BigDecimal bd = bigDecimal;
            if (bd == null) {
                bigDecimal = bd = new BigDecimal(num);
            }
            return bd;
        }

    }
}

bigDecimalValue() で取得する BigDecimal を遅延初期化する典型的な単一チェックイデオム(single-check idiom)で、マルチスレッド環境下で複数回初期化されることが許容できる場合によく出てくる書き方です。

しかしよく見ると、bigDecimal フィールドが volatile 宣言されていません。 通常はスレッドがフィールドを読み込む際に、最後に書き込まれた値を見ることを保証するためにvolatile を付けることがセオリーですが何か理由があるのかしら?

通常の単一チェックイデオムは以下のようになります。

private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null) {
        field = result = computeFieldValue();
    }
    return result;
}

JSON Processing のユースケースとしてシビアなタイミングで複数スレッドからアクセスする状況はあまりなく、主に単一スレッドからの bigDecimalValue() 呼び出しを最適化することが主眼なのでしょう。

そしてもしかしたら、volatile 付きと volatile 無しでパフォーマンス的に大きな違いが出るのかも知れません。 ということで試してみます。

volatile の有無によるパフォーマンス影響

volatile 無 のケース

    private BigDecimal bd1;

    @Test
    public void testVolatile() {
        long start = System.nanoTime();
        for (int i = 0; i < 1_000_000_000; i++) {
            if (bd1 == null) bd1 = new BigDecimal(1000);
        }
        System.out.println(System.nanoTime() - start);
    }

volatile 有 のケース

    private volatile BigDecimal bd2;

    @Test
    public void testVolatile() {
        long start = System.nanoTime();
        for (int i = 0; i < 1_000_000_000; i++) {
            if (bd2 == null) bd2 = new BigDecimal(1000);
        }
        System.out.println(System.nanoTime() - start);
    }

手元のマシン(jdk1.8.0_25)で実行して各5回の平均を見ると、

  • volatile 無 12,576,256 ns
  • volatile 有 602,701,220 ns

まぁ、当然といえば当然かもしれませんね。この差を大きいと見るかどうかですね。

あっけなく終わったので、volatile や 遅延初期化について少し補足しておきます。

volatile とは

Java言語規定(1.4) の voltile フィールドの説明によると、

Java言語では,17.で規定してあるように,共有変数にアクセスするスレッドが,その変数の私的作業コピーをもつことができる。 これは,マルチスレッドのより効率的な実装を可能にしている。 このような作業コピーは,同期点でだけ,つまりオブジェクトがロック設定又はロック解除されるときにだけ,共有主メモリ内のマスタコピーと一致させる必要がある。 共有変数が一貫性及び信頼性をもって更新されることを確実にするために,スレッドは,原則として,それらの共有変数に対する相互排他を強制するロックを得ることによって,それらの変数を排他的に使用しなければならない。 Javaは,ある目的に対して,より便利に利用できる第2の機構を提供する。つまり,フィールドをvolatile宣言できる。 この場合,スレッドは,変数にアクセスするたびに,フィールドの作業コピーをマスタコピーに一致させなければならない。 さらに,スレッドの動作による,一つ以上の volatile 変数のマスタコピーへの操作は,主メモリによって,スレッドの要求した順序と同じ順序で実行する。

つまり処理の最適化などの理由で、共有するメインメモリの内容を個々のスレッドの持つ作業コピーとして扱われることがあるが、volatile 宣言したものは、作業コピーの内容を常にマスター側に反映しますよ。ということですね。

コメント頂いたので、Java言語規定(Java SE 8) の記載ものせておきます。Java Memory Model の説明の章に説明を委譲するような記載となっています。

Java プログラム言語はスレッドが共有変数へアクセスすることを認めています(§17.1)。共有変数が一貫性及び信頼性をもって更新されることを確実にするために,スレッドは,原則として,それらの共有変数に対する相互排他を強制するロックを得ることによって,それらの変数を排他的に使用しなければならない。Javaは,ある目的のためにロックを行うよりも便利に利用できる第2の機構を提供する。つまり,フィールドをvolatile宣言できる。フィールドがvolatile宣言された場合、Java Memory Model は全てのスレッドが一貫した変数の値を見ることを保証する(§17.4)。

Java Memory Model の説明はとても簡単に書けるものではないので、こちらからvoltileの説明を引用しておきます

Java並行処理プログラミング ―その「基盤」と「最新API」を究める―

Java並行処理プログラミング ―その「基盤」と「最新API」を究める―

Java にはさらに、やや弱い同期化の仕組みとして揮発性変数(volatile variables) というものがあります。これを使って、変数の値の更新が正しく他のスレッドに伝わるようにします。クラスのフィールドが volatile と宣言されていると、コンパイラとランタイムは、この変数は共有されるのでその操作とほかのメモリ操作との間で順序変え(reordering)をしてはならない、と指示されます。揮発性変数はレジスタにキャッシュされず、またほかのプロセッサから見えないキャッシュにも保存されないので、そのリードはつねに、あるスレッドが書き込んだ最新の値を返します。

そして、Java 1.5で変更された Java Memory Model (JSR–133)で、 volatile の大きな改定ポイントに関する点が以下となります。

揮発性変数の可視性効果の及ぶ範囲は、その揮発性変数自身より広いです。スレッドAが揮発性変数に書き込みをして、次にスレッドBがその変数を読むと、Aがその揮発性変数に書き込む前にAにとって可視だったすべての変数の値が、Bにとって可視になります。そこでメモリの可視性という点では、揮発性変数に書き込むことはsynchronizedブロックを出ることに似ていて、それを読むことはsynchronizedブロックに入ることに似ています。

昔は volatile 変数を扱う時に可視性の問題があったから、可視性の同期範囲を広げて、スレッドにとって可視だった変数全てを対象に広げたということになります。これによって volatile 利用時のパフォーマンスが若干犠牲になっています。

単一チェックイデオム(single-check idiom)

先ほどの単一チェックイデオムです。

private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null) {
        field = result = computeFieldValue();
    }
    return result;
}

FieldType result = field; として、ローカル変数にフィールド値をコピーしているのはパフォーマンス用途です。

通常フィールドはヒープから参照するのに対して、ローカル変数はスレッド毎のスタックに保存されるため、値へのアクセスがより最適化されます。

特に先ほどのパフォーマンス調査の結果の通り volatile 変数の場合は、メインメモリとの整合を保証するために常にヒープから参照することになるためこのような最適化が常套手段となっています。

複数スレッドで初期化が何度も行われることに問題がない場合は volatile 宣言を外し、以下のように簡単に書くこともできます。

private FieldType field;

private FieldType getField() {
    if (field == null) {
        field = computeFieldValue();
    }
    return field;
}

ただし注意点もあります。フィールド変数が double 変数又は long 変数の場合には、volatile 宣言が必要になります。

これは double 変数又は long 変数は32bit値の2回の操作として扱われる可能性があるためです。 言語規定では以下のように説明されています。

double 変数又は long 変数が volatile 宣言されていないとき,ロード,記憶,読取り,及び 書込み 動作の実行は,これらがそれぞれ32ビットの二つの変数であるかのように扱われる。これらの動作のいずれかが規則上要求されたときはいつでも,それぞれ32ビットの二回の動作として実行される。64ビットの double 変数又は long 変数を,二つの32ビット量として扱う方法は,実装依存とする。 これは,double 又は long 変数の 読取り 又は 書込み が,実際の主メモリによって二つの32ビットの 読取り 又は 書込み として処理されることによって,時間的に分離され,その間に他の動作が入り込むことがあるということに関連している。その結果,二つのスレッドが,共有した同じ非 volatile double 変数又は非 volatile long 変数に,異なる値を並行して代入した場合,その変数を後で使用したとき,いずれの代入値とも等しくない,実装に依存した二つの値の混合値が得られることがある。 double 及び long 変数の ロード,記憶,読取り,及び 書込み 動作を,処理系は,アトム的な64ビット動作として実装してもよい,実際には,これを強く推奨する。本モデルは,64ビット量への効率的なアトム的メモリトランザクションを提供できない現在のマイクロプロセッサのために,32ビットずつに分割している。Javaとしては,一つの変数について,すべてのメモリトランザクションをアトム的として定義した方が簡単である。この複雑な定義は,現在のハードウェア実装への現実的な譲歩である。将来には,この譲歩は,削除されるかもしれない。当分の間は,プログラマは,共有 double 変数,及び共有 long 変数へのアクセスは,常に明示的に同期化するように注意すること。

さらに、 volatile 宣言していたとしても、インクリメント操作 count++ はリード・モディファイ・ライト(読んで・変更して・書き戻す)となりアトミックに処理できないため注意が必要になります。

二重チェックイデオム(double-check idiom)

複数スレッドによる複数回の初期化が許容出来ない場合は以下の二重チェックイデオムが利用できます。

private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null) {
        synchronized(this) {
            result = field;
            if (result == null) {
                field = result = computeFieldValue();
            }
        }
    }
    return result;
}

既に初期化済みの場合に、処理の重い synchronized の処理を回避するやり方です。 Java5よりも前はこのイデオムは問題がありましたが、Java5 からメモリモデルが変更されたため、今では有効な手段となっています。

初期化ホルダーイデオム(lazy initialization holder class idiom)

static 変数の遅延初期化には、初期化ホルダーイデオムを使います。 クラスのロード時に static final 変数が安全に初期化されることを利用したイデオムです。

private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}

static FieldType getField() {
    return FieldHolder.field;
}

同期のためのコードが不要となり綺麗ですね。遅延初期化の最常套手段です。



Java言語仕様 第3版 (The Java Series)

Java言語仕様 第3版 (The Java Series)

  • 作者: ジェームズゴスリン,ガイスティール,ビルジョイ,ギッラードブラーハ,James Gosling,Guy Steele,Bill Joy,Gilad Bracha,村上雅章
  • 出版社/メーカー: ピアソンエデュケーション
  • 発売日: 2006/12
  • メディア: 単行本
  • 購入: 1人 クリック: 118回
  • この商品を含むブログ (44件) を見る

EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)