1. 程式人生 > 實用技巧 >volatile實現原理與應用

volatile實現原理與應用

一、簡介

在多執行緒併發程式設計中synchronized和volatile都扮演著重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變數的“可見性”。
可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。
如果volatile變數修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因為它不會引起執行緒上下文的切換和排程。
本文將深入分析在硬體層面上Intel處理器是如何實現volatile的,通過深入分析幫助我們正確地使用volatile變數。

二、原理

1、volatile的定義與實現原理

Java語言規範第3版中對volatile的定義如下:
Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保通過排他鎖單獨獲得這個變數。
Java語言提供了volatile,在某些情況下比鎖要更加方便。如果一個欄位被宣告成volatile,Java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
在瞭解volatile實現原理之前,我們先來看下與其實現原理相關的CPU術語與說明。

volatile是如何來保證可見性的呢?讓我們在X86處理器下通過工具獲取JIT編譯器生成的彙編指令來檢視對volatile進行寫操作時,CPU會做什麼事情。

Java程式碼

instance = new Singleton(); // instance是volatile變數

轉變成彙編程式碼

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

有volatile變數修飾的共享變數進行寫操作的時候會多出第二行彙編程式碼,

通過查IA-32架構軟體開發者手冊可知,Lock字首的指令在多核處理器下會引發了兩件事情

  1)將當前處理器快取行的資料寫回到系統記憶體。
  2)這個寫回記憶體的操作會使在其他CPU裡快取了該記憶體地址的資料無效。

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。

如果對聲明瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。
但是,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。
所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,
每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,
就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。

下面來具體講解volatile的兩條實現原則。

  1)Lock字首指令會引起處理器快取回寫到記憶體。
  Lock字首指令導致在執行指令期間,聲言處理器的LOCK#訊號。
  在多處理器環境中,LOCK#訊號確保在聲言該訊號期間,處理器可以獨佔任何共享記憶體。
  但是,在最近的處理器裡,LOCK#訊號一般不鎖匯流排,而是鎖快取,畢竟鎖匯流排開銷的比較大。
  對於Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#訊號。
  但在P6和目前的處理器中,如果訪問的記憶體區域已經快取在處理器內部,則不會聲言LOCK#訊號。
  相反,它會鎖定這塊記憶體區域的快取並回寫到記憶體,並使用快取一致性機制來確保修改的原子性,此操作被稱為“快取鎖定”,
  快取一致性機制會阻止同時修改由兩個以上處理器快取的記憶體區域資料。

  2)一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。
  IA-32處理器和Intel 64處理器使用MESI(修改、獨佔、共享、無效)控制協議去維護內部快取和其他處理器快取的一致性。
  在多核處理器系統中進行操作的時候,IA-32和Intel 64處理器能嗅探其他處理器訪問系統記憶體和它們的內部快取。
  處理器使用嗅探技術保證它的內部快取、系統記憶體和其他處理器的快取的資料在總線上保持一致。
  例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體地址,而這個地址當前處於共享狀態,
  那麼正在嗅探的處理器將使它的快取行無效,在下次訪問相同記憶體地址時,強制執行快取行填充。

2.volatile的使用優化

著名的Java併發程式設計大師Doug lea在JDK 7的併發包裡新增一個佇列集合類Linked-TransferQueue,它在使用volatile變數時,
用一種追加位元組的方式來優化隊列出隊和入隊的效能。LinkedTransferQueue的程式碼如下。

/** 佇列中的頭部節點 */
private transient f?inal PaddedAtomicReference<QNode> head;
/** 佇列中的尾部節點 */
private transient f?inal PaddedAtomicReference<QNode> tail;
static f?inal class PaddedAtomicReference <T> extends AtomicReference T> {
// 使用很多4個位元組的引用追加到64個位元組
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;
// 省略其他程式碼

追加位元組能優化效能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧祕。
讓我們先來看看LinkedTransferQueue這個類,它使用一個內部類型別來定義佇列的頭節點(head)和尾節點(tail),
而這個內部類PaddedAtomicReference相對於父類AtomicReference只做了一件事情,就是將共享變數追加到64位元組。
我們可以來計算下,一個物件的引用佔4個位元組,它追加了15個變數(共佔60個位元組),再加上父類的value變數,一共64個位元組。

為什麼追加64位元組能夠提高併發程式設計的效率呢?
因為對於英特爾酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L1、L2或L3快取的快取記憶體行是64個位元組寬,
不支援部分填充快取行,這意味著,如果佇列的頭節點和尾節點都不足64位元組的話,處理器會將它們都讀到同一個快取記憶體行中,
在多處理器下每個處理器都會快取同樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個快取行鎖定,
那麼在快取一致性機制的作用下,會導致其他處理器不能訪問自己快取記憶體中的尾節點,
而佇列的入隊和出隊操作則需要不停修改頭節點和尾節點,所以在多處理器的情況下將會嚴重影響到佇列的入隊和出隊效率。
Doug lea使用追加到64位元組的方式來填滿高速緩衝區的快取行,避免頭節點和尾節點載入到同一個快取行,使頭、尾節點在修改時不會互相鎖定。

那麼是不是在使用volatile變數時都應該追加到64位元組呢?
不是的。在兩種場景下不應該使用這種方式。
·快取行非64位元組寬的處理器。如P6系列和奔騰處理器,它們的L1和L2快取記憶體行是32個位元組寬。
·共享變數不會被頻繁地寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,
如果共享變數不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。

不過這種追加位元組的方式在Java 7下可能不生效,因為Java 7變得更加智慧,它會淘汰或重新排列無用欄位,需要使用其他追加位元組的方式。