面試題系列:工作5年,第一次這麼清醒的理解final關鍵字?

跟著Mic學架構發表於2021-11-02

圖怪獸_4d9e52c6f1c95d5bdb266b4ae0504363_61669

面試題:用過final關鍵字嗎?它有什麼作用

面試考察點

考察目的: 瞭解面試者對Java基礎知識的理解

考察人群: 工作1-5年,工作年限越高,對於基礎知識理解的深度就越高。

背景知識

final關鍵字大家都不陌生,但是要達到深度理解,還是欠缺了一些。我們從三個方面去理解final關鍵字。

  1. final關鍵字的基本用法
  2. 深度理解final關鍵字
  3. final關鍵字的記憶體屏障語義

final的基本用法

final關鍵字,在Java中可以修飾類、方法、變數。

  1. 被final修飾的類,表示這個類不可被繼承,final類中的成員變數可以根據需要設為final,並且final修飾的類中的所有成員方法都被隱式指定為final方法.

    在使用final修飾類的時候,要注意謹慎選擇,除非這個類真的在以後不會用來繼承或者出於安全的考慮,儘量不要將類設計為final類。

    public final class TClass {
    
        public final String test(){
            return "true";
        }
    }
    public class TCCClass extends TClass{
    
    
        public static void main(String[] args) {
    
        }
    }
    

    上述程式執行得到如下錯誤:

    java: 無法從最終org.example.cl03.TClass進行繼承
    
  2. 被final修飾的方法,表示該方法無法被重寫.其中private方法會被隱式的指定為final方法。

    class SuperClass{
       protected final String getName() {
           return “supper class”;
       }
    
       @Override
        public String toString() {
            return getName();
        }
    }
    
    classSubClass extends SuperClass{
      protected String getName() {
          return “sub class”;
      }
    } 
    

    上述程式碼執行會得到如下錯誤:

    java: org.example.cl03.TCCClass中的test()無法覆蓋org.example.cl03.TClass中的test()
      被覆蓋的方法為final
    
  3. 被final修飾的成員變數是用得最多的地方。

    1. 對於一個final變數,如果是基本資料型別的變數,則其數值一旦在初始化之後便不能更改;final修飾的變數能間接實現常量的功能,而常量是全域性的、不可變的,因此我們同時使用static和final來修飾變數,就能達到定義常量的效果。
    2. 如果是引用型別的變數,則在對其初始化之後便不能再讓其指向另一個物件。

被final修飾的變數的初始化

  1. 在定義時初始化屬性的值

    public class TCCClass {
        private final String name;
        public static void main(String[] args) {
    
        }
    }
    

    上述程式碼在執行時會提示如下錯誤

    java: 變數 name 未在預設構造器中初始化
    

    修改成下面的方式即可。

    public class TCCClass {
        private final String name="name";
    }
    
  2. 在構造方法中賦值

    public class TCCClass {
        private final String name;
        public TCCClass(String name){
            this.name=name;
        }
    }
    

能夠在構造方法中賦值的原因是:對於一個普通成員屬性賦值時,必須要先通過構造方法例項化該物件。因此作為該屬性唯一的訪問入口,JVM允許在構造方法中給final修飾的屬性賦值。這個過程並沒有違反final的原則。當然如果被修飾final關鍵字的屬性已經初始化了值,是無法再使用構造方法重新賦值的。

反射破壞final規則

基於上述final關鍵字的基本使用描述,可以知道final修飾的屬性是不可變的。

但是,通過反射機制,可以破壞final的規則,程式碼如下

public class TCCClass {
    private final String name="name";

    public static void main(String[] args) throws Exception {
        TCCClass tcc=new TCCClass();
        System.out.println(tcc.name);
        Field name=tcc.getClass().getDeclaredField("name");
        name.setAccessible(true);
        name.set(tcc,"mic");
        System.out.println(name.get(tcc));
    }
}

列印結果如下:

name
mic

知識點擴充套件

上述程式碼理論上來說應該是下面這種寫法,因為通過反射修改tcc例項物件中的name屬性後,應該通過例項物件直接列印出name的結果。

