volatile詳解


本文對volatile的概念、原子性、指令重排、內存屏障、使用與場景等知識做說明,試圖為讀者理解volatile提供幫助。

一. 概念


volatile字面意思是易變的、不穩定的。
在Java中關鍵字volatile是一個類型修飾符,使用方式如:

static volatile int i=0;

其作用是告訴虛擬機該變量是極有可能多變的,此處免於一些優化措施,不能隨意變動目標指令,並保障該變量上操作的原子性。
volatile修飾的變量有“可見性”,其含義是變量被修改后,應用程序范圍內的所有線程都能夠知道這個改動。
volatile是非排他的,常常用於多線程之間的共享變量。volatile並非鎖的替代品,但在一定條件下它比鎖更合適,性能開銷比鎖更少。

二. 原子性


原子性是說一個操作是不可中斷的,不可分割的。
並非簡單的操作就是原子性的,或者復雜的操作就是非原子性的。
i++是非原子性的,該操作實際上是一個read-modify-write操作,在執行過程中其他線程可能已經修改了i的值,因此該操作不具備不可分割性,也就不是原子操作。但若i是一個局部變量,保證了read-modify-write過程不被其他線程干擾,那么該操作就是一個原子的操作。

一般而言,對volatile變量的賦值操作,只要表達式右邊涉及非局部變量,該操作就不是一個原子操作。

又如這樣一個賦值操作:

volatile HashMap map=new HashMap();

可以分解為如下偽代碼:

obj=allocate(HashMap.Class);//子操作1,分配內存
Constructor(obj);//子操作2,初始化
map=obj;//子操作3,將對象引用寫入map

雖然volatile只保障了子操作3是一個原子操作,但是由於子操作2和子操作3僅涉及局部變量二未涉及共享變量,因此對map變量的賦值操作仍然是一個原子操作。

又如對一個long型變量賦值:

pulic class Test { private static long lo=0; public Change(long v){ this.lo=v; } }

由於long型長64位,在32位系統中,對long型變量賦值需要2次才能完成,期間可能有別的線程干擾,因此語義上即使只是對基本類型的一步賦值,該操作也是非原子性的。

綜上,得出認識:volatile僅保障被修飾變量本身的讀、寫操作的原子性。如要保障volatile變量賦值語句的原子性,那么該語句中的操作不能涉及任何對共享變量—包括volatile變量本身--的訪問。


三. 指令重排

happens-before原則中有一條程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意后續操作。
通俗的講,代碼的執行是要保證從先往后,依次執行的—這是非常自然的事情。

這里的順序其實是從代碼的語義方面講的,在程序實際執行時,出於效率等目的在指令這一層面會對指令的先后順序進行重排。在單線程情況下,指令重排不會影響語義邏輯,但多線程時,指令重排沒有義務也無法確保多線程間的語義也一致。

一條指令的執行有很多細節,但簡單地說,可以分為幾步:

取指 IF
譯碼和取寄存器操作數 ID
執行或者有效地址計算 EX
存儲器訪問 MEM
寫回WB

完成指令需要分配CPU時間片,也涉及不同的硬件,如寄存器、算術邏輯單元ALU等。因此在執行各種指令時,使用的是一種流水線技術。

流水線能使CPU高效執行,滿載時效能客觀。但一旦被中斷就使所有硬件都進入一個停頓期,再次滿載需要幾個周期。為避免效能損失,需要想辦法盡量不讓流水線中斷。而指令重排的目的就是為了供給連續緊湊的指令,盡量少的中斷流水線。

下面以A=B+C這個操作為例:


上圖中左邊的是指令,LW表示load,LW  R1,B 表示把B加載到寄存器R1中。ADD指令是加法,第三條指令是R1,R2中的值相加后放入寄存器R3。SW表示store,該處命令是將R3的值保存到變量A。
右邊是流水線的情況,注意到有兩個紅叉,表示流水線在這里停頓,原因是數據沒有准備好,如第三條ADD在等待寄存器R2的值。由於ADD的停頓,后面所有的指令都要慢一拍。


