面試題:用過final關鍵字嗎?它有什麼作用
面試考察點
考察目的: 瞭解面試者對Java基礎知識的理解
考察人群: 工作1-5年,工作年限越高,對於基礎知識理解的深度就越高。
背景知識
final
關鍵字大家都不陌生,但是要達到深度理解,還是欠缺了一些。我們從三個方面去理解final
關鍵字。
final
關鍵字的基本用法- 深度理解
final
關鍵字 final
關鍵字的記憶體屏障語義
final的基本用法
final
關鍵字,在Java中可以修飾類、方法、變數。
-
被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進行繼承
-
被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
-
被final修飾的成員變數是用得最多的地方。
- 對於一個final變數,如果是基本資料型別的變數,則其數值一旦在初始化之後便不能更改;final修飾的變數能間接實現常量的功能,而常量是全域性的、不可變的,因此我們同時使用static和final來修飾變數,就能達到定義常量的效果。
- 如果是引用型別的變數,則在對其初始化之後便不能再讓其指向另一個物件。
被final修飾的變數的初始化
-
在定義時初始化屬性的值
public class TCCClass { private final String name; public static void main(String[] args) { } }
上述程式碼在執行時會提示如下錯誤
java: 變數 name 未在預設構造器中初始化
修改成下面的方式即可。
public class TCCClass { private final String name="name"; }
-
在構造方法中賦值
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(匿名內部類)
通過反編譯來看一下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
以引數的形式傳進來,對匿名內部類中的拷貝(變數a
和b
的拷貝)進行賦值初始化。
也就是說,在run
方法中訪問的變數a
和b
,是區域性變數a
和b
的一個副本,為什麼這麼設計?
在
test
方法中,有可能test
方法執行結束且a
和b
的宣告週期也結束了,但是Thread這個匿名內部類可能還未執行完,那麼在Thread中的run
方法中繼續使用區域性變數a
和b
就會有問題。但是又要實現這樣的效果,怎麼辦呢?所以Java採用了複製的手段來解決這個問題。
但是這樣一來,還是存在一個問題,就是test
方法中的成員變數與匿名內部類Thread中的成員變數的副本出現資料不一致怎麼辦?
這樣就達不到原本的意圖和要求。為了解決這個問題,java編譯器就限定必須將變數a
和b
限制為final變數,不允許對變數a
和b
進行更改(對於引用型別的變數,是不允許指向新的物件),這樣資料不一致性的問題就得以解決了。
另外,如果我們這麼寫也是允許的,jvm會隱式給a
和b
增加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 變數的寫與讀。
- 寫的重排序規則可以保證,在物件引用對任意執行緒可見之前,物件的 final 變數已經正確初始化了,而普通變數則不具有這個保障;
- 讀的重排序規則可以保證,在讀一個物件的 final 變數之前,一定會先讀這個物件的引用。如果讀取到的引用不為空,根據上面的寫規則,說明物件的 final 變數一定以及初始化完畢,從而可以讀到正確的變數值。
如果 final 變數的型別是引用型,那麼建構函式內,對一個 final 引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。實際上這也是為了保證 final 變數在對其他執行緒可見之前,能夠正確的初始化完成。
關於指令重排序相關的內容,就不在本篇文章中做展開,在後續的面試題中,會做詳細的分析。
final 關鍵字的好處
下面為使用 final 關鍵字的一些好處:
- final關鍵字提高了效能,JVM和Java應用都會快取final變數(實際就是常量池)
- final變數可以安全的在多執行緒環境下進行共享,而不需要額外的同步開銷
問題解答
面試題:用過final關鍵字嗎?它有什麼作用
回答: final關鍵字表示不可變,它可以修飾在類、方法、成員變數中。
- 如果修飾在類上,則表示該類不允許被繼承
- 修飾在方法上,表示該方法無法被重寫
- 修飾在變數上,表示該變數無法被修改,而且JVM會隱性定義為一個常量。
另外,final
修飾的關鍵字,還可以避免因為指令重排序帶來的可見性問題,原因是,final遵循兩個重排序規則
- 建構函式內,對一個 final 變數的寫入,與隨後把這個被構造物件的引用賦值給一個變數,這兩個操作之間不可重排序。
- 首次讀一個包含 final 變數的物件,與隨後首次讀這個 final 變數,這兩個操作之間不可以重排序。
問題總結
恰恰是平時經常使用的一些工具或者技術,所涉及到的知識點越多。
就這個問題來說,在面試時的考察點太多了,比如:
- 如何破壞final規則
- 帶static和final修飾的屬性,可以被修改嗎?
- final是否可以解決可見性問題,以及它是如何解決的?
因此,要想在面試時從容應對,一定要具備體系化的技術理解,避免面試時各種”不清楚“、”不瞭解“之類的尷尬!
關注[跟著Mic學架構]公眾號,獲取更多精品原創