深入理解final关键字

hresh 485 0

深入理解final关键字

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;

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 内部类,总结如下图所示:

深入理解final关键字

而 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 修饰符:

  1. 在内部类的方法使用到方法中定义的局部变量,则该局部变量需要添加 final 修饰符。
    public AnnoInner getAnnoInner(){
     final int y=100;
     return new AnnoInner(){
       public int getNum(){return y;}
     };
    }
    
  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;}
     };
    }
    

但是 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 Outer1 implements AnnoInner {
  int z;

  Outer1(Outer var1, int var2, int var3) {
    this.this0 = var1;
    this.valx = var2;
    this.valy = var3;
    this.z = 1;
  }

  public int add() {
    return this.valx + 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 域,编译器和处理器要遵守两个重排序规则。

  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,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;
}

在上面的例子中,构造函数里面将 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并发编程的艺术》

发表评论 取消回复
表情 图片 链接 代码

分享