眾所周知,無限制下多線程操作共享變量是危險的,為了保證線程安全語義,一般的建議是在操作共享變量時加鎖,比方說在用synchronized關鍵字修飾的方法內讀寫共享變量。
但是synchronized開銷較大,有沒有更輕量更優雅的解決方案呢?
volatile是輕量級的synchronized,在正確使用的前提下,它可以達到與synchronized一樣的線程安全的語義,而且不會帶來線程切換的開銷。
volatile的作用是什么?
volatile保證了共享變量的“可見性”。可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。它在某些情況下比synchronized的開銷更小。
這句話可能難以理解,我來舉個例子。
在我之前寫的這篇文章<Ticket Lock, CLH Lock, MCS Lock>中,第一個給出的naive lock的例子里,flag變量被聲明為volatile。
如果flag不是volatile而是普通變量,那會發生什么呢?
想象一個場景:線程A正在占有鎖,線程B自旋監聽flag變量。現在線程A退出臨界區,將flag置為false。但是線程B能立即觀察到flag的變化嗎?
很不幸,不一定。
因為現代CPU中有多個core,每個core都有各自的高速緩存(cache),線程A對flag的修改只寫在了cache上,需要一段不確定的時間才會被刷新到主存中。而線程B所在的core也會將flag變量緩存在cache中,這樣就算主存中的flag變量發生了變化,線程B還是不一定能看到。
所以,如果flag是普通變量,naive lock是不成立的。
而如果將flag設置為volatile類型,JVM就會保證任何對flag變量的寫操作會被立即刷新到主存里,同時還會讓其他緩存了flag變量的core的對應的cache line失效,強迫其他線程必須從主存中讀取最新的值。
這樣就實現了共享變量被一個線程修改,其他線程立刻就能讀到的語義。
那么volatile關鍵字的底層原理是什么呢?它是如何讓寫操作直接被刷新到主存,又是如何讓其他core的cache line失效的呢?
如果觀察編譯出來的機器碼,會發現在對volatile變量的寫操作之后,會附加一條指令
lock addl $0x0,(%esp);
很容易可以看出,addl $0x0,(%esp) 這句話本身是沒有任何作用的,效果與nop這樣的空轉指令等同,但是前面的lock前綴,有點意思。
查閱Intel的<Intel® 64 and IA-32 Architectures Software Developer’s Manual>,里面有這樣一段話
8.1.4 Effects of a LOCK Operation on Internal Processor Caches
For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor.
For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.
大概意思是說,遇到lock指令,對應的core上的cache line會被強制刷回到主存中。然后由於緩存一致性協議的效果(參見我的這篇博客<緩存一致性協議>),其他core上的對應的cache line也會被強制設置為invalid。
於是volatile的語義就實現了,僅需這一條lock指令。
但是volatile能保證原子性嗎?比方說如果我們多線程對一個volatile型的變量做自增操作,這是線程安全的嗎?
答曰:並不是。
我們想象有兩個線程對volatile類型的變量count做自增操作,count初始值為0,兩個線程同時拿到了0,同時自增為1,然后同時寫回,這樣count的結果還是1,不符合期望。
那么我們應該怎么辦呢?
參考AtomInteger,使用UNSAFE提供的CAS操作更新count,在上面的場景中,兩個線程同時執行CAS(count, 0, 1),只有其中一個線程能執行成功,另外一個線程失敗重試,讀取新的count值,然后執行CAS(count, 1, 2),這一次執行成功了,那么count的最終值是2。符合期望。
參考資料:
Intel® 64 and IA-32 Architectures Software Developer’s Manual,8.1.4節,p257