【Java 内存模型】— final 域的内存语义
final 域的重排序规则
对于 final 域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final 域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域重排序到构造函数之外。这个规则的实现包含下面2个方面:
- JMM 禁止编译器把 final 域的写重排序到构造函数之外。
- 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障会禁止处理器把 final 域重排序到构造函数之外。
public class FinalDemo {
int i; // 普通变量
final int j; // final 变量
static FinalDemo obj;
public FinalDemo { // 构造函数
i = 1; // 写普通域
j = 2; // 写 final 域
}
public static void write() { // 写线程 A 执行
obj = new FinalDemo();
}
public static void read() { // 读线程 B 执行
FinalDemo object = obj; // 读对象引用
int a = object.i; // 读普通域
int b = object.j; // 读 final 域
}
}
下图中是一种上述代码的执行时序:
在图中,写普通域的操作被编译器重排序到构造函数之外,线程B读到了 i 未初始化的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,线程 B 能正确读到 final 变量初始后的值。
写 final 域的重排序规则可以确保:
在对象引用被任意线程可见之前,对象的 final 域已被正确的初始化,而普通变量不会有这个保障。
读 final 域的重排序规则
读 final 域的重排序的规则是:
在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
初次读对象的引用与初次读对象的 final 域,这两个操作之间存在简介依赖关系。编译器遵守这种关系,所以编译器不会重排序这两个操作。大部分处理器也遵守,但是依然有少数的处理器不遵守从而会导致这两种操作的重排序,这个规则就是专门用来针对这种处理的。
假设线程 A 没有重排序,同时处理器不遵守这种间接依赖关系,那么上述代码的一种可能执行时序如下图:
在图中,读对象的普通域被重排序到读对象引用之前。读普通域时,该域还没被写线程 A 写入,这是一个错误的读取操作。而 final 域的读操作会被“限定”在对象引用之后,此时 final 域已经被正确初始化。
读 final 域的重排序规则可以确保:
在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用
final 域为引用类型
对于引用类型,写 final 域的重排序对编译器和处理器增加了如下约束:
在构造函数内对一个 final 引用对象的成员域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
看下面代码
public class FinalReferenceDemo {
final int[] intArray; // final 是引用类型
static FinalReferenceDemo obj;
public FinalReferenceDemo() {
intArray = new int[1];
intArray[0] = 1;
}
public static void writeOne() { // 线程 A
obj = new FinalReferenceDemo();
}
public static void writeTwo() { // 线程 B
obj.intArray[0] = 2;
}
public static void read() { // 线程 C
if (obj != null) {
int temp = obj.intArray[0];
}
}
}
假设线程 C 在线程 B 之前执行,JMM 可以确保线程 C 可以看到线程 A 在构造函数中对 final 域的写入,即 C 能看到数组下标 0 的值为 1。但是 C 对 B 的写入,不一定看得到,因为 B 和 C 存在数据竞争。
final 引用逃逸
上面提到过,写 final 重排序规则可以确保:
在引用变量为任意线程可见之前,该引用指向的对象中的 final 域在构造函数已经被正确的初始化了。
要达到上面效果,必须有一个保证,那就是:在构造函数内部,不能让这个被构造对象的引用被其他线程所见,也就是该对象引用不能再构造函数中“逃逸”。
看下面代码
public class FinalReferenceEscapeDemo {
final int i;
static FinalReferenceEscapeDemo obj;
public FinalReferenceEscapeDemo() {
i = 1; // 1 写 final 域
obj = this; // 2 this 在此”逃逸“
}
public static void write() {
new FinalReferenceEscapeDemo();
}
public static void read() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
上面代码中 1 和 2 会存在重排序,所以线程执行到 3 时,obj 可能不为 null,但是其实这时候 i 还没有初始化,所以会读到错误的值。
final 语义在处理器中的实现
final 语义在处理器中的具体实现如下:
- 写 final 域的重排序规则会要求在 final 域的写之后,构造函数 return 之前插入一个 StoreStore 屏障。
- 读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。
总结
JMM 为 Java 程序员提供初始化安全保证:
只要对象是正确构造的(没有 this “逃逸”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化后的值。