2020/04/08

Java の final の仕様(3) - final フィールドのセマンティクスの有用性と仕組み

final フィールドのセマンティクス

final フィールドは初期化されたら変更されることはありません。そのため、final フィールドへのアクセスと、通常のフィールドへのアクセスは、その意味するところ (セマンティクス) が少し異なってきます。例えば、通常のフィールドの内容の参照は、「そのタイミングでの値」を取得するという意味を持ちますが、final フィールドの場合は内容が変わりませんから、「そのタイミングでの」という意味は抜け落ちます。
Java は final フィールドのセマンティクスを活用して、メモリアクセスの最適化をしたり、同期化を減らしたり、セキュリティを担保したりしています。Java 言語仕様に沿って確認してみたいと思います。

final フィールドのセマンティクスを利用した最適化

final フィールドのセマンティクスを考慮すると、final フィールドについては同期化バリアを超えて参照タイミングを移動するような最適化をしても良いなど、最適化の自由度が上がります。通常のフィールドならメモリからレジスタに読み込み直さないといけないような状況でも、final フィールドならレジスタに読み込み済の値をそのまま使い続けることができます。

このように、final フィールド特有のセマンティクスは最適化で活用されています。適切に final フィールドを使用することにより、性能を向上させることができます。

final フィールドを使って同期化を減らす

finalフィールドのセマンティクスは、最適化だけでなく、マルチスレッドプログラミングを行うプログラマの負担も軽減してくれます。
final フィールドを正しく使えば、同期化しなくても、スレッドセーフな immutable オブジェクトを定義できます。final 宣言により書き込みが行われないことが担保されていれば、同期化しなくても、データ競合は発生しないと分かります。

オブジェクトは、コンストラクタの終了をもって初期化完了となります。スレッドは、初期化が完了した後のオブジェクトの参照だけを見ることができ、そのオブジェクトの final フィールドについても正しく初期化された値を参照できることが担保されています。
具体的には、コンストラクタで final フィールドに値をセットし、コンストラクタの終了前に、別のスレッドが参照できる場所 (例えば他のオブジェクトのフィールドであったり、引数として渡されたコレクションの中身であったり) に構築中のオブジェクトの参照 (this) を書き込まないようにします。こうすることで他スレッドからは必ず、final フィールドが正しく構築された状態で見えます。なおかつ、final フィールドから参照されているオブジェクトの状態も、少なくとも final フィールドと同等に最新の状態で見えます。次の例を見てください。

public class ThreadSafeImmutableObject {
  private final String x;
  private final String y;
  public ThreadSafeImmutableObject() {
    x = "";
    y = "";
  }
  public ThreadSafeImmutableObject(final String x, final String y) {
    this.x = x;
    this.y = y;
  }
  public String getX() {
    return x;
  }
  public String getY() {
    return y;
  }
}

このクラスは immutable であり、final フィールド x, y はコンストラクタで初期化されています。明示的な同期化はされていませんが、これで十分スレッドセーフです。

以下のプログラムは、finalフィールドと通常のフィールドとの比較を示しています。

public class UnsafeObject { 
    private final int x;
    private int y; 
    private static UnsafeObject instance;

    public UnsafeObject() {
        x = 5; 
        y = 7; 
    } 

    static void write() {
        instance = new UnsafeObject();
    } 

    static void read() {
        if (instance != null) {
            int i = instance.x;  // 5 であることが担保されています  
            int j = instance.y;  // 0 かもしれません
        } 
    } 
}

final フィールド x と通常のフィールド y があります。あるスレッドが write() を実行すると、instance にインスタンスが格納され、別のスレッドが read() を実行すると、instance にインスタンスが格納されていたときには x, y の値を取り出します。x = 5, y = 7 で初期化しているので、それが取り出せると期待しています。
write() はオブジェクトのコンストラクタの終了後に instance に書き込みを行います。read() は instance.x については正しい値が読み取れます。final フィールドは初期化されていることが担保されているためです。しかし、instance.y は final ではないため、担保がありません。j は必ずしも 7 ではなく、0 になることがありえます。アウト・オブ・オーダー実行に関連していますので、そちらも見てみてください。

