final 关键字在我们学习 Java 基础时都接触过,而且 String 类本身就是一个 final 类,此外,在使用匿名内部类的时候可能会经常用到 final 关键字。那么 final 关键字到底有什么特殊之处,今天我们就来了解一下。
final关键字的基本用法
在 Java 中,final 关键字可以用来修饰类、方法和变量(包括成员变量和局部变量)。下面就从这三个方面来了解一下 final 关键字的基本用法。
修饰类
当用 final 修饰一个类时,表明这个类不能被继承,比如说 String 类。final 类中的成员变量可以根据需要设为 final,但是要注意 final 类中的所有成员方法都会被隐式地指定为 final 方法。
在使用 final 修饰类的时候,要注意谨慎选择,除非这个类真的在以后不会用来继承或者出于安全的考虑,尽量不要将类设计为final类。
修饰方法
被 final 修饰的方法不能被重写。
使用 final 方法的原因有两个。第一个原因是把方法锁定,以防任何继承类修改它的含义;第二个原因是效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升。在最近的 Java 版本中,不需要使用 final 方法进行这些优化了。
类的 private 方法会隐式地被指定为 final 方法。可以对 private 方法添加 final 关键字,但并不会增加额外的意义。
修饰变量
对于一个 final 变量,如果是基本数据类型的变量,则称为常量,其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。
上述代码中,常量 j 和 obj 的重新赋值都报错了,但并不影响 obj 指向的对象中 i 的赋值。
当 final 前加上 static 时,与单独使用 final 关键字有所不同,如下代码所示:
private final int j = 5;
private static final int VALUE_ONE = 10;
public static final int VALUE_TWO = 100;
复制代码
static final 要求变量名全为大写,并且用下划线隔开,这样定义的变量被称为编译期常量。
空白final
空白 final 指的是被声明为 final 但又未给定初始值的域,无论什么情况,编译器都确保空白 final 在使用前必须被初始化。比如下面这段代码:
public class FinalTest {
private int i;
private final int j;
public FinalTest(int i, int j) {
this.i = i;
this.j = j;
}
}
复制代码
必须在域的定义处或者每个构造器中用表达式对 final 进行赋值,这正是 final 域在使用前总是被初始化的原因所在。
匿名内部类与final
闭包
闭包其实是一个很通用的概念,闭包是词法作用域的体现。
目前流行的编程语言都支持函数作为一类对象,比如 JavaScript,Ruby,Python,C#,Scala,Java8.....,而这些语言里无一例外的都提供了闭包的特性,因为闭包可以大大的增强函数的处理能力,函数可以作为一类对象的这一优点才能更好的发挥出来。
那么什么是「闭包」呢?
直白点讲就是,一个持有外部环境变量的函数就是闭包。
理解闭包通常有着以下几个关键点:
- 函数
- 自由变量
- 环境
比如下面这个例子:
let a = 1
let b = function(){
console.log(a)
}
复制代码
在这个例子里「函数」b因为捕获了外部作用域(环境)中的变量a,因此形成了闭包。 而由于变量a并不属于函数b,所以在概念里被称之为「自由变量」。
我们再进一步看下面这个 Javascript 闭包的例子:
function Add(y) {
return function(x) {
return x + y
}
}
复制代码
对内部函数 function(x)来讲,y就是自由变量,而且 function(x)的返回值,依赖于这个外部自由变量y。而往上推一层,外围 Add(y)函数正好就是那个包含自由变量y的环境。而且 Javascript 的语法允许内部函数 function(x)访问外部函数 Add(y)的局部变量。满足这三个条件,所以这个时候,外部函数 Add(y)对内部函数 function(x)构成了闭包。
这样我们就能够:
var addFive = AddWith(5)
var seven = addFive(2) // 2+5=7
复制代码
类和对象
基于类的面向对象程序语言中有一种情况,就是方法中用的自由变量是来自其所在的类的实例的。像这样:
class Foo {
private int x;
int AddWith( int y ) { return x + y; }
}
复制代码
看上去x在函数 AddWith()的作用域外面,但是通过 Foo类实例化的过程,变量x和变量y之间已经绑定了,而且和函数 AddWith()也已经打包在一起。AddWith()函数其实是透过 this关键字来访问对象的成员字段的。
Java 中到处存在闭包,只是我们感觉不出来在使用闭包。至于为什么一般不把类称为闭包,没为什么,就是种习惯。
Java内部类
关于 Java 内部类,总结如下图所示:
而 Java 内部类其实就是一个典型的闭包结构。例子如下:
public class Outer {
private class Inner{
private y=8;
public int innerAdd(){
return x+y;
}
}
private int x=5;
}
复制代码
在上述代码中,变量x为自由变量,
内部类 Inner 通过包含一个指向外部类的引用,做到自由访问外部环境类 Outer 的所有字段,其中就包括变量 x,变相把环境中的自由变量封装到函数里,形成一个闭包。
匿名内部类
我们再来看看 Java 中比较特别的匿名内部类,之所以特殊,因为它不能显式地声明构造函数,另外只能创建匿名内部类的一个实例,创建的时候一定是在 new 的后面。使用匿名内部类还有个前提条件:必须继承一个父类或实现一个接口。
我们其实都见过匿名内部类,比较经典的就是线程的创建,如下代码所示:
public static void main(String[] args) {
Thread t = new Thread() {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.print(i + " ");
}
}
};
t.start();
}
复制代码
本文旨在讨论匿名内部类与 final 之间的联系,其他暂不提及。匿名内部类会有两个地方必须需要使用 final 修饰符:
- 在内部类的方法使用到方法中定义的局部变量,则该局部变量需要添加 final 修饰符。
- public AnnoInner getAnnoInner(){ final int y=100; return new AnnoInner(){ public int getNum(){return y;} }; } 复制代码
- 在内部类的方法形参使用到外部传过来的变量,则形参需要添加 final 修饰符,注意必须要使用该变量,才需要加上 final 修饰符。
- public AnnoInner getAnnoInner(final int x,final int y){ return new AnnoInner(){ public int add(){return x+y;} }; } public AnnoInner getAnnoInner(int x,int y){ return new AnnoInner(){ public int add(){return 5;} }; } 复制代码
但是 JDK 1.8 取消了对匿名内部类引用的局部变量 final 修饰的检查,具体情况将由 Java 编译器来处理。
下面这个例子中,getAnnoInner负责返回一个匿名内部类的引用。
public interface AnnoInner {
int add();
}
public class Outer {
private int num;
public AnnoInner getAnnoInner(int x) {
int y = 2;
return new AnnoInner() {
int z = 1;
@Override
public int add() {
//Variable 'y' is accessed from within inner class, needs to be final or effectively final
//y = 5;
return x + y + z;
}
};
}
}
复制代码
上述代码中,为什么变量 y不能被修改呢?并且提示该变量应该被 final 修饰。
我们来看一下 Outer 对应的 class 文件,内容如下:
public class Outer {
private int num;
public Outer() {
}
public AnnoInner getAnnoInner(final int var1) {
final byte var2 = 2;
return new AnnoInner() {
int z = 1;
public int add() {
return var1 + var2 + this.z;
}
};
}
}
复制代码
因为变量 x 在 add()方法中被使用了,所以 Java 编译器为 x 加上了 final 修饰;变量 y 不允许被修改,因为从内部类引用的本地变量必须是最终变量或实际上的最终变量,即被 final 修饰。
capture-by-value
除此之外,在编译时还生成了一个 Outer$1.class 文件,内容如下:
class Outer$1 implements AnnoInner {
int z;
Outer$1(Outer var1, int var2, int var3) {
this.this$0 = var1;
this.val$x = var2;
this.val$y = var3;
this.z = 1;
}
public int add() {
return this.val$x + this.val$y + this.z;
}
}
复制代码
将这两个 class 文件结合起来,可以发现 Java 编译器把外部环境方法的x和y局部变量,拷贝了一份到匿名内部类里,整理后代码如下所示:
public class Outer {
private int num;
public AnnoInner getAnnoInner(final int x) {
final int y = 2;
return new AnnoInner() {
int copyX = x; //编译器相当于拷贝了外部自由变量x的一个副本到匿名内部类里。
int copyY = y;
int z = 1;
@Override
public int add() {
return copyX + copyY + z;
}
};
}
}
复制代码
为什么会出现上述这种情形呢?这里引用 R大的描述:
Java 8语言上的lambda表达式只实现了capture-by-value,也就是说它捕获的局部变量都会拷贝一份到lambda表达式的实体里,然后在lambda表达式里要变也只能变自己的那份拷贝而无法影响外部原本的变量;但是Java语言的设计者又要挂牌坊不明说自己是capture-by-value,为了以后语言能进一步扩展成支持capture-by-reference留下后路,所以现在干脆不允许向捕获的变量赋值,而且可以捕获的也只有“效果上不可变”(effectively final)的参数/局部变量。
简单来说就是:**Java 编译器实现的只是 capture-by-value,并没有实现 capture-by-reference。**而只有后者才能保持匿名内部类和外部环境局部变量保持同步,前者无法保证内外同步,那就只能不许大家改外部的局部变量。
在 JMM 讲解一文中,我们有提到过 final 关键字可以保证可见性,即被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见 final 字段的值。
并未表明 final 可以保证有序性,接下来我们就来学习一下 final 在内存中的表现。
final域的内存语义
对于 final 域,编译器和处理器要遵守两个重排序规则。
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
关于 final 域的重排序,分为 final 域的写和读。
写final域的重排序
对应上文中的规则1,具体情形我们来看下述代码:
public class FinalExample {
int i;
final int j;
static FinalExample obj;
public FinalExample() {
i = 3;
j = 4; //步骤1
}
public static void write() {
obj = new FinalExample();//步骤2
}
public static void read() {
public static void read() {
if (obj != null) {
FinalExample finalExample = obj;//步骤3
int a = finalExample.i;//步骤4
int b = finalExample.j;//步骤5
}
}
}
}
复制代码
对应上述代码就是步骤1必须先于步骤2,Java 编译器不得重排序,具体实现分为两个方面:
- JMM 禁止编译器把 final 域的写重排序到构造函数之外;
- 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
在构造器可能把“this”的引用传递出去(this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见 final 字段的值。
「逸出」指的是对封装性的破坏。比如对一个对象的操作,通过将这个对象的 this 赋值给一个外部全局变量,使得这个全局变量可以绕过对象的封装接口直接访问对象中的成员,这就是逸出。
这里提一下 final 之前存在的“逸出”问题,如下案例所示:
// 以下代码来源于【参考1】
final int x;
// 错误的构造函数
public FinalFieldExample() {
x = 3;
y = 4;
// 此处就是讲this逸出,
global.obj = this;
}
复制代码
在上面的例子中,构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。
读final域的重排序
读 final 域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
还是以 FinalExample 文件为例,在 read()方法中,步骤3必须先于步骤5执行。假设A线程执行 write()方法,B线程执行 read()方法,在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被A线程初始化过了。
final域为引用类型
final 修饰的变量,要么一开始就初始化好,要么就是空白 final,在构造器中初始化。
文中关于 final 修饰的案例都是基于基本数据类型的,如果是引用类型呢?是否还能保证数据的可见性呢?这里就不由得想起了深入学习 volatile 关键字时最后关于数组被 volatile 修饰的情形,当时给的结论是:volatile 修饰对象和数组时,只是保证其引用地址的可见性。
我们来看看 final 关键字是怎么表现的呢?
public class FinalReferenceExample {
final int[] nums;
static FinalReferenceExample obj;
public FinalReferenceExample() {
nums = new int[2]; //1
nums[0] = 1; //2
}
public static void writeOne() { //线程A
obj = new FinalReferenceExample();//3
}
public static void writeTwo() {//线程B
obj.nums[0] = 3;
}
public static void read() {//线程C
if (obj != null) {
int a = obj.nums[0];
}
}
}
复制代码
当 final 域为引用类型时,规则1稍微做了点改动:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
在上述代码中,1是对 final 域的写入,2是对这个 final 域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。
那么读数据时的可见性会发生什么变化呢?按照规则2可知,JMM 可以确保读线程C至少能看到写线程A在构造函数中对 final 引用对象的成员域的写入,所以C至少能看到数组下标0的值为1。但 JMM 无法保证线程B对 final 引用对象的成员域的写入对线程C可见。
总结
关于 final 关键字的学习就到这里了,我们来进行一个总结。
1、最初的认识:在 Java 中,final 关键字可以用来修饰类、方法和变量(包括成员变量和局部变量)。
2、更进一步:从闭包开始带大家认识 Java 的匿名内部类,介绍 final 关键字在匿名内部类中使用。
3、深入底层:final 关键字为何可以保证 final 域的可见性。
另外 final 关键字在效率上的作用主要可以总结为以下三点:
- 缓存:final 配合 static 关键字提高了代码性能,JVM 和 Java 应用都会缓存 final变量。
- 同步:final 变量或对象是只读的,可以安全的在多线程环境下进行共享,而不需要额外的同步开销。
- 内联:使用 final 关键字,JVM会显式地主动对方法、变量及类进行内联优化。
参考文献
浅析Java中的final关键字
详解Java中的final关键字
java为什么匿名内部类的参数引用时final?
《Java并发编程的艺术》
来源:https://juejin.cn/post/7140781069909016612