• 深入理解final关键字


    本文为《Java高并发》第七篇文章,首发于个人网站

    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 变量,如果是基本数据类型的变量,则称为常量,其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。

    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;
    
    • 1
    • 2
    • 3

    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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

    匿名内部类与final

    闭包

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

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

    那么什么是「闭包」呢?

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

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

    • 函数
    • 自由变量
    • 环境

    比如下面这个例子:

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

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

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

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

    对内部函数 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  
    
    • 1
    • 2

    类和对象

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

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

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

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

    Java内部类

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

    Java内部类

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

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

    在上述代码中,变量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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

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

      public AnnoInner getAnnoInner(){
        final int y=100;
        return new AnnoInner(){
          public int getNum(){return y;}
        };
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
    2. 在内部类的方法形参使用到外部传过来的变量,则形参需要添加 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;}
        };
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11

    但是 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;
          }
        };
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    上述代码中,为什么变量 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;
          }
        };
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    因为变量 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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    将这两个 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;
          }
        };
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    为什么会出现上述这种情形呢?这里引用 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,具体情形我们来看下述代码:

    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
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

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

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

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

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

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

    // 以下代码来源于【参考1】
    final int x;
    // 错误的构造函数
    public FinalFieldExample() { 
      x = 3;
      y = 4;
      // 此处就是讲this逸出,
      global.obj = this;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 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 关键字是怎么表现的呢?

    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];
        }
      }
    }
    
    • 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

    当 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并发编程的艺术》

  • 相关阅读:
    最佳策略app平台传出的绝密理财法,这是给散户们的好机会
    ELK日志框架图总结
    被忽视的数据中心非业务网络规划
    DGUS新升级:全面支持数字视频播放功能
    Solana NFT开发指南
    【JavaSE】继承
    vite + electron引入itk报错
    Docker快速入门
    为什么学完了 C#觉得自己什么都干不了?
    软考高级之系统架构设计师系列【2】考试说明及考点分析
  • 原文地址:https://blog.csdn.net/Herishwater/article/details/126757122