探究final在java中的作用

炭燒生蠔發表於2019-05-22

final關鍵字的字面意思是最終的, 不可修改的. 這似乎是一個看見名字就大概能知道怎麼用的語法, 但你是否有深究過final在各個場景中的具體使用方法, 注意事項, 以及背後涉及的Java設計思想呢?

 

一. final修飾變數

1. 基礎: final修飾基本資料型別變數和引用資料型別變數.

  • 相信大家都具備基本的常識: 被final修飾的變數是不能夠被改變的. 但是這裡的"不能夠被改變"對於不同的資料型別是有不同的含義的.
  • 當final修飾的是一個基本資料型別資料時, 這個資料的值在初始化後將不能被改變; 當final修飾的是一個引用型別資料時, 也就是修飾一個物件時, 引用在初始化後將永遠指向一個記憶體地址, 不可修改. 但是該記憶體地址中儲存的物件資訊, 是可以進行修改的.
  • 上一段話可能比較抽象, 希望下面的圖能有助於你理解, 你會發現雖說有不同的含義, 但本質還是一樣的.
  • 首先是final修飾基本資料型別時的記憶體示意圖

在這裡插入圖片描述

  • 如上圖, 變數a在初始化後將永遠指向003這塊記憶體, 而這塊記憶體在初始化後將永遠儲存數值100.
  • 下面是final修飾引用資料型別的示意圖

在這裡插入圖片描述

  • 在上圖中, 變數p指向了0003這塊記憶體, 0003記憶體中儲存的是物件p的控制程式碼(存放物件p資料的記憶體地址), 這個控制程式碼值是不能被修改的, 也就是變數p永遠指向p物件. 但是p物件的資料是可以修改的.
// 程式碼示例
public static void main(String[] args) {
    final Person p = new Person(20, "炭燒生蠔");
    p.setAge(18);   //可以修改p物件的資料
    System.out.println(p.getAge()); //輸出18

    Person pp = new Person(30, "蠔生燒炭");
    p = pp; //這行程式碼會報錯, 不能通過編譯, 因為p經final修飾永遠指向上面定義的p物件, 不能指向pp物件. 
}
  • 不難看出final修飾變數的本質: final修飾的變數會指向一塊固定的記憶體, 這塊記憶體中的值不能改變.
  • 引用型別變數所指向的物件之所以可以修改, 是因為引用變數不是直接指向物件的資料, 而是指向物件的引用的. 所以被final修飾的引用型別變數將永遠指向一個固定的物件, 不能被修改; 物件的資料值可以被修改.

 

2. 進階: 被final修飾的常量在編譯階段會被放入常量池中

  • final是用於定義常量的, 定義常量的好處是: 不需要重複地建立相同的變數. 而常量池是Java的一項重要技術, 由final修飾的變數會在編譯階段放入到呼叫類的常量池中.
  • 請看下面這段演示程式碼. 這個示例是專門為了演示而設計的, 希望能方便大家理解這個知識點.
public static void main(String[] args) {
    int n1 = 2019;          //普通變數
    final int n2 = 2019;    //final修飾的變數

    String s = "20190522";  
    String s1 = n1 + "0522";    //拼接字串"20190512"
    String s2 = n2 + "0522";    

    System.out.println(s == s1);    //false
    System.out.println(s == s2);    //true
}

首先要介紹一點: 整數-127-128是預設載入到常量池裡的, 也就是說如果涉及到-127-128的整數操作, 預設在編譯期就能確定整數的值. 所以這裡我故意選用數字2019(大於128), 避免數字預設就存在常量池中.

  • 上面的程式碼運作過程是這樣的:
  • 首先根據final修飾的常量會在編譯期放到常量池的原則, n2會在編譯期間放到常量池中.
  • 然後s變數所對應的"20190522"字串會放入到字串常量池中, 並對外提供一個引用返回給s變數.
  • 這時候拼接字串s1, 由於n1對應的資料沒有放入常量池中, 所以s1暫時無法拼接, 需要等程式載入執行時才能確定s1對應的值.
  • 但在拼接s2的時候, 由於n2已經存在於常量池, 所以可以直接與"0522"拼接, 拼接出的結果是"20190522". 這時系統會檢視字串常量池, 發現已經存在字串20190522, 所以直接返回20190522的引用. 所以s2和s指向的是同一個引用, 這個引用指向的是字串常量池中的20190522.

 

  • 當程式執行時, n1變數才有具體的指向.
  • 當拼接s1的時候, 會建立一個新的String型別物件, 也就是說字串常量池中的20190522會對外提供一個新的引用.
  • 所以當s1與s用"=="判斷時, 由於對應的引用不同, 會返回false. 而s2和s指向同一個引用, 返回true.

總結: 這個例子想說明的是: 由於被final修飾的常量會在編譯期進入常量池, 如果有涉及到該常量的操作, 很有可能在編譯期就已經完成.

 

3. 探索: 為什麼區域性/匿名內部類在使用外部區域性變數時, 只能使用被final修飾的變數?

