final 详解
2024-09-30 08:00:49 # Technical # JavaConcurrency

final 这个平时经常在工具类中用到的关键字,通常知道的是它可以用来修饰类、方法、变量,修饰后表示「不可变」。但除此之外,final 经常在多线程并发场景下被提及,java 编程语言规范中也在 17 章 Thread and Locks 中提及到了 final,所以 final 在多线程的场景下有些什么特点值得深思

基础使用

快速过下 final 修饰类、方法、变量的特点

修饰类

当某个类被 final 时,这个类无法被继承,即这个类是不能有子类的

final 类中的所有方法都隐式为 final,因为无法覆盖他们,所以在 final 类中给任何方法添加 final 关键字是没有任何意义的

不过虽然无法被继承,但可以通过组合的方式来拓展这个类

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyString {
private String innnerString;

// 对旧方法的支持
public int length() {
return innerString.length();
}

// 添加新方法
public void m1() {
...
}
}

修饰方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void main(String[] args) {
C2 c2 = new C2();
c2.m1();
c2.m2();
C1 c1 = c2;
c1.m1();
c1.m2();
}

private static class C1 {
private void m1() {
System.out.println("c1 m1");
}
public void m2() {
System.out.println("c1 m2");
}
}

private static class C2 extends C1 {
private void m1() {
System.out.println("c2 m1");
}
public void m2() {
System.out.println("c2 m2");
}
}

Out:

1
2
3
4
c2 m1
c2 m2
c1 m1
c2 m2

两个特点:

  • private 修饰的方法是隐式的 final
  • final 方法不能被覆盖,但是能被重载

修饰变量

1
2
3
4
5
6
7
8
9
10
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
// k的值由随机数对象决定,只是k在初始化后无法被更改
final int k = r.nextInt();
}
  • static final 修饰的变量声明即刻赋值
  • final 变量可以在构造函数内赋值

final 域重排序规则

编译器、处理器和 JIT 编译器的指令重排序优化可能会导致多线程下的有序性问题,而由于 final 的不可更改性,因此编译器会针对 final 域的指令重排遵循新的规则

final 基本类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class FinalTest2 {

final int x;
int y;
static FinalTest2 f;

public FinalTest2() {
x = 3;
y = 4;
}

static void writer() {
f = new FinalTest2();
}

static void reader() {
if (f == null) {
System.out.println("f is null!");
} else {
System.out.println("f.y: " + f.y);
System.out.println("f.x: " + f.x);
}
}

public static void main(String[] args) {
new Thread(FinalTest2::writer).start();
new Thread(FinalTest2::reader).start();
}
}

由于 x,y 之间没有依赖性,普通变量 y 可能会被重排序到构造函数之外,这样 reader 线程读到的 y 可能是一个还未完成赋值的默认值 0

final 引用类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class FinalTest3 {

final int[] arrays;
FinalTest3 f;

public FinalTest3() {
arrays = new int[1];
arrays[0] = 1;
}

void write() {
f = new FinalTest3();
}

void read() {
if (f == null) {
System.out.println("f is null");
} else {
System.out.println("f's arrays is: " + f.arrays[0]);
}
}

public static void main(String[] args) {
FinalTest3 test3 = new FinalTest3();
new Thread(test3::write).start();
new Thread(test3::read).start();
}
}

f.arrays[0] 输出的结果总是 0,说明 array[0] = 1 无法从构造函数中逃逸出来

重排序原理

由于 final 的不可变特性,如果 final 变量在构造函数中初始化,使得 JVM 会在构造函数返回前加入 StoreStore 屏障,其他线程在读取 final 变量前会加入 LoadLoad 屏障,屏障保证了 final 变量的有序性与可见性,即保证每个线程看到的值都会是相同的

引用类型会额外保证构造函数内对 final 对象写入的有序性与可见性

this 溢出

final 可以保证在使用一个 final 对象的引用时,该对象已被构造函数初始化完成过

但需要 避免在构造过程中泄露 this,即不要在构造函数中引用 this,这可能导致其他线程访问到初始化部分的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;

public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}

public void writer() {
new FinalReferenceEscapeDemo();
}

public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}

假设一个线程 A 执行 writer 方法另一个线程 B 执行 reader 方法。因为构造函数中操作 1 和 2 之间没有数据依赖性,1 和 2 可以重排序,先执行了 2,这个时候引用对象 referenceDemo 是个没有完全初始化的对象,而当线程 B 去读取该对象时就会出错。尽管依然满足了 final 域写重排序规则:在引用对象对所有线程可见时,其 final 域已经完全初始化成功。但是,引用对象 this 逸出,该代码依然存在线程安全的问题

String 线程安全吗

String 是线程安全的

既然如此

1
2
3
4
5
6
7
8
9
10
static String s = "1";

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
s += "1";
}).start();
}
System.out.println(s);
}

String 依旧是线程安全的,只不过这里并没有用 String,而是创建了一堆 String