final フィールドのセキュリティ

2つのスレッドが1つの String 方の static 変数にアクセスしている状況を考えます。
スレッド1:
Holder.str = "abcdefg".substring(3);

スレッド2:
String x = Holder.str;
if (x.equals("abc")) {
  System.out.println(x);
}

String オブジェクトは immutable なので、String 操作は同期化されません。String の実装にはデータ競合はありませんが、他のコードでは、String オブジェクトの使用を絡めたデータ競合が発生する可能性はあります。
Java メモリモデルは、データ競合のあるプログラムに対し、弱い保証を行います。
String クラスの内部のフィールドが final でなかったとすると、スレッド2 は String オブジェクトの初期化が完了せず、長さがデフォルトの 0 のままである状態を参照して、equals() を実行してしまうことがあります (可能性は低いですが)。その後は正しく "abc" という文字列と認識されます。実際には、final フィールドで保持しているため、このようなことは起きません。
悪意のあるコードは、スレッド間で文字列参照を渡すときのデータ競合を悪用してくることがありますが、Java のセキュリティ機能は、String が真に immutable であることによって、このような悪意から防衛しています。

final フィールドのセマンティクスの仕組み

ここからは、Java VM の内部的な仕組みに関する話題です。

デリファレンスチェーンとメモリチェーン

以下のような状況を考えます。
  • o: オブジェクト
  • f: o の final フィールド
  • c: o のコンストラクタで、f に書き込みを行う

f の凍結操作は、c の終了時に行われます。正常終了でも異常終了でも行われます。
c が別のコンストラクター c2 を呼び出し、c2 が final フィールドを設定した場合、final フィールドの凍結は c2 の終了時に行われることに注意してください。
実行の都度、読み取り動作はデリファレンスチェーンとメモリチェーンという2つのチェーン (半順序関係) の影響を受けます。2つのチェーンの構築処理は実行の一部であり、チェーンは実行ごとに組み立てられます。
2つのチェーンは、次のような制約を満たす関係性の集まりです。

デリファレンスチェーン

あるアクション a が、スレッド t によるオブジェクト o のフィールドまたは要素の読み取りまたは書き込みであるとします。ただし t は o を初期化したスレッドではありません。
このとき、スレッド t によって o のアドレスを見る何らかの読み取り r が存在しなければなりません。
このとき、dereferences(r, a) という半順序関係があるとします。

メモリチェーン

メモリチェーンを構成する半順序関係の制約には、次の3種類があります。
  • 読み取り r が書き込み w を見ているとき、mc(w, r) が存在する
  • dereferences(r, a) が存在するとき、mc(r, a) が存在する
  • 書き込み w がスレッド t によるオブジェクト o のアドレスへの書き込み (ただし、t は o を初期化したスレッドではない) であるとき、t が o のアドレスを見るための読み取り r が存在しなければならず、mc(r, w) が存在する

以下の情報が与えられたとします。
  • 書き込み w
  • final フィールドの凍結 f
  • 何らかのアクション a (ただし、final フィールドの読み取り以外)
  • f によって凍結された final フィールドの読み取り r1
  • 読み取り r2
ここに、以下のようなチェーンがあるとします。happends-before(x, y) は、y の前に x が起きていなければならないという半順序関係です。
  • happens-before(w, f)
  • happens-before(f, a)
  • mc(a, r1)
  • dereferences(r1, r2)
この状況で、r2 で読み取れる値を決定するには、happens-before(w, r2) という関係を考慮する必要があります。happens-before 関係は、推移的に閉じておらず、dereferences 関係、mc 関係を包括的に考慮する必要があります。
final フィールドの読み取りにあたって、その前に発生すると判断される書き込みは、final フィールドのセマンティクス、つまりここで説明した順序関係によって導出されたもののみです。

コンストラクタ内での final フィールドの読み取り

オブジェクトの final フィールドの読み取りは、コンストラクタ内から同じスレッドで読み取る場合、happens-before ルールによってそのフィールドの初期化と順序付けられます。コンストラクタでフィールドが設定された後に読み取りが発生した場合、最後のフィールドに割り当てられた値が見えます。それ以外の場合は、デフォルト値が見えます。

