Java 面试 -final 的内存语义,我就不信你还吃不透 Java 的泛型
编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
现在开始分析 writer()方法:
/**
线程 A 执行 writer 写方法
*/
public static void writer() {
obj = new FinalExample();
}
构造一个 FinalExample 类型的对象
将对象的引用赋值给变量 obj
?
首先假设线程 B 读对象引用与读对象的成员域之间没有重排序,则下图是其一种执行可能
线程执行时序图
3、读 final 与的重排序规则
读 final 域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意是处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
?
解释:初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。
编译器遵守间接依赖关系,编译器不会重排序这两个操作
大多数处理器也遵守间接依赖,不会重排序这两个操作。但是少部分处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门针对这种处理器的。
?
分析 reader()方法:
/**
线程 B 执行 reader 读方法
*/
public static void reader() {
// 读对象的引用
FinalExample finalExample = obj;
// 读普通域
int a = finalExample.i;
// 读 final 域
int b = finalExample.j;
}
初次读引用变量 obj
初次读引用变量 obj 指向对象的普通域 j
初次读引用变量 obj 指向对象的 final 域 i
假设 B 线程所处的处理器不遵守间接依赖关系,且 A 线程执行过程中没有发生任何重排序,此时存在如下的执行时序:
线程执行时序图
**上图 B 线程中读对象的普通域被重排序到处理器读取对象引用之前,**此时普通域 i 还没有被线程 A 写入,因此这是一个错误的读取操作。但是 final 域的读取会被重排序规则把读 final 域的操作“限定”在读该 final 域所属对象的引用读取之后,此时 final 域已经被正确的初始化了,这是一个正确的读取操作。
?
总结:
读 final 域的重排序规则可以确保,在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。
?
4、final 域为引用类型
上面讲述了基础数据类型,如果 final 域修饰的引用类型又该如何?
package com.lizba.p1;
/**
<p>
</p>
@Author: Liziba
@Date: 2021/6/11 21:52
*/
public class FinalReferenceExample {
/** final 是引用类型 */
final int[] intArray;
static FinalReferenceExample obj;
/**
构造函数
*/
public FinalReferenceExample() {
this.intArray = new int[1]; // 1
intArray[0] = 1; // 2
}
/**
写线程 A 执行
*/
public static void writer1() {
obj = new FinalReferenceExample(); // 3
}
/**
写线程 B 执行
*/
public static void writer2() {
obj.intArray[0] = 2; // 4
}
/**
读线程 C 执行
*/
public static void reader() {
if (obj != null) { // 5
int temp = obj.intArray[0]; // 6
}
}
}
如上 final 域为一个 int 类型的数组的引用变量。对应引用类型,写 final 域的重排序对编译器和处理器增加了如下约束:
在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给另一个引用变量,这两个操作不能重排序。
对于上述程序,假设 A 执行 writer1()方法,执行完后线程 B 执行 writer2()方法,执行完后线程 C 执行 reader()方法。则存在如下线程执行时序:
引用型 final 的执行时序图
JMM 对于上述代码,可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即写线程 C 至少能看到数组下标 0 的值为 1。但是写线程 B 对数组元素的写入,读线程 C 可能看得到可能看不到。JMM 不能保证线程 B 的写入对读线程 C 可见。因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。
此时如果想确保读线程 C 看到写线程 B 对数组元素的写入,可以结合同步原语(volatile 或者 lock)来实现。
?
5、为什么 final 引用不能从构造函数内“逸出”
本文一直在说写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化了。那究竟是如何实现的呢?
其实这需要另一个条件:在构造函数内部,不能让这个被构造对象的引用被其它线程所见。也就是对象引用不能在构造函数中“逸出”。
?
示例代码:
package com.lizba.p1;
/**
<p>
final 引用逸出 demo
</p>
@Author: Liziba
@Date: 2021/6/11 22:33
*/
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample() {
i = 1; // 1、写 final 域
obj = this; // 2、this 引用在此处"逸出"
}
public static void writer() {
new FinalReferenceEscapeExample();
}
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
假设线程 A 执行 writer()方法,线程 B 执行 reader()方法。这里操作 2 导致对象还未完成构造前就对线程 B 可见了。因为 1 和 2 允许重排序,所以线程 B 可能无法看到 final 域被正确初始化后的值。实际执行的时序图可能如下所示:
多线程执行时序图
总结:
在构造函数返回之前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没被初始化。而在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。
?
6、final 语义在处理器中的实现
举例 X86 处理器中 final 语义的具体实现。
在编译器中会存在如下的处理:
写 final 域的重排序规则会要求编译器在 final 域的写之后,构造函数 return 之前插入一个 StoreStore 屏障
读 final 域的重排序规则要求编译器在读 final 域的操作前插入一个 LoadLoad 屏障
但是,由于 X86 处理器不会对写-写操作做重排序,所以在 X86 处理器中,写 final 域需要的 StoreStore 屏障会被省略。同样,由于 X86 处理器不会对存在间接依赖关系的操作做重排序,所以在 X86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。因此,在 X86 处理器中,final 域的读/写不会插入任何内存屏障。
评论