はじめに
Javaでは、不変オブジェクトを作成する際は、インスタンスフィールドをfinalにするのが一般的かと思います。
これを、finalを付けることでフィールドへの再代入を防ぎ、オブジェクトの状態を変更できないようにすることで、マルチスレッド環境でも安全に扱いやすくなる、と理解している方も多いのではないでしょうか。
実は、インスタンスフィールドにfinalを付けることは、フィールドへの再代入を防ぐだけでなく、Javaメモリモデル(JMM)上の特別な可視性保証を得るという意味もあります。
finalのインスタンスフィールドに対する特別な保証は、次のように要約できます。
- 構築済みのオブジェクトへの参照を別スレッドが取得した場合、そのスレッドは初期化済みの
finalフィールドの値を読み取ることができる -
finalフィールドが参照するオブジェクトのフィールドや配列の要素にも、構築時の状態に対する保証が及ぶ - この保証は、通常のhappens-before関係とは異なる特別な規則として定められている
この保証により、構築済みの不変オブジェクトを別スレッドが取得したときに、その内部の一部だけが未初期化の状態に見える、といった問題を防げます。
この記事では、最初にJMMの重要な概念であるhappens-before関係を説明し、そのうえでfinalのインスタンスフィールドに与えられる保証と注意点を紹介します。
なお、Javaではフィールド、ローカル変数、引数、メソッド、クラスなどにfinalを指定できますが、この記事ではfinalなインスタンスフィールドのみを対象とします。
happens-before関係
happens-before関係の概要
happens-before関係は、JLS §17.4.5で規定されている概念です。
ある処理Aが別の処理Bにhappens-beforeする場合、Aの結果はBから可視になり、AはBより前に順序付けられます。
つまり、AとBの間にhappens-before関係が成立していれば、BはAの結果を観測でき、JMM上もAはBより前に順序付けられます。
例えば、「りんごを切る」と「りんごを食べる」という二つの処理を考えてみます。「りんごを切る」が「りんごを食べる」にhappens-beforeするなら、りんごを食べる時点では、必ずりんごが切られた状態になっている、というようなことです。
JLSでは、happens-before関係を構成する規則がいくつか定められています。ここでは代表的なものを紹介します。
プログラム順序
同じスレッド内では、プログラム順序で先にある処理と後にある処理の間にhappens-before関係が成り立ちます。
data = 42; // (1)
ready = true; // (2)
この場合、(1)は(2)にhappens-beforeします。
synchronizes-with関係
ある処理が別の処理にsynchronizes-withする場合、happens-before関係が成り立ちます。
synchronizes-with関係を作る処理には、例えば次のものがあります。
- 同じモニターに対するunlockと、その後のlock
- 同じ
volatile変数に対する書き込みと、その後の読み取り
推移性
処理xが処理yにhappens-beforeすることをhb(x, y)と表記すると、次の関係が成り立ちます。
hb(x, y) かつ hb(y, z) ⇒ hb(x, z)
これらを組み合わせることで、異なるスレッド間の処理順序と可視性を導けます。次の節で例を見てみましょう。
複数スレッド間の処理順序
次の例では、Thread 1が通常フィールドdataへ書き込んだ後、volatileフィールドreadyへ書き込んでいます。
class VolatileExample {
int data = 0;
volatile boolean ready = false;
// Thread 1
void publish() {
data = 42; // (1) 通常の書き込み
ready = true; // (2) volatile書き込み
}
// Thread 2
void consume() {
if (ready) { // (3) volatile読み取り
int value = data; // (4)
}
}
}
それぞれの関係は、次のようになります。
- (1)から(2):Thread 1内のプログラム順序
- (2)から(3):
volatileによるsynchronizes-with関係 - (3)から(4):Thread 2内のプログラム順序
- (1)から(4):上記3つの関係から導かれるhappens-before関係
したがって、Thread 2の(3)がThread 1の(2)で書き込まれたtrueを観測した場合、(4)ではdata == 42が保証されます。
このように、Javaでは、volatile変数のみならず、volatile変数への書き込みより前に行った通常変数への書き込みも、volatileの読み取りより後に行う処理から可視になります。
finalのインスタンスフィールドのメモリモデル上の特別な保証
finalフィールドの可視性保証
オブジェクトのコンストラクタが完了すると、finalフィールドには特別な可視性保証が与えられます。つまり、このオブジェクトを別のスレッドから同期することなく参照した場合でも、コンストラクタで設定したfinalフィールドの値を正しく読み取ることができるということです。
この保証はfinalフィールド自身だけに限定されません。より詳細には、コンストラクタの完了時点における次の値が保証の対象になります。
-
finalフィールド自身の値(参照型の場合は、その参照) -
finalフィールドが配列を参照する場合、その配列の要素 -
finalフィールドがオブジェクトを参照する場合、そのオブジェクトから参照をたどって到達できるすべてのフィールド
ただし、参照先のフィールドや配列要素について保証されるのは、コンストラクタの完了時点までに設定された値であることに注意が必要です。コンストラクタの完了後に変更した値まで見えることを保証するものではありません。
volatileによる可視性の保証と違い、finalによる可視性保証によって、コンストラクタ内の書き込みと別スレッドでの読み取りとの間に、通常のhappens-before関係が成立するわけではないことに注意が必要です。
finalフィールドの保証の例
次の例では、Dataに3つのfinalフィールドと1つの通常フィールドを定義します。
class Detail {
int number;
}
class Data {
final int idFinal;
final Detail detailFinal;
final int[] valuesFinal;
int ordinary;
Data() {
idFinal = 7;
Detail detail = new Detail();
detail.number = 3;
detailFinal = detail;
valuesFinal = new int[] { 10, 20 };
ordinary = 9;
}
}
別スレッドが構築後のDataへの参照を取得した場合、各読み取りに対する可視性保証の有無は次のとおりです。
| 読み取り | 可視性の保証 |
|---|---|
data.idFinal |
〇 |
data.detailFinal |
〇 |
data.detailFinal.number |
〇 |
data.valuesFinal |
〇 |
data.valuesFinal[0] |
〇 |
data.ordinary |
× |
detailFinal.numberは通常フィールドですが、finalなdetailFinalから参照をたどって読むため、構築時に書き込んだ値の可視性が保証されます。
同様に、valuesFinal[0]もfinalな配列参照からたどって読むため、構築時の値の可視性が保証されます。
一方、ordinaryはData自身の通常フィールドです。finalフィールドから参照をたどって読む値ではないため、可視性は保証されません。
finalのインスタンスフィールドの注意点
最後に、いくつか注意点を紹介します。
構築後の変更には適用されない
finalな参照は、参照先のオブジェクトや配列を不変にはしません。
data.detailFinal.number = 100;
data.valuesFinal[0] = 200;
これらはdetailFinalやvaluesFinalへの再代入ではないため、Javaの文法上は許されます。
しかし、コンストラクタ終了後の変更には、finalの可視性保証は及びません。変更後の値を別スレッドへ届けるには、適切な同期が必要です。
コンストラクタ終了前には適用されない
finalフィールドの可視性が保証されるのは、コンストラクタ終了後です。そのため、コンストラクタが終わる前にthisを別スレッドから到達できる場所へ公開すると、初期化前の値を読み取られる可能性があります。
class Broken {
static volatile Broken leaked;
final int answerFinal;
Broken() {
leaked = this; // NG: 構築途中のthisを公開
answerFinal = 42;
}
}
別スレッドがleakedから構築途中のオブジェクトを取得すると、answerFinal == 0を観測し得ます。
leakedをvolatileにしても、この例は安全になりません。leaked = thisより後に行われるanswerFinal = 42は、そのvolatile書き込みによって読み取り側へ公開されないためです。
インスタンスの参照自体の可視性は保証されない
finalが保証するのは、別スレッドが構築後の参照を取得した場合に、対象の値を正しく読み取れることです。
オブジェクトへの参照自体が、いつ別スレッドから見えるようになるかは保証されません。参照を確実に別スレッドへ公開するには、参照を保持するフィールドをvolatileにするなど、適切な同期が必要です。
static volatile Data data;
// Thread 1
data = new Data();
// Thread 2
Data local = data;
if (local != null) {
int value = local.ordinary;
}
volatileによる同期とfinalの可視性保証の関係
ここまで読んで、「オブジェクトへの参照をvolatileにして公開すれば、各フィールドの値は読み取り側から見えるのだから、なぜfinalに特別な保証が必要なのか」と疑問に思うかもしれません。
実際、上述の例のようにdataをvolatileフィールドにして適切に公開すれば、その書き込みと読み取りの間にhappens-before関係が成立します。その推移性により、Dataのコンストラクタ内で行われた書き込みは、finalでないordinaryへの書き込みも含めて読み取り側から可視になります。この場合、構築時の値を見せるためにfinalの特別な保証へ頼る必要はありません。
finalの特別な保証が意味を持つのは、オブジェクトへの参照が誤って適切に同期されずに公開された場合です。そのようなデータ競合があっても、別スレッドが構築後の参照を取得できたなら、少なくともfinalフィールドと、そこから参照をたどって到達できる構築時の値は正しく見えます。
つまり、finalの可視性保証は、「オブジェクトの参照の公開方法に不備があった場合でも、不変オブジェクトの構築時の状態が壊れて見えることを防ぐための保証」といえます。
まとめ
いかがでしたでしょうか。
finalは単にフィールドへの再代入を防ぐためのものと思われがちですが、Javaメモリモデル上、不変オブジェクトの構築時の状態を別スレッドから正しく見せるという重要な役割も持っています。
普段の開発でこの仕組みを直接意識する機会は少ないかもしれませんが、happens-before関係との違いを知っておくと、並行処理におけるfinalの意味をより深く理解できると思います。本記事が、Javaのメモリモデルを理解するうえで少しでも参考になれば幸いです。