引用
https://cloud.tencent.com/developer/article/1379380
导言
滴滴二面的时候被问到了这个问题,平时用final好像一直没出什么问题,就忽视了这个,整场面试也是在这里开始崩了的,太弱了qwq
常见用法
当时问到的时候,随口就说了下面这些final的特性
- 修饰常量,Java不支持原生常量,在Java种也没有定义常量的const关键字。然而, 我们可以使用final关键字间接的实现常量
- 修饰变量,此时变量的引用不可被修改(如使用final修饰String时,因为String本身是不可变的(内部也是一个final字符组),String赋值是修改了指向。故而在被final修饰后,引用不可改变,可被视为常数),但final不能阻止变量内在值的修改(如果我们用final修饰一个List,会发现List仍然可以进行add,但不能使其指向另一个List)
- 修饰类与方法,被final修饰的类不可被继承,被final修饰的方法不可被重写
- 使用final的实例必须被初始化
原理
然后面试官就问:"你加了final之后,这个List它的内存模型发生了什么变化?说具体一点"
emm....不具体我都不会,但知耻而后勇,我们可以现在学!
JAVA 中的域
所谓的域,翻译成英文就是field, 也就是我们常说的字段,或者说是属性。 比如类的字段(属性),局部的,全局的。所谓域,其实是“field”的翻译
关于Java中的变量,官方文档中如是说:
- 类中的成员变量——称为字段(亦即 “域”)
- 一个方法或代码块中的变量——称为局部变量(亦即 “本地变量”)
- 在方法声明中的变量——称为参数
final 域
对于final域,编译器和处理器要遵守两个重排序规则:
1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(先写入final变量,后调用该对象引用)
原因:编译器会在final域的写之后,插入一个StoreStore屏障
2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。(先读对象的引用,后读final变量)
原因:编译器会在读final域操作的前面插入一个LoadLoad屏障
示例
public class FinalExample {
int i; // 普通变量
final int j; // final 变量
static FinalExample obj;
public void FinalExample() { // 构造函数
i = 1; // 写普通域
j = 2; // 写 final 域
}
public static void writer() { // 写线程 A 执行
obj = new FinalExample();
}
public static void reader() { // 读线程 B 执行
FinalExample object = obj; // 读对象引用
int a = object.i; // 读普通域 a=1或者a=0或者直接报错i没有初始化
int b = object.j; // 读 final域 b=2
}}
这里假设一个线程 A 执行 writer ()方法,随后另一个线程 B 执行 reader ()方法。
- 第一种情况
写普通域的操作被编译器重排序到了构造函数之外
而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,读线程 B 正确的读取了 final 变量初始化之后的值。
写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。
- 第二种情况
读对象的普通域的操作被处理器重排序到读对象引用之前
而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。
final 域是引用类型
对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
public class FinalReferenceExample {
final int[] intArray; // final 是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample() { // 构造函数
intArray = new int[1]; // 1
intArray[0] = 1; // 2
}
public static void writerOne() { // 写线程 A 执行
obj = new FinalReferenceExample(); // 3
}
public static void writerTwo() { // 写线程 B 执行
obj.intArray[0] = 2; // 4
}
public static void reader() { // 读线程 C 执行
if (obj != null) { // 5
int temp1 = obj.intArray[0]; // 6 temp1=1或者temp1=2,不可能等于0
}
}}
1 是对 final 域的写入;
2 是对这个 final 域引用的对象的成员域的写入;
3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。
JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。