- Java程序員面試筆試寶典(第2版)
- 何昊等編著
- 1901字
- 2022-06-17 16:00:45
1.11 volatile的作用
volatile的使用是為了線程安全,但volatile不保證線程安全。線程安全有三個要素:可見性、有序性和原子性。線程安全是指在多線程情況下,對共享內存的使用,不會因為不同線程的訪問和修改而發生不期望的情況。
volatile有以下三個作用:
(1)volatile用于解決多核CPU高速緩存導致的變量不同步
這本質上是個硬件問題,其根源在于:CPU的高速緩存的讀取速度遠遠快于主存(物理內存)。所以,CPU在讀取一個變量的時候,會把數據先讀取到緩存,這樣下次再訪問同一個數據的時候就可以直接從緩存讀取了,顯然提高了讀取的性能。而多核CPU有多個這樣的緩存。這就帶來了問題,當某個CPU(例如CPU1)修改了這個變量(比如把a的值從1修改為2),但是其他的CPU(例如CPU2)在修改前已經把a=1讀取到自己的緩存了,當CPU2再次讀取數據的時候,它仍然會去自己的緩存區中讀取,此時讀取到的值仍然是1,但是實際上這個值已經變成2了。這里,就涉及了線程安全的要素:可見性。
可見性是指當多個線程在訪問同一個變量時,如果其中一個線程修改了變量的值,那么其他線程應該能立即看到修改后的值。
volatile的實現原理是內存屏障(Memory Barrier),其原理為:當CPU寫數據時,如果發現一個變量在其他CPU中存有副本,那么會發出信號量通知其他CPU將該副本對應的緩存行置為無效狀態,當其他CPU讀取到變量副本的時候,會發現該緩存行是無效的,然后,它會從主存重新讀取變量。
(2)volatile可以解決指令重排序的問題
一般情況下,程序是按照順序執行的,例如下面的代碼:

如果i++發生在int i=0之前,那么會不可避免地出錯,CPU在執行代碼對應指令的時候,會認為1、2兩行是具備依賴性的,因此,CPU一定會安排行1早于行2執行。
那么,int i=0一定會早于boolean f=false嗎?
并不一定,CPU在運行期間會對指令進行優化,沒有依賴關系的指令,它們的順序可能會被重排。在單線程執行下,發生重排是沒有問題的,CPU保證了順序不一定一致,但結果一定一致。
但在多線程環境下,重排序則會引起很大的問題,這又涉及了線程安全的要素:有序性。
有序性是指程序執行的順序應當按照代碼的先后順序執行。
為了更好地理解有序性,下面通過一個例子來分析:

理想的結果應該是,線程二不停地打印0,最后打印一個1,終止。
在線程一里,f和i沒有依賴性,如果發生了指令重排,那么f = true發生在i++之前,就有可能導致線程二在終止循環前輸出的全部是0。
需要注意的是,這種情況并不常見,再次運行并不一定能重現,正因為如此,很可能會導致出現一些莫名的問題。如果修改上方代碼中i的定義為使用volatile關鍵字來修飾,那么就可以保證最后的輸出結果符合預期。這是因為,被volatile修飾的變量,CPU不會對它做重排序優化,所以也就保證了有序性。
(3)volatile不保證操作的原子性
原子性:一個或多個操作,要么全部連續執行且不會被任何因素中斷,要么就都不執行。一眼看上去,這個概念和數據庫概念里的事務(Transaction)很類似,沒錯,事務就是一種原子性操作。
原子性、可見性和有序性,是線程安全的三要素。
需要特別注意的是,volatile保證可見性和有序性,但是不保證操作的原子性,下面的代碼將會證明這一點:

在之前的內容有提及,volatile能保證修改后的數據對所有線程可見,那么,這一段對intVal自增的代碼,最終執行完畢的時候,intVal應該為10000。
但事實上,結果是不確定的,大部分情況下會小于10000。這是因為,無論是volatile還是自增操作,都不具備原子性。
假設intVal初始值為100,自增操作的指令執行順序如下所示:
1)獲取intVal值,此時主存內intVal值為100;
2)intVal執行+1,得到101,此時主存內intVal值仍然為100;
3)將101寫回給intVal,此時主存內intVal值從100變化為101。
具體執行流程如圖1-2所示。
這個過程很容易理解,如果這段指令發生在多線程環境下呢?以下面這段會發生錯誤的指令順序為例:
1)線程一獲得了intVal值為100;
2)線程一執行+1,得到101,此時值沒有寫回給主存;
3)線程二在主存內獲得了intVal值為100;
4)線程二執行+1,得到101;
5)線程一寫回101;
6)線程二寫回101;
于是,最終主存內的intVal值,還是101。具體執行流程如圖1-3所示。

圖1-2 自增操作的實現原理

圖1-3 多線程執行自增操作的結果
為什么volatile的可見性保證在這里沒有生效?
根據volatile保證可見性的原理(內存屏障),當一個線程執行寫的時候,才會改變“數據修改”的標量,在上述過程中,線程一在執行加法操作發生后,寫回操作發生前,CPU開始處理線程二的時間片,執行了另外一次讀取intVal,此時intVal值為100,且由于寫回操作尚未發生,這一次讀取是成功的。
因此,出現了最后計算結果不符合預期的情況。
synchoronized關鍵字確實可以解決多線程的原子操作問題,可以修改上面的代碼為:

但是,這種方式明顯效率不高(后面會介紹如何通過CAS來保證原子性),10個線程都在爭搶同一個代碼塊的使用權。
由此可見,volatile只能提供線程安全的兩個必要條件:可見性和有序性。