• 深入理解final关键字


    inal 关键字在我们学习 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 关键字有所不同,如下代码所示:

    1. private final int j = 5;
    2. private static final int VALUE_ONE = 10;
    3. public static final int VALUE_TWO = 100;

    static final 要求变量名全为大写,并且用下划线隔开,这样定义的变量被称为编译期常量。

    空白final

    空白 final 指的是被声明为 final 但又未给定初始值的域,无论什么情况,编译器都确保空白 final 在使用前必须被初始化。比如下面这段代码:

    1. public class FinalTest {
    2. private int i;
    3. private final int j;
    4. public FinalTest(int i, int j) {
    5. this.i = i;
    6. this.j = j;
    7. }
    8. }

    必须在域的定义处或者每个构造器中用表达式对 final 进行赋值,这正是 final 域在使用前总是被初始化的原因所在。

    匿名内部类与final

    闭包

    闭包其实是一个很通用的概念,闭包是词法作用域的体现。

    目前流行的编程语言都支持函数作为一类对象,比如 JavaScript,Ruby,Python,C#,Scala,Java8.....,而这些语言里无一例外的都提供了闭包的特性,因为闭包可以大大的增强函数的处理能力,函数可以作为一类对象的这一优点才能更好的发挥出来。

    那么什么是「闭包」呢?

    直白点讲就是,一个持有外部环境变量的函数就是闭包

    理解闭包通常有着以下几个关键点:

    • 函数
    • 自由变量
    • 环境

    比如下面这个例子:

    1. let a = 1
    2. let b = function(){
    3. console.log(a)
    4. }

    在这个例子里「函数」b因为捕获了外部作用域(环境)中的变量a,因此形成了闭包。 而由于变量a并不属于函数b,所以在概念里被称之为「自由变量」。

    我们再进一步看下面这个 Javascript 闭包的例子:

    1. function Add(y) {
    2. return function(x) {
    3. return x + y
    4. }
    5. }

    对内部函数 function(x)来讲,y就是自由变量,而且 function(x)的返回值,依赖于这个外部自由变量y。而往上推一层,外围 Add(y)函数正好就是那个包含自由变量y的环境。而且 Javascript 的语法允许内部函数 function(x)访问外部函数 Add(y)的局部变量。满足这三个条件,所以这个时候,外部函数 Add(y)对内部函数 function(x)构成了闭包。

    这样我们就能够:

    1. var addFive = AddWith(5)
    2. var seven = addFive(2) // 2+5=7

    类和对象

    基于类的面向对象程序语言中有一种情况,就是方法中用的自由变量是来自其所在的类的实例的。像这样:

    1. class Foo {
    2. private int x;
    3. int AddWith( int y ) { return x + y; }
    4. }

    看上去x在函数 AddWith()的作用域外面,但是通过 Foo类实例化的过程,变量x和变量y之间已经绑定了,而且和函数 AddWith()也已经打包在一起。AddWith()函数其实是透过 this关键字来访问对象的成员字段的。

    Java 中到处存在闭包,只是我们感觉不出来在使用闭包。至于为什么一般不把类称为闭包,没为什么,就是种习惯。

    Java内部类

    关于 Java 内部类,总结如下图所示:

    而 Java 内部类其实就是一个典型的闭包结构。例子如下:

    1. public class Outer {
    2. private class Inner{
    3. private y=8;
    4. public int innerAdd(){
    5. return x+y;
    6. }
    7. }
    8. private int x=5;
    9. }

    在上述代码中,变量x为自由变量,

    内部类 Inner 通过包含一个指向外部类的引用,做到自由访问外部环境类 Outer 的所有字段,其中就包括变量 x,变相把环境中的自由变量封装到函数里,形成一个闭包。

    匿名内部类

    我们再来看看 Java 中比较特别的匿名内部类,之所以特殊,因为它不能显式地声明构造函数,另外只能创建匿名内部类的一个实例,创建的时候一定是在 new 的后面。使用匿名内部类还有个前提条件:必须继承一个父类或实现一个接口

    我们其实都见过匿名内部类,比较经典的就是线程的创建,如下代码所示:

    1. public static void main(String[] args) {
    2. Thread t = new Thread() {
    3. public void run() {
    4. for (int i = 1; i <= 5; i++) {
    5. System.out.print(i + " ");
    6. }
    7. }
    8. };
    9. t.start();
    10. }

    本文旨在讨论匿名内部类与 final 之间的联系,其他暂不提及。匿名内部类会有两个地方必须需要使用 final 修饰符:

    1.关于 final 域的重排序,分为 final 域的写和读。

    写final域的重排序

    对应上文中的规则1,具体情形我们来看下述代码:

    2.在内部类的方法使用到方法中定义的局部变量,则该局部变量需要添加 final 修饰符。

    1. public AnnoInner getAnnoInner(){
    2. final int y=100;
    3. return new AnnoInner(){
    4. public int getNum(){return y;}
    5. };
    6. }

    2.在内部类的方法形参使用到外部传过来的变量,则形参需要添加 final 修饰符,注意必须要使用该变量,才需要加上 final 修饰符。

    1. public AnnoInner getAnnoInner(final int x,final int y){
    2. return new AnnoInner(){
    3. public int add(){return x+y;}
    4. };
    5. }
    6. public AnnoInner getAnnoInner(int x,int y){
    7. return new AnnoInner(){
    8. public int add(){return 5;}
    9. };
    10. }

    但是 JDK 1.8 取消了对匿名内部类引用的局部变量 final 修饰的检查,具体情况将由 Java 编译器来处理。

    下面这个例子中,getAnnoInner负责返回一个匿名内部类的引用。

    1. public interface AnnoInner {
    2. int add();
    3. }
    4. public class Outer {
    5. private int num;
    6. public AnnoInner getAnnoInner(int x) {
    7. int y = 2;
    8. return new AnnoInner() {
    9. int z = 1;
    10. @Override
    11. public int add() {
    12. //Variable 'y' is accessed from within inner class, needs to be final or effectively final
    13. //y = 5;
    14. return x + y + z;
    15. }
    16. };
    17. }
    18. }

    上述代码中,为什么变量 y不能被修改呢?并且提示该变量应该被 final 修饰。

    我们来看一下 Outer 对应的 class 文件,内容如下:

    1. public class Outer {
    2. private int num;
    3. public Outer() {
    4. }
    5. public AnnoInner getAnnoInner(final int var1) {
    6. final byte var2 = 2;
    7. return new AnnoInner() {
    8. int z = 1;
    9. public int add() {
    10. return var1 + var2 + this.z;
    11. }
    12. };
    13. }
    14. }

    因为变量 x 在 add()方法中被使用了,所以 Java 编译器为 x 加上了 final 修饰;变量 y 不允许被修改,因为从内部类引用的本地变量必须是最终变量或实际上的最终变量,即被 final 修饰。

    capture-by-value

    除此之外,在编译时还生成了一个 Outer$1.class 文件,内容如下:

    1. class Outer$1 implements AnnoInner {
    2. int z;
    3. Outer$1(Outer var1, int var2, int var3) {
    4. this.this$0 = var1;
    5. this.val$x = var2;
    6. this.val$y = var3;
    7. this.z = 1;
    8. }
    9. public int add() {
    10. return this.val$x + this.val$y + this.z;
    11. }
    12. }

    将这两个 class 文件结合起来,可以发现 Java 编译器把外部环境方法的x和y局部变量,拷贝了一份到匿名内部类里,整理后代码如下所示:

    1. public class Outer {
    2. private int num;
    3. public AnnoInner getAnnoInner(final int x) {
    4. final int y = 2;
    5. return new AnnoInner() {
    6. int copyX = x; //编译器相当于拷贝了外部自由变量x的一个副本到匿名内部类里。
    7. int copyY = y;
    8. int z = 1;
    9. @Override
    10. public int add() {
    11. return copyX + copyY + z;
    12. }
    13. };
    14. }
    15. }

    为什么会出现上述这种情形呢?这里引用 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 域,编译器和处理器要遵守两个重排序规则。

    1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
    2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

    关于 final 域的重排序,分为 final 域的写和读。

    写final域的重排序

    对应上文中的规则1,具体情形我们来看下述代码:

    1. public class FinalExample {
    2. int i;
    3. final int j;
    4. static FinalExample obj;
    5. public FinalExample() {
    6. i = 3;
    7. j = 4; //步骤1
    8. }
    9. public static void write() {
    10. obj = new FinalExample();//步骤2
    11. }
    12. public static void read() {
    13. public static void read() {
    14. if (obj != null) {
    15. FinalExample finalExample = obj;//步骤3
    16. int a = finalExample.i;//步骤4
    17. int b = finalExample.j;//步骤5
    18. }
    19. }
    20. }
    21. }

     

    对应上述代码就是步骤1必须先于步骤2,Java 编译器不得重排序,具体实现分为两个方面:

    1. JMM 禁止编译器把 final 域的写重排序到构造函数之外;
    2. 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

    在构造器可能把“this”的引用传递出去(this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见 final 字段的值。

    「逸出」指的是对封装性的破坏。比如对一个对象的操作,通过将这个对象的 this 赋值给一个外部全局变量,使得这个全局变量可以绕过对象的封装接口直接访问对象中的成员,这就是逸出

    这里提一下 final 之前存在的“逸出”问题,如下案例所示:

    1. // 以下代码来源于【参考1
    2. final int x;
    3. // 错误的构造函数
    4. public FinalFieldExample() {
    5. x = 3;
    6. y = 4;
    7. // 此处就是讲this逸出,
    8. global.obj = this;
    9. }

    在上面的例子中,构造函数里面将 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 关键字是怎么表现的呢?

    1. public class FinalReferenceExample {
    2. final int[] nums;
    3. static FinalReferenceExample obj;
    4. public FinalReferenceExample() {
    5. nums = new int[2]; //1
    6. nums[0] = 1; //2
    7. }
    8. public static void writeOne() { //线程A
    9. obj = new FinalReferenceExample();//3
    10. }
    11. public static void writeTwo() {//线程B
    12. obj.nums[0] = 3;
    13. }
    14. public static void read() {//线程C
    15. if (obj != null) {
    16. int a = obj.nums[0];
    17. }
    18. }
    19. }

    当 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会显式地主动对方法、变量及类进行内联优化。

  • 相关阅读:
    前端小案例-图片存放在远端服务器
    编译报错 internal compiler error: Segmentation fault 解决方法
    【2023秋招笔经】20220813 16点美团笔试
    UIC(组态UI工程)公版文件库新增7款行业素材
    MQTT(EMQX) - SpringBoot 整合MQTT 连接池 Demo - 附源代码 + 在线客服聊天架构图
    contentDocument contentWindow,canvas 、svg,iframe
    Spring 从入门到精通 (二十) 持久层框架 MyBatis
    ivew button 使用自定义图标
    Spring Boot(Vue3+ElementPlus+Axios+MyBatisPlus+Spring Boot 前后端分离)【七】
    orb-slam2 从单目开始的简单学习(7):Optimizer
  • 原文地址:https://blog.csdn.net/java_lujj/article/details/126757833