public static void main(String[] args) throws Exception {
  TCCClass tcc=new TCCClass();
  System.out.println(tcc.name);
  Field name=tcc.getClass().getDeclaredField("name");
  name.setAccessible(true);
  name.set(tcc,"mic");
  System.out.println(tcc.name); //here
}

但是實際輸出結果後,發現tcc.name列印的結果沒有變化?

原因是:JVM在編譯時期做的深度優化機制, 就把final型別的String進行了優化, 在編譯時期就會把String處理成常量,導致列印結果不會發生變化。

為了避免這種深度優化帶來的影響,我們還可以把上述程式碼修改成下面這種形式

public class TCCClass {
    private final String name=(null == null ? "name" : "");

    public static void main(String[] args) throws Exception {
        TCCClass tcc=new TCCClass();
        System.out.println(tcc.name);
        Field name=tcc.getClass().getDeclaredField("name");
        name.setAccessible(true);
        name.set(tcc,"mic");
        System.out.println(tcc.name);
    }
}

列印結果如下:

name
mic

反射無法修改被final和static同時修飾的變數

把上面的程式碼修改如下。

public class TCCClass {
    private static final String name=(null == null ? "name" : "");

    public static void main(String[] args) throws Exception {
        TCCClass tcc=new TCCClass();
        System.out.println(tcc.name);
        Field name=tcc.getClass().getDeclaredField("name");
        name.setAccessible(true);
        name.set(tcc,"mic");
        System.out.println(tcc.name);
    }
}

執行結果,執行之後會報出如下異常, 因為反射無法修改同時被static final修飾的變數:

Exception in thread "main" java.lang.IllegalAccessException: Can not set static final java.lang.String field org.example.cl03.TCCClass.name to java.lang.String
	at sun.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76)
	at sun.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80)
	at sun.reflect.UnsafeQualifiedStaticObjectFieldAccessorImpl.set(UnsafeQualifiedStaticObjectFieldAccessorImpl.java:77)
	at java.lang.reflect.Field.set(Field.java:764)
	at org.example.cl03.TCCClass.main(TCCClass.java:13)

那麼被final和static同時修飾的屬性,能否被修改呢?答案是可以的!

修改程式碼如下:

public class TCCClass {
    private static final String name=(null == null ? "name" : "");

    public static void main(String[] args) throws Exception {
        TCCClass tcc=new TCCClass();
        System.out.println(tcc.name);
        Field name=tcc.getClass().getDeclaredField("name");
        name.setAccessible(true);

        Field modifiers = name.getClass().getDeclaredField("modifiers");
        modifiers.setAccessible(true);
        modifiers.setInt(name, name.getModifiers() & ~Modifier.FINAL);

        name.set(tcc,"mic");

        modifiers.setInt(name, name.getModifiers() & ~Modifier.FINAL);

        System.out.println(tcc.name);
    }
}

具體思路是,把被修飾了final關鍵字的name屬性,通過反射的方式去掉final關鍵字,程式碼實現

Field modifiers = name.getClass().getDeclaredField("modifiers");
modifiers.setAccessible(true);
modifiers.setInt(name, name.getModifiers() & ~Modifier.FINAL);

接著通過反射修改name屬性,修改成功後,再使用下面程式碼把final關鍵字加回來

modifiers.setInt(name, name.getModifiers() & ~Modifier.FINAL);

為什麼區域性內部類和匿名內部類只能訪問final變數

在瞭解這個問題之前,我們先來看下面這段程式碼

    public static void main(String[] args)  {

    }

    public void test(final int b) {
        final int a = 10;
        new Thread(){
            public void run() {
                System.out.println(a);
                System.out.println(b);
            };
        }.start();
    }
}

這段程式碼被編譯後,會生成兩個檔案: FinalExample.class和FinalExample$1.class(匿名內部類)

image-20211102124604099

通過反編譯來看一下FinalExample$1.class這個類

class FinalExample$1 extends Thread {
    FinalExample$1(FinalExample this$0, int var2, int var3) {
        this.this$0 = this$0;
        this.val$a = var2;
        this.val$b = var3;
    }

    public void run() {
        System.out.println(this.val$a);
        System.out.println(this.val$b);
    }
}