提示: 在JDK1.8以後, 通過內部類訪問外部區域性變數時, 無需顯式把外部區域性變數宣告為final. 不是說不需要宣告為final了, 而是這件事情在編譯期間系統幫我們做了. 但是我們還是有必要了解為什麼要用final修飾外部區域性變數.

public class Outter {
    public static void main(String[] args) {
        final int a = 10;
        new Thread(){
            @Override
            public void run() {
                System.out.println(a);
            }
        }.start();
    }
}
  • 在上面這段程式碼, 如果沒有給外部區域性變數a加上final關鍵字, 是無法通過編譯的. 可以試著想想: 當main方法已經執行完後, main方法的棧幀將會彈出, 如果此時Thread物件的生命週期還沒有結束, 還沒有執行列印語句的話, 將無法訪問到外部的a變數.
  • 那麼為什麼加上final關鍵字就能正常編譯呢? 我們通過檢視反編譯程式碼看看內部類是怎樣呼叫外部成員變數的.
  • 我們可以先通過javac編譯得到.class檔案(用IDE編譯也可以), 然後在命令列輸入javap -c .class檔案的絕對路徑, 就能檢視.class檔案的反編譯程式碼. 以上的Outter類經過編譯產生兩個.class檔案, 分別是Outter.class和Outter$1.class, 也就是說內部類會單獨編譯成一個.class檔案. 下面給出Outter$1.class的反編譯程式碼.
Compiled from "Outter.java"
final class forTest.Outter$1 extends java.lang.Thread {
  forTest.Outter$1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Thread."<init>":()V
       4: return

  public void run();
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: bipush        10
       5: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
       8: return
}
  • 定位到run()方法反編譯程式碼中的第3行:
  • 3: bipush 10
  • 我們看到a的值在內部類的run()方法執行過程中是以壓棧的形式儲存到本地變數表中的, 也就是說在內部類列印變數a的值時, 這個變數a不是外部的區域性變數a, 因為如果是外部區域性變數的話, 應該會使用load指令載入變數的值. 也就是說系統以拷貝的形式把外部區域性變數a複製了一個副本到內部類中, 內部類有一個變數指向外部變數a所指向的值.

 

  • 但研究到這裡好像和final的關係還不是很大, 不加final似乎也可以拷貝一份變數副本, 只不過不能在編譯期知道變數的值罷了. 這時該思考一個新問題了: 現在我們知道內部類的變數a和外部區域性變數a是兩個完全不同的變數, 那麼如果在執行run()方法的過程中, 內部類中修改了a變數所指向的值, 就會產生資料不一致問題.
  • 正因為我們的原意是內部類和外部類訪問的是同一個a變數, 所以當在內部類中使用外部區域性變數的時候應該用final修飾區域性變數, 這樣區域性變數a的值就永遠不會改變, 也避免了資料不一致問題的發生.

 

二. final修飾方法

  • 使用final修飾方法有兩個作用, 首要作用是鎖定方法, 不讓任何繼承類對其進行修改.
  • 另外一個作用是在編譯器對方法進行內聯, 提升效率. 但是現在已經很少這麼使用了, 近代的Java版本已經把這部分的優化處理得很好了. 但是為了滿足求知慾還是瞭解一下什麼是方法內斂.
  • 方法內斂: 當呼叫一個方法時, 系統需要進行儲存現場資訊, 建立棧幀, 恢復執行緒等操作, 這些操作都是相對比較耗時的. 如果使用final修飾一個了一個方法a, 在其他呼叫方法a的類進行編譯時, 方法a的程式碼會直接嵌入到呼叫a的程式碼塊中.
//原始碼
public static void test(){
    String s1 = "包夾方法a";
    a();
    String s2 = "包夾方法a";
}

public static final void a(){
    System.out.println("我是方法a中的程式碼");
    System.out.println("我是方法a中的程式碼");
}

//經過編譯後
public static void test(){
    String s1 = "包夾方法a";
    System.out.println("我是方法a中的程式碼");
    System.out.println("我是方法a中的程式碼");
    String s2 = "包夾方法a";
}
  • 在方法非常龐大的時候, 這樣的內嵌手段是幾乎看不到任何效能上的提升的, 在最近的Java版本中,不需要使用final方法進行這些優化了. --《Java程式設計思想》

 

三. final修飾類

  • 使用final修飾類的目的簡單明確: 表明這個類不能被繼承.
  • 當程式中有永遠不會被繼承的類時, 可以使用final關鍵字修飾
  • 被final修飾的類所有成員方法都將被隱式修飾為final方法.

 
 

  • 參考資料
  • https://www.cnblogs.com/ChenLLang/p/5316662.html
  • http://www.cnblogs.com/xrq730/p/4857820.html
  • https://gitbook.cn/books/5c6e1937c73f4717175f7477/index.html
  • http://www.cnblogs.com/xrq730/p/4844915.html
  • http://www.cnblogs.com/dolphin0520/p/3811445.html
  • https://www.cnblogs.com/dolphin0520/p/3736238.html

相關文章