final フィールドを後から変更することについて

デシリアライズなどのいくつかのケースでは、構築後のオブジェクトの final フィールドを変更する必要があります。リフレクションや、何らかの VM 実装依存の方法で変更することが可能です。このような変更が合理的なセマンティクスを持つ唯一のパターンとして、オブジェクト生成直後に final フィールドを更新する、というものがあります。

まず、final フィールドの全更新が完了するまで、オブジェクトを他のスレッドから見えるようにすることや、final フィールドを読み取ることはできるべきではありません。その上で、final フィールドの凍結をコンストラクタの終了時と、リフレクションまたは他の特殊なメカニズムによる final フィールド更新の完了直後の両方で行うようにします。
まだいくつかの複雑な問題があります。
  • final フィールドが宣言時に定数式で初期化されている場合、final フィールドの使用はコンパイル時に定数式の値に置き換えられているため、 final フィールドを変更しても、その値が反映されない
  • final フィールドの積極的な最適化を許可する仕様により、スレッド内で、コンストラクタ内で行われなかった変更を伴う final フィールドの読み取りのリオーダーが許可されてしまう
次の例を見てください。

class UpdateFinal {
  final int x;
  UpdateFinal() { 
    x = 1; 
  } 
  int getAnswer() { 
    return calculate(this, this); 
  } 
  int calculate(UpdateFinal a1, UpdateFinal a2) { 
    int i = a1.x; 
    updateX(a1); 
    int j = a2.x;
    return j - i; 
  }
  static void updateX(UpdateFinal a) { 
    // リフレクションを使って a.x を 2 に変更する
  } 
}

calculate() に対し VM は、x の読み取りと updateX() の呼び出しを自由にリオーダーできます。そのため、getAnswer() の結果は -1 だったり 0 だったり 1 だったりします。
  • 先に updateX() されると、-1
  • 書いてある通りに実行されれば 0
  • updateX() の状況によっては a1.x が 0 になって、答えは 1
VM 実装は、final-field-safe なコンテキストでコードブロックを実行するようになっています。オブジェクトが final-field-safe なコンテキスト内で構築されているなら、final フィールドの読み取りは、そのコンテキスト内で発生した final フィールドの更新についてリオーダーされないようになっています。
final-field-safe コンテキストは、さらなる保護がされています。
スレッドが誤って公開された参照を見ているとき (コンストラクタからの this の漏洩など) は final フィールドにはデフォルト値が見えますが、final-field-safe コンテキスト内で適切に公開された参照を見ているときは正しい値が見えることを保証します。形式的には、final-field-safe コンテキスト内で実行されるコードは、final フィールドのセマンティクスの目的においてのみ、個別のスレッドのように扱われます。
VM は final フィールドへのアクセスを final-field-safe コンテキストの内部から外部へ移動したり、その逆をしたりしないようになっています。
final-field-safe コンテキストの使用が適切な場所の1つは、Executor やスレッドプールです。個別の final-field-safe コンテキスト内で各 Runnable を実行することで、Executor はある Runnable による誤ったアクセスが同じ Executor の他の Runnable の final フィールドに関する保証を失わせないことを保証できます。

write-protected フィールド

とても特殊な final フィールドの一種として、write-protected フィールドというものがあります。
通常、final フィールドは変更できませんが、System クラスの in, out, err は歴史的な事情で final static フィールドになっているにも係らず、setIn(), setOut(), setErr() メソッドによる変更が出来なければなりません。これらのフィールドは、通常の final フィールドと区別するために write-protected フィールドと呼びます。
VM は、write-protected フィールドを通常の final フィールドとは異なる方法で処理する必要があります。例えば、通常の final フィールドの読み取りは同期化の影響を受けません。ロックまたは volatile 読み取りに伴うバリアは、final フィールドから読み取られる値に影響しません。しかし write-protected フィールドは値が変化することがありますから、同期化の影響を受けさせなければなりません。したがって、final フィールドセマンティクスでは、これらのフィールドは、ユーザーコードが System クラスに含まれていない限り、ユーザーコードによって変更できない通常のフィールドとして扱われるように指示されています。