天天看點

深入了解final關鍵字

作者:陳三歲98

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 修飾符。
  2. public AnnoInner getAnnoInner(){ final int y=100; return new AnnoInner(){ public int getNum(){return y;} }; } 複制代碼
  3. 在内部類的方法形參使用到外部傳過來的變量,則形參需要添加 final 修飾符,注意必須要使用該變量,才需要加上 final 修飾符。
  4. 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 域,編譯器和處理器要遵守兩個重排序規則。

  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并發程式設計的藝術》

來源:https://juejin.cn/post/7140781069909016612