看一個更復雜的例子:
a=b-c
x=y+z
上面的代碼執行如下:


由於SUB減和ADD加指令,這里有不少停頓。而在這里先進行LW Ry,y和LW Rz,z這兩條加載指令對程序邏輯是沒有影響的,既然有停頓,不如利用停頓時間完成這兩步操作:

指令重排后,所有的停頓消除,流水線順暢:

 

四. 內存屏障


volatile能保障有序性和可見性,因為寫線程對volatile變量做寫入操作時會產生一個類似釋放鎖的效果,讀線程對volatile變量做讀取操作時會產生一個類似獲取鎖的效果。
寫線程對volatile變量做寫入操作時,虛擬機在該操作前插入一個內存釋放屏障Release Barrier,在操作完成后插入一個內存存儲屏障Store Barrier。

釋放屏障禁止寫操作及其之前的操作有指令重排,這保證了volatile寫操作前的任何讀、寫操作都先序提交,也就是說當其他線程看到寫操作對volatile變量的更新時,之前其他執行操作的結果對此時的讀線程都是可見的。

 

volatile變量讀操作時,Java虛擬機會在操作前插入一個裝載屏障Load Barrier,在操作后插入一個獲取屏障Acquire Barrier。

裝載屏障通過沖刷處理器緩存,使當前執行線程所在的處理器將其他處理器對共享變量作的更新同步到該處理器的高速緩存中。獲取屏障禁止指令重排。

寫操作的釋放屏障與讀操作的裝載屏障一起使用保障了volatile的可見性。這類似於鎖對可見性的保障,但volatile是非排他的即非阻塞的,因而volatile讀取並不能保證是時時最新的值,可能在讀取的同時有寫操作在更新共享變量。

為有助於理解,可以把釋放屏障(Release Barrier)設想成是打開的屏障,正在接受之前的操作完成與提交,或是根據字面意思,在release前當然要求所有操作完成;加載屏障Load Barrier是從其他線程所在的處理器里獲取最新,並加載到當前的處理器緩存中。


五. 數組與對象

如果volatile修飾數組,那么volatile只能對數組引用本身起作用,無法對數組中的元素起作用。

volatile int[] oneArrary   //假設修飾一個數組
int i=oneArrary[0]; //操作1
ineArrary[1]=1;//操作2
volatile int[] twoArrary=oneArrary;//操作3

操作1中,實際上可分解為2個子操作,子操作(1)讀取數組引用地址,由於volatile修飾數組,這步子操作是volatile有效的,子操作(2)在取到數組引用后根據下標讀取具體元素,這步與volatile是無關的。因此操作1無法保障volatile。
操作2中,元素與volatile無關,volatile不起作用。
操作3中,操作的都是數組引用,volatile是起作用的。
類似地,對於引用型的對象,volatile只是能保障對象的引用,至於該引用所指向的對象實例中的值volatile無法保障。
如果要使數組內的元素也能觸發volatile作用,可以使用AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。

 

六. 典型場景

需要注意一點,未用volatile修飾共享變量時,當虛擬機在Client模式下,JIT不會做足夠的優化,有時共享變量的更新反而對線程可見,當虛擬機在Server模式下,JIT的進行足夠的優化,有些數據副本及緩存的措施,共享變量對線程不具可見性。

public class temp { private  static  boolean ok;//注意這里
    private  static  int num; private  static  class  WorkerThread extends Thread{ public  void run(){ while (!ok){ System.out.println(num); } } } public  static  void main(String[] args) throws InterruptedException{ new WorkerThread().start(); Thread.sleep(1000); num=42; ok=true; Thread.sleep(10000); } }

上述代碼在Client模式下WorkerThread可以發現判斷變動,退出程序。但在Server模式下時,優化后無法發現變量的改動,導致程序無法退出。
此時,需要把ok變量修飾為volatile即可。這個場景也是volatile使用的典型場景--基於對共享變量的原子操作。

 

參考:
[1] 葛一鳴 郭超 Java高並發程序設計 
[2] 黃文海 Java多線程編程實戰指南


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM