一、简介
final 可以修饰变量,方法和类,用于表示所修饰的内容一旦赋值之后就不会再被改变,比如 String 类就是一个 final 类型的类。即使能够知道 final 具体的使用方法,对 final 在多线程中存在的重排序问题也很容易忽略,在此做探讨。
二、使用场景
final 能够修饰变量,方法和类,也就是 final 使用范围基本涵盖了 java 每个地方,下面就分别以锁修饰的位置:变量,方法和类进行说明。
2.1、变量
2.1.1、成员变量
通常每个类中的成员变量可以分为类变量(static 修饰的变量)
以及实例变量
。针对这两种类型的变量赋初值的时机是不同的,类变量可以在声明变量的时候直接赋初值或者在静态代码块中给类变量赋初值。而实例变量可以在声明变量的时候给实例变量赋初值,在非静态初始化块中以及构造器中赋初值。类变量有两个时机赋初值,而实例变量则可以有三个时机赋初值。当 final 变量未初始化时系统不会进行隐式初始化,会出现报错。
public class FinalDemo {
private final int a = 6;
private final String str;
private final static boolean b;
private final double c;
// 没有再构造函数,初始化代码块,声明时赋值,报错
private final char ch;
{
// 示例变量可以初始化块中赋初值
str = "初始化代码块赋值";
}
static {
// 类变量(静态变量)可以在静态初始化块中赋值
b = true;
// 非静态变量不可以在静态初始化块中赋初值
str = "非静态变量不可以在静态初始化块中赋初值";
}
public FinalDemo() {
// 实例变量可以在初始化块中赋值
c = 1.0;
// 已经赋值就不能再更改
a = 10;
}
public void a(){
// 实例方法不能为final类型变量赋值
ch = 'a';
}
}
类变量
:必须要在静态初始化块中指定初始值或者声明该类变量时指定初始值,而且只能在这两个地方之一进行指定;
实例变量
:必要要在非静态初始化块,声明该实例变量或者在构造器中指定初始值,而且只能在这三个地方进行指定。
2.1.2、局部变量
public void test(final int a) {
final int b;
b = 1;
// 不能再次赋值
b = 2;
a = 3;
}
2.1.3、final 基本数据类型 VS final 引用数据类型
如果 final 修饰的是一个基本数据类型的数据,一旦赋值后就不能再次更改,那么,如果 final 是引用数据类型了?这个引用的对象能够改变吗?
public class FinalExample {
// 在声明final实例成员变量时进行赋值
private final static Person person = new Person(24, 170);
public static void main(String[] args) {
// 对final引用数据类型person进行更改
person.age = 22;
System.out.println(person.toString());
}
static class Person {
private int age;
private int height;
public Person(int age, int height) {
this.age = age;
this.height = height;
}
@Override
public String toString() {
return "Person{" +
"age=" + age +
", height=" + height +
'}';
}
}
}
当对 final 修饰的引用数据类型变量 person 的属性改成 22,是可以成功操作的。当 final 修饰基本数据类型变量时,不能对基本数据类型变量重新赋值,因此基本数据类型变量不能被改变。而对于引用类型变量而言,它仅仅保存的是一个引用,final 只保证这个引用类型变量所引用的地址不会发生改变,即一直引用这个对象,但这个对象属性是可以改变的。
2.1.4、宏变量
利用 final 变量的不可更改性,在满足一下三个条件时,该变量就会成为一个 “宏变量”,即是一个常量。
1、使用 final 修饰符修饰;
2、在定义该 final 变量时就指定了初始值;
3、该初始值在编译时就能够唯一指定。
注意:当程序中其他地方使用该宏变量的地方,编译器会直接替换成该变量的值。
2.2、方法
2.2.1、重写
当父类的方法被 final 修饰的时候,子类不能重写父类的该方法,比如在 Object 中,getClass () 方法就是 final 的,就不能重写该方法,但是 hashCode () 方法就不是被 final 所修饰的,可以重写 hashCode () 方法。
public class FinalExampleParent {
public final void test() {
}
}
FinalExample 继承该父类,当重写 test () 方法时出现报错。
2.2.2、重载
public class FinalExampleParent {
public final void test() {
}
public final void test(String str) {
}
}
- 1、父类的 final 方法是不能够被子类重写的。
- 2、final 方法是可以被重载的。
2.3、类
当一个类被 final 修饰时,表明该类是不能被子类继承的。子类继承往往可以重写父类的方法和改变父类属性,会带来一定的安全隐患,因此,当一个类不希望被继承时就可以使用 final 修饰。
public final class FinalExampleParent {
public final void test() {
}
}
父类被 final 修饰,当子类继承该父类的时候,就会报错。
三、例子
final 经常会被用作不变类上,利用 final 的不可更改性。
不变类的意思是创建该类的实例后,该实例的实例变量是不可改变的。满足以下条件则可以成为不可变类:
- 使用 private 和 final 修饰符来修饰该类的成员变量
- 提供带参的构造器用于初始化类的成员变量;
- 仅为该类的成员变量提供 getter 方法,不提供 setter 方法,因为普通方法无法修改 fina 修饰的成员变量;
- 如果有必要就重写 Object 类 的 hashCode () 和 equals () 方法,应该保证用 equals () 判断相同的两个对象其 Hashcode 值也是相等的。
JDK 中提供的八个包装类和 String 类都是不可变类。
/** The value is used for character storage. */
private final char value[];
四、多线程中的 final
上述 final 使用,应该属于 Java 基础层面的。final 在多线程并发的情况下,在 java 内存模型中我们知道 java 内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说 java 内存模型就是一弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。那么,在多线程情况下,final 会进行怎样的重排序?会导致线程安全的问题吗?
4.1、final 域重排序规则
4.1.1、final 域为基本类型
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;
public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
假设线程 A 在执行 writer () 方法,线程 B 执行 reader () 方法。
写 final 域的重排序规则禁止对 final 域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
1、JMM 禁止编译器把 final 域的写重排序到构造函数之外;
2、编译器会在 final 域写之后,构造函数 return 之前,插入一个 storestore 屏障。这个屏障可以禁止处理器把 final 域的写重排序到构造函数之外。
writer 方法,虽然只有一行代码,但实际上做了两件事情:
1、构造了一个 FinalDemo 对象;
2、把这个对象赋值给成员变量 finalDemo。
存在的一种可能执行时序图,如下:
由于 a,b 之间没有数据依赖性,普通域(普通变量)a 可能会被重排序到构造函数之外,线程 B 就有可能读到的是普通变量 a 初始化之前的值(零值),这样就可能出现错误。而 final 域变量 b,根据重排序规则,会禁止 final 修饰的变量 b 重排序到构造函数之外,从而 b 能够正确赋值,线程 B 就能够读到 final 变量初始化后的值。
因此,写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程 B 有可能就是一个未正确初始化的对象 finalDemo。
读 final 域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的 final 域,JMM 会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读 final 域操作的前面插入一个 LoadLoad 屏障。实际上,读对象的引用和读该对象的 final 域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。
read () 方法主要包含了三个操作:
1、初次读引用变量 finalDemo;
2、初次读引用变量 finalDemo 的普通域 a;
3、初次读引用变量 finalDemo 的 final 与 b;
假设线程 A 写过程没有重排序,那么线程 A 和线程 B 有一种的可能执行时序为下图:
读对象的普通域被重排序到了读对象引用的前面就会出现线程 B 还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而 final 域的读操作就 “限定” 了在读 final 域变量前已经读到了该对象的引用,从而就可以避免这种情况。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读这个包含这个 final 域的对象的引用。
4.1.2、final 域为引用类型
final域是基本数据类型的时候重排序规则是怎么的?如果是引用数据类型?
对 final 修饰的对象的成员域写操作:
针对引用数据类型,final 域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个 final 修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是 “增加” 也就说前面对 final 基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看。
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}
public void writerTwo() {
arrays[0] = 2; //4
}
public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}
针对上面的实例程序,线程线程 A 执行 wirterOne 方法,执行完后线程 B 执行 writerTwo 方法,然后线程 C 执行 reader 方法。下图就以这种执行时序出现的一种情况来讨论。
由于对 final 域的写禁止重排序到构造方法外,因此 1 和 3 不能被重排序。由于一个 final 域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此 2 和 3 不能重排序。
对 final 修饰的对象的成员域读操作:
JMM 可以确保线程 C 至少能看到写线程 A 对 final 引用的对象的成员域的写入,即能看下 arrays (0) = 1,而写线程 B 对数组元素的写入可能看到可能看不到。JMM 不保证线程 B 的写入对线程 C 可见,线程 B 和线程 C 之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者 volatile。
按照 final 修饰的数据类型分类:
- 基本数据类型:
- 1、final 域写:禁止 final 域写与构造方法重排序,即禁止 final 域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的 final 域全部已经初始化过。
- 2、final 域读:禁止初次读对象的引用与读该对象包含的 final 域的重排序。
- 引用数据类型:
- 额外增加约束:禁止在构造函数对一个 final 修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序。
五、实现原理
写 final 域会要求编译器在 final 域写之后,构造函数返回前插入一个 StoreStore 屏障。读 final 域的重排序规则会要求编译器在读 final 域的操作前插入一个 LoadLoad 屏障。
果以 X86 处理为例,X86 不会对写 - 写重排序,所以 StoreStore 屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在 X86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。也就是说,以 X86 为例的话,对 final 域的读 / 写的内存屏障都会被省略!具体是否插入还是得看是什么处理器。
六、为什么 final 引用不能从构造函数中 “溢出”
这里还有一个比较有意思的问题:上面对 final 域写重排序规则可以确保我们在使用一个对象引用的时候该对象的 final 域已经在构造函数被初始化过了。但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中 “逸出”。以下面的例子来说:
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 方法另一个线程执行 reader 方法。因为构造函数中操作 1 和 2 之间没有数据依赖性,1 和 2 可以重排序,先执行了 2,这个时候引用对象 referenceDemo 是个没有完全初始化的对象,而当线程 B 去读取该对象时就会出错。尽管依然满足了 final 域写重排序规则:在引用对象对所有线程可见时,其 final 域已经完全初始化成功。但是,引用对象 “this” 逸出,该代码依然存在线程安全的问题。
评论