java 記憶體模型-08-final
基礎知識
基本用法
- 修飾類
當用final修飾一個類時,表明這個類不能被繼承。
也就是說,如果一個類你永遠不會讓他被繼承,就可以用final進行修飾。
final類中的成員變數可以根據需要設為final,但是要注意final類中的所有成員方法都會被隱式地指定為final方法。
- 修飾方法
使用final方法的原因有兩個。
第一個原因是把方法鎖定,以防任何繼承類修改它的含義;
第二個原因是效率。
在早期的Java實現版本中,會將final方法轉為內嵌呼叫。但是如果方法過於龐大,可能看不到內嵌呼叫帶來的任何效能提升。
在最近的Java版本中,不需要使用final方法進行這些優化了。
- 修飾變數
對於一個final變數,如果是基本資料型別的變數,則其數值一旦在初始化之後便不能更改;
如果是引用型別的變數,則在對其初始化之後便不能再讓其指向另一個物件。
匿名內部類
匿名內部類使用外部變數時為何要強制使用 final 修飾
private void initViews() { final int a = 3; // Compilation error if remove final btn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (a > 1) { // volala } } } }
那麼,有沒有想過為什麼?
而像其他支援完整閉包的語言如JavaScript,Python等,是沒有這等限制的。
究其原因,是Java對閉包支援不夠完整,或者說它並不是像動態語言那樣的完整閉包。
對於匿名內部類來說,編譯器會建立一個命名類(OutClass$1之類的),然後把匿名類所在的[能捕獲的變數](ofollow,noindex">https://en.wikipedia.org/wiki/Closure_(computer_programming) ,以構造引數的形式傳遞給內部類使用,這樣一樣,外部的變數與內部類看到的變數是不同的,雖然它們的值是相同的,因此,如果再允許外部修改這些變數,或者內部類裡面修改這些變數,都會造成資料的不一致性(因為它們是不同的變數) ,所以Java強制要求匿名內部類訪問的外部變數要加上final來修飾。
對於其他語言,匿名內部類,持有的是外部變數的一個包裝的引用(wrapper reference),這可以能看不懂,但是理解起來就是內部類能直接訪問外部變數,外部與閉包內部訪問的是同一個變數,因此外部修改了,內部能看到變化,內部修改了,外部也能看到變化。
一句話總結就是,Java 內部類與外部持有的是值相同的不同的變數;其他支援閉包的語言則持有的是相同的變數。
ps: Jdk 1.8+ 就沒有這種限制了。
JLS 規範
各種部落格內容大都人云亦云,此處從官方文件進行再次學習。
final class
如果未宣告 final 的類被更改為要宣告 final,那麼如果載入了該類已有子類的二進位制檔案,則丟擲 VerifyError,
因為 final 類不能有子類;對於廣泛分佈的類,不推薦這樣的更改。
更改已宣告為 final 的類,不再被宣告為 final,不會破壞與已有二進位制檔案的相容性。
final methods
將已宣告為 final 的方法更改為不再宣告為final不會破壞與已有二進位制檔案的相容性。
更改未宣告為 final 的例項方法可能會破壞與依賴於重寫方法能力的現有二進位制檔案的相容性。
- 普通版本
class Super { void out() { System.out.println("out"); } } class Test extends Super { public static void main(String[] args) { Test t = new Test(); t.out(); } void out() { super.out(); } }
- final 版本
class Super { final void out() { System.out.println("!"); } }
如果Super被重新編譯,但沒有進行測試,那麼使用已有的測試二進位制檔案執行新的二進位制檔案將導致VerifyError,因為類測試不正確地嘗試覆蓋例項方法。
更改未宣告為final的類(static)方法不會破壞與現有二進位制檔案的相容性,因為該方法不可能被重寫。
final & static
final fields
...
JMM final
與前面介紹的鎖和 volatil e相比較,對 final 域的讀和寫更像是普通的變數訪問。
對於 final 域,編譯器和處理器要遵守兩個重排序規則:
-
在建構函式內對一個 final 域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。
-
初次讀一個包含 final 域的物件的引用,與隨後初次讀這個 final 域,這兩個操作之間不能重排序。
例項
- FinalExample.java
public class FinalExample { int i;//普通變數 final int j;//final變數 static FinalExample obj; public void FinalExample () {//建構函式 i = 1;//寫普通域 j = 2;//寫final域 } public static void writer() {//寫執行緒A執行 obj = new FinalExample (); } public static void reader() {//讀執行緒B執行 FinalExample object = obj;//讀物件引用 int a = object.i;//讀普通域 int b = object.j;//讀final域 } }
這裡假設一個執行緒 A 執行writer()
方法,隨後另一個執行緒 B 執行reader()
方法。
下面我們通過這兩個執行緒的互動來說明這兩個規則。
寫 final 域的重排序規則
寫 final 域的重排序規則禁止把 final 域的寫重排序到建構函式之外。這個規則的實現包含下面 2 個方面:
-
JMM 禁止編譯器把 final 域 的寫重排序到建構函式之外。
-
編譯器會在 final 域的寫之後,建構函式 return 之前,插入一個 StoreStore 屏障。這個屏障禁止處理器把 final 域的寫重排序到建構函式之外。
現在讓我們分析 writer() 方法。
writer() 方法只包含一行程式碼:finalExample = new FinalExample()
。
這行程式碼包含兩個步驟:
-
構造一個 FinalExample 型別的物件;
-
把這個物件的引用賦值給引用變數 obj。
假設執行緒 B 讀物件引用與讀物件的成員域之間沒有重排序(馬上會說明為什麼需要這個假設),下圖是一種可能的執行時序:
時間線:-----------------------------------------------------------------> 執行緒A:執行建構函式 寫j=2 StoreStore屏障 建構函式結束 建構函式的引用賦值給引用變數obj (...執行緒B...) 寫i=1 執行緒B:讀物件引用obj 讀物件普通域i(×) 讀物件final域j(√)
在以上流程中,寫普通域的操作被編譯器重排序到了建構函式之外,讀執行緒 B 錯誤的讀取了普通變數i初始化之前的值。
而寫 final 域的操作,被寫 final 域的重排序規則“限定”在了建構函式之內,讀執行緒 B 正確的讀取了 final 變數初始化之後的值。
寫 final 域的重排序規則可以確保:
在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化過了,而普通域不具有這個保障。
以上圖為例,在讀執行緒 B “看到”物件引用 obj 時,很可能 obj 物件還沒有構造完成(對普通域i的寫操作被重排序到建構函式外,此時初始值 2 還沒有寫入普通域 i)。
讀 final 域的重排序規則
讀 final 域的重排序規則如下:
-
在一個執行緒中,初次讀物件引用與初次讀該物件包含的 final 域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。
編譯器會在讀 final 域操作的前面插入一個 LoadLoad 屏障。
初次讀物件引用與初次讀該物件包含的 final 域,這兩個操作之間存在間接依賴關係。
由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。
大多數處理器也會遵守間接依賴,大多數處理器也不會重排序這兩個操作。
但有少數處理器允許對存在間接依賴關係的操作做重排序(比如alpha處理器),這個規則就是專門用來針對這種處理器。
reader()
方法包含三個操作:
-
初次讀引用變數 obj;
-
初次讀引用變數 obj 指向物件的普通域 j。
-
初次讀引用變數 obj 指向物件的 final 域 i。
現在我們假設寫執行緒 A 沒有發生任何重排序,同時程式在不遵守間接依賴的處理器上執行,下面是一種可能的執行時序:
時間線:-------------------------------------------------------------------------> 執行緒A:執行建構函式 寫i=1 寫j=2 StoreStore屏障 建構函式結束 建構函式的引用賦值給引用變數obj 執行緒B:讀物件普通域i(×) (...A執行完...) 讀物件引用obj LoadLoad屏障 讀物件final域j(√)
在上圖中,讀物件的普通域的操作被處理器重排序到讀物件引用之前。
讀普通域時,該域還沒有被寫執行緒A寫入,這是一個錯誤的讀取操作。
而讀final域的重排序規則會把讀物件final域的操作“限定”在讀物件引用之後,此時該final域已經被A執行緒初始化過了,這是一個正確的讀取操作。
讀 final 域的重排序規則可以確保:
在讀一個物件的 final 域之前,一定會先讀包含這個final域的物件的引用。
在這個示例程式中,如果該引用不為null,那麼引用物件的final域一定已經被A執行緒初始化過了。
如果 final 域是引用型別
上面我們看到的 final 域是基礎資料型別,下面讓我們看看如果 final 域是引用型別,將會有什麼效果?
請看下列示例程式碼:
public class FinalReferenceExample { final int[] intArray;//final是引用型別 static FinalReferenceExample obj; public FinalReferenceExample () {//建構函式 intArray = new int[1];//1 intArray[0] = 1;//2 } public static void writerOne () {//寫執行緒A執行 obj = new FinalReferenceExample ();//3 } public static void writerTwo () {//寫執行緒B執行 obj.intArray[0] = 2;//4 } public static void reader () {//讀執行緒C執行 if (obj != null) {//5 int temp1 = obj.intArray[0];//6 } } }
這裡 final 域為一個引用型別,它引用一個int型的陣列物件。
對於引用型別,寫final域的重排序規則對編譯器和處理器增加了如下約束:
- 在建構函式內對一個 final 引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。
對上面的示例程式,我們假設首先執行緒 A 執行 writerOne() 方法,執行完後執行緒 B 執行 writerTwo() 方法,
執行完後執行緒 C 執行 reader() 方法。
下面是一種可能的執行緒執行時序:
時間線:--------------------------------------------------------------------------------------------> 執行緒A:執行建構函式 1.寫final引用 2.寫final引用的物件成員域 StoreStore屏障 建構函式結束 3.建構函式的引用賦值給引用變數obj 執行緒B:4.寫final引用的物件的成員域 執行緒C:5.讀物件引用obj LoadLoad屏障 6.讀final引用的物件成員域
在上圖中,1 是對 final 域的寫入,2 是對這個 final 域引用的物件的成員域的寫入,3 是把被構造的物件的引用賦值給某個引用變數。
這裡除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。
JMM 可以確保讀執行緒 C 至少能看到寫執行緒 A 在建構函式中對 final 引用物件的成員域的寫入。
即 C 至少能看到陣列下標 0 的值為 1。
而寫執行緒 B 對陣列元素的寫入,讀執行緒 C 可能看的到,也可能看不到。
JMM 不保證執行緒 B 的寫入對讀執行緒 C 可見,因為寫執行緒 B 和讀執行緒 C 之間存在資料競爭,此時的執行結果不可預知。
如果想要確保讀執行緒 C 看到寫執行緒 B 對陣列元素的寫入,寫執行緒 B 和讀執行緒 C 之間需要使用同步原語(lock 或 volatile)來確保記憶體可見性。
為什麼 final 引用不能從建構函式內“逸出”
前面我們提到過,寫 final 域的重排序規則可以確保:在引用變數為任意執行緒可見之前,該引用變數指向的物件的final域已經在建構函式中被正確初始化過了。
其實要得到這個效果,還需要一個保證:
在建構函式內部,不能讓這個被構造物件的引用為其他執行緒可見,也就是物件引用不能在建構函式中“逸出”。
為了說明問題,讓我們來看下面示例程式碼:
public class FinalReferenceEscapeExample { final int i; static FinalReferenceEscapeExample obj; public FinalReferenceEscapeExample () { i = 1;//1 寫final域 obj = this;//2 this引用在此“逸出” } public static void writer() { new FinalReferenceEscapeExample (); } public static void reader { if (obj != null) {// 3 int temp = obj.i;// 4 } } }
假設一個執行緒 A 執行 writer() 方法,另一個執行緒 B 執行 reader() 方法。
這裡的操作 2 使得物件還未完成構造前就為執行緒 B 可見。
即使這裡的操作 2 是建構函式的最後一步,且即使在程式中操作 2 排在操作 1 後面,執行 read() 方法的執行緒仍然可能無法看到 final 域被初始化後的值,
因為這裡的操作 1 和操作 2 之間可能被重排序。
實際的執行時序可能如下圖所示:
時間線:--------------------------------------------------------------------------------------------> 執行緒A:執行建構函式 2.obj=this;被構造物件的引用在此處“溢位” 1.i=1;對final域初始化 建構函式結束 執行緒B:3.if(obj!=null);讀取不為null的引用a4.temp=obj.i;這裡將讀取到final域初始化之前的值
final 語義在處理器中的實現
現在我們以 x86 處理器為例,說明 final 語義在處理器中的具體實現。
上面我們提到,寫 final 域的重排序規則會要求譯編器在 final 域的寫之後,建構函式 return 之前,插入一個 StoreStore 障屏。
讀 final 域的重排序規則要求編譯器在讀 final 域的操作前面插入一個 LoadLoad 屏障。
由於 x86 處理器不會對寫-寫操作做重排序,所以在 x86 處理器中,寫 final 域需要的 StoreStore 障屏會被省略掉。
同樣,由於 x86 處理器不會對存在間接依賴關係的操作做重排序,所以在 x86 處理器中,
讀 final 域需要的 LoadLoad 屏障也會被省略掉。
也就是說在x86 處理器中,final 域的讀/寫不會插入任何記憶體屏障!
JSR-133 為什麼要增強 final 的語義
在舊的 Java 記憶體模型中 ,最嚴重的一個缺陷就是執行緒可能看到final域的值會改變。
比如,一個執行緒當前看到一個整形final域的值為0(還未初始化之前的預設值),過一段時間之後這個執行緒再去讀這個final域的值時,卻發現值變為了1(被某個執行緒初始化之後的值)。
最常見的例子就是在舊的Java記憶體模型中,String的值可能會改變。
為了修補這個漏洞,JSR-133專家組增強了final的語義。通過為final域增加寫和讀重排序規則,可以為java程式設計師提供初始化安全保證:只要物件是正確構造的(被構造物件的引用在建構函式中沒有“逸出”),那麼不需要使用同步(指lock和volatile的使用),就可以保證任意執行緒都能看到這個final域在建構函式中被初始化之後的值。
- String 的值可能會改變
String s1 = "/usr/tmp"; String s2 = s1.substring(4);
字串s2的偏移量為4,長度為4。
但是,在舊的模型下,另一個執行緒可以看到偏移量的預設值為0,然後再看到正確的值4,它將顯示為字串“/usr”變為“/tmp”。
參考資料
- final 基礎
http://toughcoder.net/blog/2016/11/12/understanding-java-keyword-final/
https://juejin.im/entry/58c4811161ff4b005d94fed2
https://www.cnblogs.com/dolphin0520/p/3736238.html
http://www.importnew.com/7553.html
https://blog.csdn.net/ch717828/article/details/46922777
- jls
final Fields and static Constant Variables
- jmm