从并发角度讲final关键字
前言
final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。一旦将引用声明作final,将不能改变这个引用了,编译器会检查代码,如果试图将变量再次初始化的话,编译器会报编译错误。
一、final基本使用
在Java
中,final
关键字可以用来修饰类、方法和变量(包括成员变量和局部变量)。
1.1 修饰类
当用final
修饰一个类时,表明这个类不能被继承。最常见是就是String类,任何类都无法继承它。
1 |
|
如果一个类永远不会让他被继承(子类继承往往可以重写父类的方法和改变父类属性,会带来一定的安全隐患),就可以用final进行修饰。
注意,final类中的成员变量可以根据需要设置为final,但是它的所有成员方法会被隐式地指定为final方法。在使用final修饰类的时候,要注意谨慎选择,除非这个类真的在以后不会用来继承或者出于安全的考虑,尽量不要将类设计为final类。
解决方法
设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合。
如下代码大概写个组合实现的意思:
1 |
|
1.2 修饰方法
当父类的方法被final
修饰的时候,子类不能重写父类的该方法,比如在Object
中,getClass()
方法就是final
的,我们就不能重写该方法。
1 |
|
如果想禁止该方法在子类中被重写的,可以设置该方法为为final
。
1.2.1 private final
因为重写的前提是子类可以从父类中继承此方法,如果父类中final修饰的方法同时访问控制权限为private,将会导致子类中不能直接继承到此方法,此时子类中就可以定义相同的方法名和参数。
示例
1 |
|
说明
Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()
是不可执行的,因为Base中的test方法是private的,无法被访问到。
1.2.2 final方法是可以被重载的
父类的final方法是不能够被子类重写的,但是final方法可以被重载。
1 |
|
1.3 修饰参数
Java允许在参数列表中以声明的方式将参数指明为final,这意味着无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据。
1 |
|
在上面的代码中,我们声明了一个名为 process
的方法,并将其中的 message
参数指定为 final。由于 message
已经被声明为 final,所以无法在方法中更改它的值。如果我们尝试在方法中更改 message
的值,编译器将会报错。
1.4 修饰变量
final修饰变量表示这个变量一旦赋值就不能修改。
当final修饰一个基本数据类型时,表示该基本数据类型的值一旦在初始化后便不能发生变化;
如果final修饰一个引用类型时,则在对其初始化之后便不能再让其指向其他对象了,但该引用所指向的对象的内容是可以发生变化的,final只保证这个引用类型变量所引用的地址不会发生改变,即一直引用这个对象,但这个对象属性是可以改变的。
1.4.1 成员变量
Java中,成员变量分为类变量(static修饰)和实例变量。
针对这两种类型的变量赋初值的时机是不同的:
- 类变量可以在声明变量的时候直接赋初值或者在静态代码块中给类变量赋初值;
- 实例变量可以在声明变量的时候给实例变量赋初值,在非静态初始化块中以及构造器中赋初值。
因此类变量有两个时机赋初值,而实例变量则可以有三个时机赋初值。
被final修饰的变量必须在上述时机赋初值,否则编译器会报错。
总结
- final修饰的类变量:必须要在静态初始化块中指定初始值或者声明该类变量时指定初始值,而且只能在这两个地方之一进行指定,一旦赋值后不能再修改。
- final修饰的实例变量:必要要在非静态初始化块,声明该实例变量或者在构造器中指定初始值,而且只能在这三个地方之一进行指定,一旦赋值后不能再修改。
1.4.2 局部变量
final
局部变量由程序员进行显式初始化,如果final
局部变量已经进行了初始化则后面就不能再次进行更改,如果final
变量未进行初始化,可以进行赋值,当且仅有一次赋值,一旦赋值之后再次赋值就会出错。
当局部变量被final修饰时,它表示该变量的值只能被初始化一次,一旦初始化之后就不能再被修改。下面是一个示例代码:
1 |
|
上述代码中,变量num
被定义为局部变量,并且用final
关键字进行修饰。由于该变量被final
修饰,因此在函数内部无法更改其值。
二、final域重排序规则
前面介绍的只是final关键字的基础用法。然而在多线程的层面,final也有其自己的内存语义。主要体现在final域的重排序上,下面来介绍final的重排序规则。
2.1 final域为基本类型
先看一段示例性的代码:
1 |
|
假设线程A在执行writer()方法,随后另一个线程B执行reader()方法。
2.1.1 写final域重排序规则
写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
- JMM禁止编译器把final域的写重排序到构造函数之外;
- 编译器会在final域写之后,构造函数return之前,插入一个
storestore
屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情:
- 构造了一个
FinalDemo
对象; - 把这个对象赋值给成员变量
finalDemo
。
我们来画下存在的一种可能执行时序图,如下:
由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。
因此,写final域的重排序规则可以确保:
在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo
。
2.1.2 读final域重排序规则
读final域重排序规则为:
在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad
屏障。
实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。
read()方法主要包含了三个操作:
- 初次读引用变量
finalDemo
; - 初次读引用变量
finalDemo
的普通域a; - 初次读引用变量
finalDemo
的final域b;
假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:
读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。
2.2 final域为引用类型
2.2.1 对final修饰的对象的成员域写操作
针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:
在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。
注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是适用。
示例
1 |
|
针对上面的实例程序,线程A执行wirterOne
方法,执行完后线程B执行writerTwo
方法,然后线程C执行reader方法。
下图就以这种执行时序出现的一种情况来讨论:
由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
2.2.2 对final修饰的对象的成员域读操作
JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。
2.3 关于final重排序的总结(重点)
按照final修饰的数据类型分类:
- 基本数据类型:
final域写
:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。final域读
:禁止初次读对象的引用与读该对象包含的final域的重排序。
- 引用数据类型:
额外增加约束
:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序