Chapter 1-6. Final
Last updated
Last updated
與前面介紹的lock/volatile相比, 對final field的read/write更像是對普通變數的存取. 對於final field, 編譯器和處理器要遵守兩個重排序規則:
在建構子內對一個final field的寫入, 與隨後把這個被建構物件的參照賦值給一個參考變數, 這兩個操作之間不可以重排序.
初次讀取一個包含final field的物件之參照, 與隨後初次讀取這個final field, 這兩個操作之間不能重排序.
以下就通過一些範例程式來說明這兩個規則:
這裡假設一個執行緒A執行write()方法, 緊接著另一個執行緒B執行read()方法. 下面會通過這兩個執行緒的互動來說明這兩個規則.
寫入final field的重排序規則禁止把final field的寫入重排序到建構子之外. 這條規則的實作包含以下2個方面:
JMM禁止編譯器把final field的寫入重排序至建構子之外.
編譯器會在final field的寫入之後, 建構子的return之前, 插入一個StoreStore屏障. 這個屏障會禁止處理器把final field的寫入排序到建構子之外.
現在來看看write()方法. write()方法只包含一行程式:
這行程式包含兩個步驟:
obj = new FinalExample();
建構一個FinalExample類型的物件
把這個物件的參照賦值給參照變數obj
假設執行緒B讀物件參照與讀物件的member field之間沒有重排序(稍後會說明為什麼要這樣假設), 下圖是一種可能的執行時序: 在上圖中, 寫入普通變數的操作被編譯器重排序到了建構子之外, 執行緒B錯誤地讀取了普通變數i初始化之前的值; 而寫入final變數的操作, 被寫入final field的重排序規則"限制"在了建構子之內, 執行緒B正確地讀取了final變數初始化之後的值.
寫入final field的重排序規則可以確保: 在物件參照為任意執行緒可見之前, 物件的final field已經被正確初始化過了, 然而, 普通變數不具有這種保障. 以上圖為例, 在執行緒B"看到"物件參照obj時, 很有可能obj物件還沒有建構完成 (對普通變數i的寫入操作被重排序到建構子外, 此時初始值2還沒有寫入普通變數i).
讀final field的重排序規則如下:
在一個執行緒中, 初次讀取物件參照與初次讀取該物件包含的final field, JMM禁止處理器重排序這兩個操作(要注意的是, 此規則僅針對處理器). 編譯器會在讀final field操作的前面插入一個LoadLoad屏障.
初次讀取物件參照與初次讀取該物件包含的final field, 這兩個操作之間存在間接相依性. 由於編譯器遵守間接相依性, 因此編譯器不會重排序這兩個操作; 大多數處理器也會遵守間接依賴性, 所以大多數的處理器也不會去重排序這兩個操作. 但有少數處理器允許存在間接相依性的操作進行重排序(譬如alpha處理器), 此規則就是專門用來針對這種處理器的.
read()方法包含三個操作: 1. 初次讀取參照變數obj 2. 初次讀取參照變數obj指向之物件的普通field j 3. 初次讀取參照變數obj指向之物件的final field i
讀取final field的重排序規則可以確保: 在讀取一個物件的final field之前, 一定會先讀取包含這個final field的物件之參照. 在這個範例程式中, 若該參照不為null, 那麼參照物件的final field一定已經被執行緒A初始化過了.
在建構子內對一個final參照的物件之member field的寫入, 與隨後在建構子外把這個被建構物件的參照賦值給一個參照變數, 此兩操作之間不能重排序.
JMM可以確保執行緒C至少能看到執行緒A在建構子中對final參照物件的member field的寫入. 即C至少能看到陣列索引0的值為1; 而執行緒B對陣列元素的寫入, 執行緒C可能看得到, 也可能看不到. JMM不保證執行緒B的寫入對執行緒C可見, 因為執行緒B/C之間存在data race, 此時的執行結果不可預知.
若想要確保執行緒C看到執行緒B對陣列元素的寫入, 執行緒B/C之間需要使用同步關鍵字(lock或是volatile)來確保記憶體可見性.
從上圖我們可以看出: 在建構子回傳之前, 被建構物件的參照不能被其他執行緒看到, 因為此時的final field可能還沒有被初始化.在建構子回傳後, 任何執行緒都將保證能夠看到final field正確初始化後的值.
現在我們以x86處理器為例, 說明final語意在處理器中的具體實作. 前面有提到, 寫final field的重排序規則會要求編譯器在final field的寫之後, 建構子回傳之前, 插入一個StoreStore屏障. 讀final field的重排序規則要求編譯器在讀final field的操作前面插入一個LoadLoad屏障.
由於x86處理器不會對write-write操作進行重排序, 故在x86處理器中, 寫final field需要的StoreStore屏障會被省略掉. 同樣, 由於x86處理器不會對存在間接相依性的操作進行重排序, 所以在x86處理器中, 讀final field需要的LoadLoad屏障也會被省略掉. 意思就是說在x86處理器裡, final field的read/write不會插入任何記憶體屏障.
在舊的Java記憶體模型中, 最嚴重的一個缺陷就是執行緒可能看到final field的值會改變. 譬如說, 一個執行緒當前看到一個整數型別的final field其值為0(還沒初始化的預設值), 過一段時間後, 這個執行緒再去讀這個final field的值時, 卻發現值變為了1(被某個執行緒初始化之後的值). 最常見的例子就是在舊的Java記憶體模型中, String的值可能會改變(參考文獻). 為了修補這個漏洞, JSR-133增強了final的語意. 通過為final field增加寫和讀的重排序規則, 可以為Java開發者提供初始化安全保證: 只要物件是正確建構的(被建構物件的參照在建構子中沒有"逸出"), 那麼不需要使用同步(即lock/volatile的使用), 就可以保證任意執行緒都能看到這個final field在建構子中被初始化之後的值.
現在, 我們假設執行緒A沒有發生任何重排序, 同時程式在不遵守間接相依性的處理器上執行, 以下就是一種可能的執行時序: 在上圖中, 讀物件的普通field之操作被處理器重排序到讀取物件參照之前. 讀普通field時, 該field還沒有被執行緒A寫入, 這是一個錯誤的讀取操作, 而讀取final field的重排序規則會把讀取物件final field的操作"限制"在讀取物件參照之後, 此時該final field已經被執行緒A初始化過了, 這是一個正確的讀取操作.
以上我們看到的final field都是primitive type, 再來看一下若final field是reference type(參照型), 會發生什麼事. 請看以下範例程式: 這裡final field為一個參照型, 其引用了一個int型的陣列物件. 對於參照型, 寫入final field的重排序規則對編譯器和處理器增加了如下約束:
對上面的範例程式, 我們假設首先執行緒A執行writeOne()方法, 執行完後執行緒B執行writeTwo()方法, 最後執行緒C執行read()方法. 以下就是一種可能的執行緒執行時序: 在上圖中, 1是對final filed的寫入, 2是對這個final field參照之物件的member field的寫入, 3是把被建構的物件之參照賦值給某個參照變數. 此處除了前面提到的1不能和3重排序外, 2與3也不能重排序.
前面我們提到過, 寫入final field的重排序規則可以確保: 在參照變數為任意執行緒可見之前, 該參照變數指向的物件的final field已經在建構子中被正確初始化過了. 其實要得到這個效果, 還需要一個保證: 在建構子的內部, 不能讓這個被建構物件的參照被其他執行緒看見, 也就是物件參照不能在建構子中"逸出". 為了說明這個問題, 可以先看一下下面這段範例程式:
假設一個執行緒A執行write()方法, 另一個執行緒B執行read()方法. 這裡的操作2會使得物件還未完成建構之前就對執行緒B可見. 即使這裡的操作2是建構子的最後一步, 且即使在程式中操作2排在操作1後面, 執行read()方法的執行緒仍然可能無法看到final field被初始化後的值, 因為這裡的操作1和操作2之間可能被重排序. 實際的執行時序可能如下圖所示: