引用

https://cloud.tencent.com/developer/article/1379380

导言

滴滴二面的时候被问到了这个问题,平时用final好像一直没出什么问题,就忽视了这个,整场面试也是在这里开始崩了的,太弱了qwq

常见用法

当时问到的时候,随口就说了下面这些final的特性

  1. 修饰常量,Java不支持原生常量,在Java种也没有定义常量的const关键字。然而, 我们可以使用final关键字间接的实现常量
  2. 修饰变量,此时变量的引用不可被修改(如使用final修饰String时,因为String本身是不可变的(内部也是一个final字符组),String赋值是修改了指向。故而在被final修饰后,引用不可改变,可被视为常数),但final不能阻止变量内在值的修改(如果我们用final修饰一个List,会发现List仍然可以进行add,但不能使其指向另一个List)
  3. 修饰类与方法,被final修饰的类不可被继承,被final修饰的方法不可被重写
  4. 使用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 域已经被正确初始化过了,而普通域不具有这个保障。
image.png

  • 第二种情况
    读对象的普通域的操作被处理器重排序到读对象引用之前

而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。

读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。

image.png

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
        }
    }}

image.png
1 是对 final 域的写入;
2 是对这个 final 域引用的对象的成员域的写入;
3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。