導語
在Java多線程並發編程中,volatile關鍵詞扮演着重要角色,它是輕量級的synchronized,在多處理器開發中保證了共享變量的“可見性”。“可見性”的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。如果一個字段被聲明為volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。與synchronized不同,volatile變量不會引起線程上下文的切換和調度,在適合的場景下擁有更低的執行成本和更高的效率。
本文將從硬件層面詳細解讀volatile關鍵字如何保證變量在多線程之間的可見性,在此之前,有必要講解一下CPU緩存的相關知識。
CPU緩存
CPU緩存的出現主要是為了解決CPU運算速度與內存讀寫速度不匹配的矛盾,因為CPU運算速度要比內存讀寫速度快得多,舉個例子:
- 一次主內存的訪問通常在幾十到幾百個時鍾周期
- 一次L1高速緩存的讀寫只需要1~2個時鍾周期
- 一次L2高速緩存的讀寫也只需要數十個時鍾周期
這種訪問速度的顯著差異,導致CPU可能會花費很長時間等待數據到來或把數據寫入內存。
基於此,現在CPU大多數情況下讀寫都不會直接訪問內存(CPU都沒有連接到內存的管腳),取而代之的是CPU緩存,CPU緩存是位於CPU與內存之間的臨時存儲器,它的容量比內存小得多但是交換速度卻比內存快得多。而緩存中的數據是內存中的一小部分數據,但這一小部分是短時間內CPU即將訪問的,當CPU調用大量數據時,就可先從緩存中讀取,從而加快讀取速度。
按照讀取順序與CPU結合的緊密程度,CPU緩存可分為:
- 一級緩存:簡稱L1 Cache,位於CPU內核的旁邊,是與CPU結合最為緊密的CPU緩存。
- 二級緩存:簡稱L2 Cache,分內部和外部兩種芯片,內部芯片二級緩存運行速度與主頻相同,外部芯片二級緩存運行速度則只有主頻的一半。
- 三級緩存:簡稱L3 Cache,部分高端CPU才有。
每一級緩存中所存儲的數據全部都是下一級緩存中的一部分,這三種緩存的技術難度和制造成本是相對遞減的,所以其容量也相對遞增。
當CPU要讀取一個數據時,首先從一級緩存中查找,如果沒有再從二級緩存中查找,如果還是沒有再從三級緩存中或內存中查找。一般來說每級緩存的命中率大概都有80%左右,也就是說全部數據量的80%都可以在一級緩存中找到,只剩下20%的總數據量才需要從二級緩存、三級緩存或內存中讀取。
使用CPU緩存帶來的問題
用一張圖表示一下 CPU –> CPU緩存 –> 主內存 數據讀取之間的關系:
圖片來自網絡
當系統運行時,CPU執行計算的過程如下:
- 程序以及數據被加載到主內存
- 指令和數據被加載到CPU緩存
- CPU執行指令,把結果寫到高速緩存
- 高速緩存中的數據寫回主內存
如果服務器是單核CPU,那么這些步驟不會有任何的問題,但是如果服務器是多核CPU,那么問題來了,以Intel Core i7處理器的高速緩存概念模型為例(圖片來自《深入理解計算機系統》):
試想下面一種情況:
- 核0讀取了一個字節,根據局部性原理,它相鄰的字節同樣被被讀入核0的緩存
- 核3做了上面同樣的工作,這樣核0與核3的緩存擁有同樣的數據
- 核0修改了那個字節,被修改后,那個字節被寫回核0的緩存,但是該信息並沒有寫回主存
- 核3訪問該字節,由於核0並未將數據寫回主存,數據不同步
為了解決這一問題,CPU制造商規定了一個緩存一致性協議。
緩存一致性協議
每個CPU都有一級緩存,但是,我們卻無法保證每個CPU的一級緩存數據都是一樣的。 所以同一個程序,CPU進行切換的時候,切換前和切換后的數據可能會有不一致的情況。那么這個就是一個很大的問題了。 如何保證各個CPU緩存中的數據是一致的。就是CPU的緩存一致性問題。
總線鎖
一種處理一致性問題的辦法是使用Bus Locking(總線鎖)。當一個CPU對其緩存中的數據進行操作的時候,往總線中發送一個Lock信號。 這個時候,所有CPU收到這個信號之后就不操作自己緩存中的對應數據了,當操作結束,釋放鎖以后,所有的CPU就去內存中獲取最新數據更新。
但是用鎖的方式總是避不開性能問題。總線鎖總是會導致CPU的性能下降。所以出現另外一種維護CPU緩存一致性的方式,MESI。
MESI
MESI是保持一致性的協議。它的方法是在CPU緩存中保存一個標記位,這個標記位有四種狀態:
- M: Modify,修改緩存,當前CPU的緩存已經被修改了,即與內存中數據已經不一致了;
- E: Exclusive,獨占緩存,當前CPU的緩存和內存中數據保持一致,而且其他處理器並沒有可使用的緩存數據;
- S: Share,共享緩存,和內存保持一致的一份拷貝,多組緩存可以同時擁有針對同一內存地址的共享緩存段;
- I: Invalid,失效緩存,這個說明CPU中的緩存已經不能使用了。
CPU的讀取遵循下面幾點:
- 如果緩存狀態是I,那么就從內存中讀取,否則就從緩存中直接讀取。
- 如果緩存處於M或E的CPU讀取到其他CPU有讀操作,就把自己的緩存寫入到內存中,並將自己的狀態設置為S。
- 只有緩存狀態是M或E的時候,CPU才可以修改緩存中的數據,修改后,緩存狀態變為M。
這樣,每個CPU都遵循上面的方式則CPU的效率就提高上來了。
volatile保證可見性的底層原理
在X86處理器下通過工具獲取JIT編譯器生成的匯編指令來查看對volatile進行寫操作,CPU會做什么事情。
Java代碼如下:
instance = new Singleton(); //instance是volatile變量
轉變成匯編代碼如下:
0x01a3de1d: movb $0X0,0X1104800(%esi);
0x01a3de24: lock addl $0X0,(%esp);
有volatile修飾的共享變量進行寫操作時會多出第二行匯編代碼,該句代碼的意思是對原值加零,其中相加指令addl前有lock修飾。通過查IA-32架構軟件開發者手冊可知,lock前綴的指令在多核處理器下會引發兩件事情:
1、將當前處理器緩存行的數據寫回到系統內存。
Lock前綴指令導致在執行指令期間,聲言處理器的LOCK# 信號。在多處理器環境中,LOCK# 信號確保在聲言該信號期間,處理器可以獨占任何共享內存(因為它會鎖住總線,導致其他CPU不能訪問總線,也就不能訪問系統內存,在Intel486和Pentium處理器中都是這種策略)。但是,在最近的處理器里,LOCK# 信號一般不鎖總線,而是鎖緩存,因為鎖總線開銷的比較大。在P6和目前的處理器中,如果訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK# 信號。相反,它會鎖定這塊區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操作被稱為“緩存鎖定”,緩存一致性機制會阻止同時修改由兩個以上的處理器緩存的內存區域數據。
2、這個寫回內存的操作會使在其他CPU里緩存了該內存地址的數據無效。
IA-32處理器和Intel 64處理器使用MESI控制協議去維護內部緩存和其他處理器緩存的一致性。在多核處理器系統中進行操作的時候,IA-32和Intel 64處理器能嗅探其他處理器訪問系統內存和它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統內存和其他處理器的緩存的數據在總線上保持一致。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處於共享狀態,那么正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強行執行緩存行填充。