聊聊併發(一)深入分析Volatile的實現原理
引言
在多執行緒併發程式設計中synchronized和Volatile都扮演著重要的角色,Volatile是輕量級的synchronized,它在多處理器開發中保證了共享變數的“可見性”。可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。它在某些情況下比synchronized的開銷更小,本文將深入分析在硬體層面上Inter處理器是如何實現Volatile的,通過深入分析能幫助我們正確的使用Volatile變數。
術語定義
術語 | 英文單詞 | 描述 |
共享變數 | 在多個執行緒之間能夠被共享的變數被稱為共享變數。共享變數包括所有的例項變數,靜態變數和陣列元素。他們都被存放在堆記憶體中,Volatile只作用於共享變數。 | |
記憶體屏障 | Memory Barriers | 是一組處理器指令,用於實現對記憶體操作的順序限制。 |
緩衝行 | Cache line | 快取中可以分配的最小儲存單位。處理器填寫快取線時會載入整個快取線,需要使用多個主記憶體讀週期。 |
原子操作 | Atomic operations | 不可中斷的一個或一系列操作。 |
快取行填充 | cache line fill | 當處理器識別到從記憶體中讀取運算元是可快取的,處理器讀取整個快取行到適當的快取(L1,L2,L3的或所有) |
快取命中 | cache hit | 如果進行快取記憶體行填充操作的記憶體位置仍然是下次處理器訪問的地址時,處理器從快取中讀取運算元,而不是從記憶體。 |
寫命中 | write hit | 當處理器將運算元寫回到一個記憶體快取的區域時,它首先會檢查這個快取的記憶體地址是否在快取行中,如果存在一個有效的快取行,則處理器將這個運算元寫回到快取,而不是寫回到記憶體,這個操作被稱為寫命中。 |
寫缺失 | write misses the cache | 一個有效的快取行被寫入到不存在的記憶體區域。 |
Volatile的官方定義
Java語言規範第三版中對volatile的定義如下: java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個欄位被宣告成volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
為什麼要使用Volatile
Volatile變數修飾符如果使用恰當的話,它比synchronized的使用和執行成本會更低,因為它不會引起執行緒上下文的切換和排程。
Volatile的實現原理
那麼Volatile是如何來保證可見性的呢?在x86處理器下通過工具獲取JIT編譯器生成的彙編指令來看看對Volatile進行寫操作CPU會做什麼事情。
Java程式碼: | instance = new Singleton();//instance是volatile變數 |
彙編程式碼: | 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp); |
有volatile變數修飾的共享變數進行寫操作的時候會多第二行彙編程式碼,通過查IA-32架構軟體開發者手冊可知,lock字首的指令在多核處理器下會引發了兩件事情。
- 將當前處理器快取行的資料會寫回到系統記憶體。
- 這個寫回記憶體的操作會引起在其他CPU裡快取了該記憶體地址的資料無效。
處理器為了提高處理速度,不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完之後不知道何時會寫到記憶體,如果對聲明瞭Volatile變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡。
這兩件事情在IA-32軟體開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述。
Lock字首指令會引起處理器快取回寫到記憶體。Lock字首指令導致在執行指令期間,聲言處理器的 LOCK# 訊號。在多處理器環境中,LOCK# 訊號確保在聲言該訊號期間,處理器可以獨佔使用任何共享記憶體。(因為它會鎖住匯流排,導致其他CPU不能訪問匯流排,不能訪問匯流排就意味著不能訪問系統記憶體),但是在最近的處理器裡,LOCK#訊號一般不鎖匯流排,而是鎖快取,畢竟鎖匯流排開銷比較大。在8.1.4章節有詳細說明鎖定操作對處理器快取的影響,對於Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#訊號。但在P6和最近的處理器中,如果訪問的記憶體區域已經快取在處理器內部,則不會聲言LOCK#訊號。相反地,它會鎖定這塊記憶體區域的快取並回寫到記憶體,並使用快取一致性機制來確保修改的原子性,此操作被稱為“快取鎖定”,快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料。
一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。IA-32處理器和Intel 64處理器使用MESI(修改,獨佔,共享,無效)控制協議去維護內部快取和其他處理器快取的一致性。在多核處理器系統中進行操作的時候,IA-32 和Intel 64處理器能嗅探其他處理器訪問系統記憶體和它們的內部快取。它們使用嗅探技術保證它的內部快取,系統記憶體和其他處理器的快取的資料在總線上保持一致。例如在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體地址,而這個地址當前處理共享狀態,那麼正在嗅探的處理器將無效它的快取行,在下次訪問相同記憶體地址時,強制執行快取行填充。
Volatile的使用優化
著名的Java併發程式設計大師Doug lea在JDK7的併發包裡新增一個佇列集合類LinkedTransferQueue,他在使用Volatile變數時,用一種追加位元組的方式來優化隊列出隊和入隊的效能。
追加位元組能優化效能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧祕。讓我們先來看看LinkedTransferQueue這個類,它使用一個內部類型別來定義佇列的頭佇列(Head)和尾節點(tail),而這個內部類PaddedAtomicReference相對於父類AtomicReference只做了一件事情,就將共享變數追加到64位元組。我們可以來計算下,一個物件的引用佔4個位元組,它追加了15個變數共佔60個位元組,再加上父類的Value變數,一共64個位元組。
/** head of the queue */ private transient final PaddedAtomicReference<QNode> head; /** tail of the queue */ private transient final PaddedAtomicReference<QNode> tail; static final class PaddedAtomicReference <T> extends AtomicReference <T> { // enough padding for 64bytes with 4byte refs Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; PaddedAtomicReference(T r) { super(r); } } public class AtomicReference <V> implements java.io.Serializable { private volatile V value; //省略其他程式碼 }
為什麼追加64位元組能夠提高併發程式設計的效率呢? 因為對於英特爾酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M處理器的L1,L2或L3快取的快取記憶體行是64個位元組寬,不支援部分填充快取行,這意味著如果佇列的頭節點和尾節點都不足64位元組的話,處理器會將它們都讀到同一個快取記憶體行中,在多處理器下每個處理器都會快取同樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個快取行鎖定,那麼在快取一致性機制的作用下,會導致其他處理器不能訪問自己快取記憶體中的尾節點,而佇列的入隊和出隊操作是需要不停修改頭接點和尾節點,所以在多處理器的情況下將會嚴重影響到佇列的入隊和出隊效率。Doug lea使用追加到64位元組的方式來填滿高速緩衝區的快取行,避免頭接點和尾節點載入到同一個快取行,使得頭尾節點在修改時不會互相鎖定。
那麼是不是在使用Volatile變數時都應該追加到64位元組呢?不是的。在兩種場景下不應該使用這種方式。第一:快取行非64位元組寬的處理器,如P6系列和奔騰處理器,它們的L1和L2快取記憶體行是32個位元組寬。第二:共享變數不會被頻繁的寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,共享變數如果不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。