我們看到匿名內部類FinalExample$1的構造器含有三個引數,一個是指向外部類物件的引用,另外兩個是int型變數,很顯然,這裡是將變數test方法中的形參b,以及常量a以引數的形式傳進來,對匿名內部類中的拷貝(變數ab的拷貝)進行賦值初始化。

也就是說,在run方法中訪問的變數ab,是區域性變數ab的一個副本,為什麼這麼設計?

test方法中,有可能test方法執行結束且ab的宣告週期也結束了,但是Thread這個匿名內部類可能還未執行完,那麼在Thread中的run方法中繼續使用區域性變數ab就會有問題。但是又要實現這樣的效果,怎麼辦呢?所以Java採用了複製的手段來解決這個問題。

但是這樣一來,還是存在一個問題,就是test方法中的成員變數與匿名內部類Thread中的成員變數的副本出現資料不一致怎麼辦?

這樣就達不到原本的意圖和要求。為了解決這個問題,java編譯器就限定必須將變數ab限制為final變數,不允許對變數ab進行更改(對於引用型別的變數,是不允許指向新的物件),這樣資料不一致性的問題就得以解決了。

另外,如果我們這麼寫也是允許的,jvm會隱式給ab增加final關鍵字。

public void test(int b) {
  int a = 10;
  new Thread(){
    public void run() {
      System.out.println(a);
      System.out.println(b);
    };
  }.start();
}

final防止指令重排

final關鍵字,還能防止指令重排序帶來的可見性問題;

對於final變數,編譯器和處理器都要遵守兩個重排序規則:

  • 建構函式內,對一個 final 變數的寫入,與隨後把這個被構造物件的引用賦值給一個變數,這兩個操作之間不可重排序。
  • 首次讀一個包含 final 變數的物件,與隨後首次讀這個 final 變數,這兩個操作之間不可以重排序。

實際上這兩個規則也正是針對 final 變數的寫與讀。

  1. 寫的重排序規則可以保證,在物件引用對任意執行緒可見之前,物件的 final 變數已經正確初始化了,而普通變數則不具有這個保障;
  2. 讀的重排序規則可以保證,在讀一個物件的 final 變數之前,一定會先讀這個物件的引用。如果讀取到的引用不為空,根據上面的寫規則,說明物件的 final 變數一定以及初始化完畢,從而可以讀到正確的變數值。

如果 final 變數的型別是引用型,那麼建構函式內,對一個 final 引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。實際上這也是為了保證 final 變數在對其他執行緒可見之前,能夠正確的初始化完成。

關於指令重排序相關的內容,就不在本篇文章中做展開,在後續的面試題中,會做詳細的分析。

final 關鍵字的好處

下面為使用 final 關鍵字的一些好處:

  • final關鍵字提高了效能,JVM和Java應用都會快取final變數(實際就是常量池)
  • final變數可以安全的在多執行緒環境下進行共享,而不需要額外的同步開銷

問題解答

面試題:用過final關鍵字嗎?它有什麼作用

回答: final關鍵字表示不可變,它可以修飾在類、方法、成員變數中。

  1. 如果修飾在類上,則表示該類不允許被繼承
  2. 修飾在方法上,表示該方法無法被重寫
  3. 修飾在變數上,表示該變數無法被修改,而且JVM會隱性定義為一個常量。

另外,final修飾的關鍵字,還可以避免因為指令重排序帶來的可見性問題,原因是,final遵循兩個重排序規則

  1. 建構函式內,對一個 final 變數的寫入,與隨後把這個被構造物件的引用賦值給一個變數,這兩個操作之間不可重排序。
  2. 首次讀一個包含 final 變數的物件,與隨後首次讀這個 final 變數,這兩個操作之間不可以重排序。

問題總結

恰恰是平時經常使用的一些工具或者技術,所涉及到的知識點越多。

就這個問題來說,在面試時的考察點太多了,比如:

  1. 如何破壞final規則
  2. 帶static和final修飾的屬性,可以被修改嗎?
  3. final是否可以解決可見性問題,以及它是如何解決的?

因此,要想在面試時從容應對,一定要具備體系化的技術理解,避免面試時各種”不清楚“、”不瞭解“之類的尷尬!

關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章