在多線程并發程式設計中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
一個有效的緩存行被寫入到不存在的記憶體區域。
java語言規範第三版中對volatile的定義如下: java程式設計語言允許線程通路共享變量,為了確定共享變量能被準确和一緻的更新,線程應該確定通過排他鎖單獨獲得這個變量。java語言提供了volatile,在某些情況下比鎖更加友善。如果一個字段被聲明成volatile,java線程記憶體模型確定所有線程看到這個變量的值是一緻的。
volatile變量修飾符如果使用恰當的話,它比synchronized的使用和執行成本會更低,因為它不會引起線程上下文的切換和排程。
那麼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處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體位址,而這個位址目前處理共享狀态,那麼正在嗅探的處理器将無效它的緩存行,在下次通路相同記憶體位址時,強制執行緩存行填充。
著名的java并發程式設計大師doug lea在jdk7的并發包裡新增一個隊列集合類linkedtransferqueue,他在使用volatile變量時,用一種追加位元組的方式來優化隊列出隊和入隊的性能。
追加位元組能優化性能?這種方式看起來很神奇,但如果深入了解處理器架構就能了解其中的奧秘。讓我們先來看看linkedtransferqueue這個類,它使用一個内部類類型來定義隊列的頭隊列(head)和尾節點(tail),而這個内部類paddedatomicreference相對于父類atomicreference隻做了一件事情,就将共享變量追加到64位元組。我們可以來計算下,一個對象的引用占4個位元組,它追加了15個變量共占60個位元組,再加上父類的value變量,一共64個位元組。
<a href="http://ifeve.com/volatile/#viewsource">檢視源代碼</a>
<code>01</code>
<code>/** head of the queue */</code>
<code>02</code>
<code>private</code> <code>transient</code> <code>final</code> <code>paddedatomicreference<qnode> head;</code>
<code>03</code>
<code>04</code>
<code>/** tail of the queue */</code>
<code>05</code>
<code>private</code> <code>transient</code> <code>final</code> <code>paddedatomicreference<qnode> tail;</code>
<code>06</code>
<code>07</code>
<code>static</code> <code>final</code> <code>class</code> <code>paddedatomicreference <t></code><code>extends</code> <code>atomicreference <t> {</code>
<code>08</code>
<code>09</code>
<code> </code><code>// enough padding for 64bytes with 4byte refs</code>
<code>10</code>
<code> </code><code>object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;</code>
<code>11</code>
<code>12</code>
<code> </code><code>paddedatomicreference(t r) {</code>
<code>13</code>
<code>14</code>
<code> </code><code>super</code><code>(r);</code>
<code>15</code>
<code>16</code>
<code> </code><code>}</code>
<code>17</code>
<code>18</code>
<code>}</code>
<code>19</code>
<code>20</code>
<code>public</code> <code>class</code> <code>atomicreference <v></code><code>implements</code> <code>java.io.serializable {</code>
<code>21</code>
<code>22</code>
<code> </code><code>private</code> <code>volatile</code> <code>v value;</code>
<code>23</code>
<code>24</code>
<code> </code><code>//省略其他代碼</code>
<code>25</code>
<code>26</code>
<code>}</code>
為什麼追加64位元組能夠提高并發程式設計的效率呢? 因為對于英特爾酷睿i7,酷睿, atom和netburst, core solo和pentium m處理器的l1,l2或l3緩存的高速緩存行是64個位元組寬,不支援部分填充緩存行,這意味着如果隊列的頭節點和尾節點都不足64位元組的話,處理器會将它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭尾節點,當一個處理器試圖修改頭接點時會将整個緩存行鎖定,那麼在緩存一緻性機制的作用下,會導緻其他處理器不能通路自己高速緩存中的尾節點,而隊列的入隊和出隊操作是需要不停修改頭接點和尾節點,是以在多處理器的情況下将會嚴重影響到隊列的入隊和出隊效率。doug lea使用追加到64位元組的方式來填滿高速緩沖區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定。
那麼是不是在使用volatile變量時都應該追加到64位元組呢?不是的。在兩種場景下不應該使用這種方式。第一:緩存行非64位元組寬的處理器,如p6系列和奔騰處理器,它們的l1和l2高速緩存行是32個位元組寬。第二:共享變量不會被頻繁的寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩沖區,這本身就會帶來一定的性能消耗,共享變量如果不被頻繁寫的話,鎖的幾率也非常小,就沒必要通過追加位元組的方式來避免互相鎖定。