您好,登錄后才能下訂單哦!
這篇文章主要介紹“如何理解Java中volatile關鍵字”,在日常操作中,相信很多人在如何理解Java中volatile關鍵字問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”如何理解Java中volatile關鍵字”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!
CPU
緩存計算機中的所有運算操作都是由CPU
完成的,CPU
指令執行過程需要涉及數據讀取和寫入操作,但是CPU
只能訪問處于內存中的數據,而內存的速度和CPU
的速度是遠遠不對等的,因此就出現了緩存模型,也就是在CPU
和內存之間加入了緩存層。一般現代的CPU
緩存層分為三級,分別叫L1
緩存、L2
緩存和L3
緩存,簡略圖如下:
L1
緩存:三級緩存中訪問速度最快,但是容量最小,另外L1
緩存還被劃分成了數據緩存(L1d
,data
首字母)和指令緩存(L1i
,instruction
首字母)
L2
緩存:速度比L1
慢,但是容量比L1
大,在現代的多核CPU
中,L2
一般被單個核獨占
L3
緩存:三級緩存中速度最慢,但是容量最大,現代CPU
中也有L3
是多核共享的設計,比如zen3
架構的設計
緩存的出現,是為了解決CPU
直接訪問內存效率低下的問題,CPU
進行運算的時候,將需要的數據從主存復制一份到緩存中,因為緩存的訪問速度快于內存,在計算的時候只需要讀取緩存并將結果更新到緩存,運算結束再將結果刷新到主存,這樣就大大提高了計算效率,整體交互圖簡略如下:
雖然緩存的出現,大大提高了吞吐能力,但是,也引入了一個新的問題,就是緩存不一致。比如,最簡單的一個i++
操作,需要將內存數據復制一份到緩存中,CPU
讀取緩存值并進行更新,先寫入緩存,運算結束后再將緩存中新的刷新到內存,具體過程如下:
讀取內存中的i
到緩存中
CPU
讀取緩存i
中的值
對i
進行加1操作
將結果寫回緩存
再將數據刷新到主存
這樣的i++
操作在單線程不會出現問題,但在多線程中,因為每個線程都有自己的工作內存(也叫本地內存,是線程自己的緩存),變量i
在多個線程的本地內存中都存在一個副本,如果有兩個線程執行i++
操作:
假設兩個線程為A、B,同時假設i
初始值為0
線程A從內存中讀取i
的值放入緩存中,此時i
的值為0,線程B也同理,放入緩存中的值也是0
兩個線程同時進行自增操作,此時A、B線程的緩存中,i
的值都是1
兩個線程將i
寫入主內存,相當于i
被兩次賦值為1
最終結果是i
的值為1
這個就是典型的緩存不一致問題,主流的解決辦法有:
總線加鎖
緩存一致性協議
這是一種悲觀的實現方式,具體來說,就是通過處理器發出lock
指令,鎖住總線,總線收到指令后,會阻塞其他處理器的請求,直到占用鎖的處理器完成操作。特點是只有一個搶到總線鎖的處理器運行,但是這種方式效率低下,一旦某個處理器獲取到鎖其他處理器只能阻塞等待,會影響多核處理器的性能。
圖示如下:
緩存一致性協議中最出名的就是MESI
協議,MESI
保證了每一個緩存中使用的共享變量的副本都是一致的。大致思想是,CPU
操作緩存中的數據時,如果發現該變量是一個共享變量,操作如下:
讀取:不做其他處理,只是將緩存中數據讀取到寄存器中
寫入:發出信號通知其他CPU
將該變量的緩存行設置為無效狀態(Invalid
),其他CPU
進行該變量的讀取時需要到主存中再次獲取
具體來說,MESI
中規定了緩存行使用4種狀態標記:
M
:Modified
,被修改
E
:Exclusive
,獨享的
S
:Shared
,共享的
I
:Invalid
,無效的
有關MESI
詳細的實現超出了本文的范圍,想要詳細了解可以參考此處或此處。
JMM
看完了CPU
緩存再來看一下JMM
,也就是Java
內存模型,指定了JVM
如何與計算機的主存進行工作,同時也決定了一個線程對共享變量的寫入何時對其他線程可見,JMM
定義了線程和主內存之間的抽象關系,具體如下:
共享變量存儲于主內存中,每個線程都可以訪問
每個線程都有私有的工作內存或者叫本地內存
工作內存只存儲該線程對共享變量的副本
線程不能直接操作主內存,只有先操作了工作內存之后才能寫入主內存
工作內存和JMM
內存模型一樣也是一個抽象概念,其實并不存在,涵蓋了緩存、寄存器、編譯期優化以及硬件等
簡略圖如下:
與MESI
類似,如果一個線程修改了共享變量,刷新到主內存后,其他線程讀取工作內存的時候發現緩存失效,會從主內存再次讀取到工作內存中。
而下圖表示了JVM
與計算機硬件分配的關系:
文章都看了大半了還沒到volatile
?別急別急,先來看看并發編程中的三個重要特性,這對正確理解volatile
有很大的幫助。
原子性就是在一次或多次操作中:
要么所有的操作全部都得到了執行,且不會受到任何因素的干擾而中斷
要么所有的操作都不執行
一個典型的例子就是兩個人轉賬,比如A向B轉賬1000元,那么這包含兩個基本的操作:
A的賬戶扣除1000元
B的賬戶增加1000元
這兩個操作,要么都成功,要么都失敗,也就是不能出現A賬戶扣除1000但是B賬戶金額不變的情況,也不能出現A賬戶金額不變B賬戶增加1000的情況。
需要注意的是兩個原子性操作結合在一起未必是原子性的,比如i++
。本質上來說,i++
涉及到了三個操作:
get i
i+1
set i
這三個操作都是原子性的,但是組合在一起(i++
)就不是原子性的。
另一個重要的特性是可見性,可見性是指,一個線程對共享變量進行了修改,那么另外的線程可以立即看到修改后的最新值。
一個簡單的例子如下:
public class Main { private int x = 0; private static final int MAX = 100000; public static void main(String[] args) throws InterruptedException { Main m = new Main(); Thread thread0 = new Thread(()->{ while(m.x < MAX) { ++m.x; } }); Thread thread1 = new Thread(()->{ while(m.x < MAX){ } System.out.println("finish"); }); thread1.start(); TimeUnit.MILLISECONDS.sleep(1); thread0.start(); } }
線程thread1
會一直運行,因為thread1
把x
讀入工作內存后,會一直判斷工作內存中的值,由于thread0
改變的是thread0
工作內存的值,并沒有對thread1
可見,因此永遠也不會輸出finish
,使用jstack
也可以看到結果:
有序性是指代碼在執行過程中的先后順序,由于JVM
的優化,導致了代碼的編寫順序未必是代碼的運行順序,比如下面的四條語句:
int x = 10; int y = 0; x++; y = 20;
有可能y=20
在x++
前執行,這就是指令重排序。一般來說,處理器為了提高程序的效率,可能會對輸入的代碼指令做一定的優化,不會嚴格按照編寫順序去執行代碼,但可以保證最終運算結果是編碼時的期望結果,當然,重排序也有一定的規則,需要嚴格遵守指令之間的數據依賴關系,并不是可以任意重排序,比如:
int x = 10; int y = 0; x++; y = x+1;
y=x+1
就不能先優于x++
執行。
在單線程下重排序不會導致預期值的改變,但在多線程下,如果有序性得不到保證,那么將可能出現很大的問題:
private boolean initialized = false; private Context context; public Context load(){ if(!initialized){ context = loadContext(); initialized = true; } return context; }
如果發生了重排序,initialized=true
排序到了context=loadContext()
的前面,假設兩個線程A、B同時訪問,且loadContext()
需要一定耗時,那么:
線程A通過判斷后,先設置布爾變量的值為true
,再進行loadContext()
操作
線程B中由于布爾變量被設置為true
,會直接返回一個未加載完成的context
volatile
好了終于到了volatile
了,前面說了這么多,目的就是為了能徹底理解和明白volatile
。這部分分為四個小節:
volatile
的語義
如何保證有序性以及可見性
實現原理
使用場景
與synchronized
區別
先來介紹一下volatile
的語義。
被volatile
修飾的實例變量或者類變量具有兩層語義:
保證了不同線程之間對共享變量操作時的可見性
禁止對指令進行重排序操作
先說結論:
volatile
能保證可見性
volatile
能保證有序性
volatile
不能保證原子性
下面分別進行介紹。
Java
中保證可見性有如下方式:
volatile
:當一個變量被volatile
修飾時,對共享資源的讀操作會直接在主內存中進行(準確來說也會讀取到工作內存中,但是如果其他線程進行了修改就必須從主內存重新讀取),寫操作是先修改工作內存,但是修改結束后立即刷新到主內存中
synchronized
:synchronized
一樣能保證可見性,能夠保證同一時刻只有一個線程獲取到鎖,然后執行同步方法,并且確保鎖釋放之前,變量的修改被刷新到主內存中
使用顯式鎖Lock
:Lock
的lock
方法能保證同一時刻只有一個線程能夠獲取到鎖然后執行同步方法,并且確保鎖釋放之前能夠將對變量的修改刷新到主內存中
具體來說,可以看一下之前的例子:
public class Main { private int x = 0; private static final int MAX = 100000; public static void main(String[] args) throws InterruptedException { Main m = new Main(); Thread thread0 = new Thread(()->{ while(m.x < MAX) { ++m.x; } }); Thread thread1 = new Thread(()->{ while(m.x < MAX){ } System.out.println("finish"); }); thread1.start(); TimeUnit.MILLISECONDS.sleep(1); thread0.start(); } }
上面說過這段代碼會不斷運行,一直沒有輸出,就是因為修改后的x
對線程thread1
不可見,如果在x
的定義中加上了volatile
,就不會出現沒有輸出的情況了,因為此時對x
的修改是線程thread1
可見的。有序性
JMM
中允許編譯期和處理器對指令進行重排序,在多線程的情況下有可能會出現問題,為此,Java
同樣提供了三種機制去保證有序性:
volatile
synchronized
顯式鎖Lock
另外,關于有序性不得不提的就是Happens-before
原則。Happends-before
原則說的就是如果兩個操作的執行次序無法從該原則推導出來,那么就無法保證有序性,JVM
或處理器可以任意重排序。這么做的目的是為了盡可能提高程序的并行度,具體規則如下:
程序次序規則:在一個線程內,代碼按照編寫時的次序執行,編寫在后面的操作發生與編寫在前面的操作之后
鎖定規則:如果一個鎖處于鎖定狀態,則unlock
操作要先行發生于對同一個鎖的lock
操作
volatile
變量規則:對一個變量的寫操作要早于對這個變量之后的讀操作
傳遞規則:如果操作A先于操作B,操作B先于操作C,那么操作A先于操作C
線程啟動規則:Thread
對象的start()
方法先行發生于對該線程的任何動作
線程中斷規則:對線程執行interrupt()
方法肯定要優于捕獲到中斷信號,換句話說,如果收到了中斷信號,那么在此之前必定調用了interrupt()
線程終結規則:線程中所有操作都要先行發生于線程的終止檢測,也就是邏輯單元的執行肯定要發生于線程終止之前
對象終結規則:一個對象初始化的完成先行發生于finalize()
之前
對于volatile
,會直接禁止對指令重排,但是對于volatile
前后無依賴關系的指令可以隨意重排,比如:
int x = 0; int y = 1; //private volatile int z; z = 20; x++; y--;
在z=20
之前,先定義x
或先定義y
并沒有要求,只需要在執行z=20
的時候,可以保證x=0,y=1
即可,同理,x++
或y--
具體先執行哪一個并沒有要求,只需要保證兩者執行在z=20
之后即可。
在Java
中,所有對基本數據類型變量的讀取賦值操作都是原子性的,對引用類型的變量讀取和賦值也是原子性的,但是:
將一個變量賦值給另一個變量的操作不是原子性的,因為涉及到了一個變量的讀取以及一個變量的寫入,兩個原子性操作結合在一起就不是原子性操作
多個原子性操作在一起就不是原子性操作,比如i++
JMM
只保證基本讀取和賦值的原子性操作,其他的均不保證,如果需要具備原子性,那么可以使用synchronized
或Lock
,或者JUC
包下的原子操作類
也就是說,volatile
并不能保證原子性,例子如下:
public class Main { private volatile int x = 0; private static final CountDownLatch latch = new CountDownLatch(10); public void inc() { ++x; } public static void main(String[] args) throws InterruptedException { Main m = new Main(); IntStream.range(0, 10).forEach(i -> { new Thread(() -> { for (int j = 0; j < 1000; j++) { m.inc(); } latch.countDown(); }).start(); }); latch.await(); System.out.println(m.x); } }
最后輸出的x
的值會少于10000
,而且每次運行的結果也并不相同,至于原因,可以從兩個線程A、B開始分析,圖示如下:
0-t1
:線程A將x
讀入工作內存,此時x=0
t1-t2
:線程A時間片完,CPU
調度線程B,線程B將x
讀入工作內存,此時x=0
t2-t3
:線程B對工作內存中的x
進行自增操作,并更新到工作內存中
t3-t4
:線程B時間片完,CPU
調度線程A,同理線程A對工作內存中的x
自增
t4-t5
:線程A將工作內存中的值寫回主內存,此時主內存中的值為x=1
t5
以后:線程A時間片完,CPU
調度線程B,線程B也將自己的工作內存寫回主內存,再次將主內存中的x
賦值為1
也就是說,多線程操作的話,會出現兩次自增但是實際上只進行一次數值修改的操作。想要x
的值變為10000
也很簡單,加上synchronized
即可:
new Thread(() -> { synchronized (m) { for (int j = 0; j < 1000; j++) { m.inc(); } } latch.countDown(); }).start();
前面已經知道,volatile
可以保證有序性以及可見性,那么,具體是如何操作的呢?
答案就是一個lock;
前綴,該前綴實際上相當于一個內存屏障,該內存屏障會為指令的執行提供如下幾個保障:
確保指令重排序時不會將其后面的代碼排到內存屏障之前
確保指令重排序時不會將其前面的代碼排到內存屏障之后
確保執行到內存屏障修飾的指令時前面的代碼全部執行完成
強制將線程工作內存中的值修改刷新到主存中
如果是寫操作,會導致其他線程工作內存中的緩存數據失效
一個典型的使用場景是利用開關進行線程的關閉操作,例子如下:
public class ThreadTest extends Thread{ private volatile boolean started = true; @Override public void run() { while (started){ } } public void shutdown(){ this.started = false; } }
如果布爾變量沒有被volatile
修飾,那么很可能新的布爾值刷新不到主內存中,導致線程不會結束。
synchronized
的區別使用上的區別:volatile
只能用于修飾實例變量或者類變量,但是不能用于修飾方法、方法參數、局部變量等,另外可以修飾的變量為null
。但synchronized
不能用于對變量的修飾,只能修飾方法或語句塊,而且monitor
對象不能為null
對原子性的保證:volatile
無法保證原子性,但是synchronized
可以保證
對可見性的保證:volatile
與synchronized
都能保證可見性,但是synchronized
是借助于JVM
指令monitor enter
/monitor exit
保證的,在monitor exit
的時候所有共享資源都被刷新到主內存中,而volatile
是通過lock;
機器指令實現的,迫使其他線程工作內存失效,需要到主內存加載
對有序性的保證:volatile
能夠禁止JVM
以及處理器對其進行重排序,而synchronized
保證的有序性是通過程序串行化執行換來的,并且在synchronized
代碼塊中的代碼也會發生指令重排的情況
其他區別:volatile
不會使線程陷入阻塞,但synchronized
會
到此,關于“如何理解Java中volatile關鍵字”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。