从缓存行出发理解volatile变量、伪共享False sharing、disruptor
volatile關鍵字
當變量被某個線程A修改值之后,其它線程比如B若讀取此變量的話,立刻可以看到原來線程A修改后的值
?
注:普通變量與volatile變量的區別是volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前可以立即從內存刷新,即一個線程修改了某個變量的值,其它線程讀取的話肯定能看到新的值;
普通變量:
寫命中:當處理器將操作數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,如果不存在一個有效的緩存行,則處理器將這個操作數寫回到緩存,而不是寫回到內存,這個操作被稱為寫命中。
術語 | 英文單詞 | 描述 |
共享變量 | ? | 在多個線程之間能夠被共享的變量被稱為共享變量。共享變量包括所有的實例變量,靜態變量和數組元素。他們都被存放在堆內存中,Volatile只作用于共享變量。 |
內存屏障 | Memory Barriers | 是一組處理器指令,用于實現對內存操作的順序限制。 備注:?In the?Java Memory Model?a?volatile?field has a store barrier inserted after a write to it and a load barrier inserted before a read of it. |
緩沖行 | Cache line | 緩存中可以分配的最小存儲單位。處理器填寫緩存線時會加載整個緩存線,需要使用多個主內存讀周期。 |
原子操作 | Atomic operations | 不可中斷的一個或一系列操作。 |
緩存行填充 | cache line fill | 當處理器識別到從內存中讀取操作數是可緩存的,處理器讀取整個緩存行到適當的緩存(L1,L2,L3的或所有) |
緩存命中 | cache hit | 如果進行高速緩存行填充操作的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操作數,而不是從內存。 |
寫命中 | write hit | 當處理器將操作數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,如果不存在一個有效的緩存行,則處理器將這個操作數寫回到緩存,而不是寫回到內存,這個操作被稱為寫命中。 |
寫缺失 | write misses the cache | 一個有效的緩存行被寫入到不存在的內存區域。 |
單核CPU緩存結構
?
?
單核CPU緩存
多核CPU緩存
所謂緩存航就是緩存中可以分配的最小存儲單位。
處理器填寫緩存線時會加載整個緩存線,需要使用多個主內存讀周期。
下面降到偽緩存時會介紹,多核CPU、內存的緩存系統;
Information transfer between the cache and the memory?is in terms of complete cache lines, rather than individual bytes.?Thus if the program needs a particular byte, the entire cache line containing that byte is obtained from the memory.?For example, suppose that the cache of Figure 2 was being used and the program fetches the word (two bytes) at location 0004736. If none of the cache lines contain the 16 bytes stored in addresses 0004730 through 000473F, then these 16 bytes are transferred from the memory to one of the cache lines. Because of the spatial locality of the program, we expect that other values in the cache line thus loaded will be referenced in the near future.
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前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。但是就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存里把數據讀到處理器緩存里。
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個字節。
為什么追加64字節能夠提高并發編程的效率呢? 因為對于英特爾酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M處理器的L1,L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味著如果隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個緩存行鎖定,那么在緩存一致性機制的作用下,會導致其他處理器不能訪問自己高速緩存中的尾節點,而隊列的入隊和出隊操作是需要不停修改頭接點和尾節點,所以在多處理器的情況下將會嚴重影響到隊列的入隊和出隊效率。Doug lea使用追加到64字節的方式來填滿高速緩沖區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定。
那么是不是在使用Volatile變量時都應該追加到64字節呢?不是的。在兩種場景下不應該使用這種方式。第一:緩存行非64字節寬的處理器,如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。第二:共享變量不會被頻繁的寫。因為使用追加字節的方式需要處理器讀取更多的字節到高速緩沖區,這本身就會帶來一定的性能消耗,共享變量如果不被頻繁寫的話,鎖的幾率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。
| /** 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個字節寬。第二:共享變量不會被頻繁的寫。因為使用追加字節的方式需要處理器讀取更多的字節到高速緩沖區,這本身就會帶來一定的性能消耗,共享變量如果不被頻繁寫的話,鎖的幾率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。
?
Java并發編程實踐寫道:“一個理解volatile變量好的方法:想想它們的行為與SynchrosizedInteger類相似,只不過用get和set方法取代了對volatile變量的讀寫操作。然而訪問volatile變量的操作不會加鎖,也就不會引起線程的阻塞,這使volatile相對于synchronized而言,只是輕量級的同步機制”
?
false-sharing
?
Memory is stored within the cache system in units know as cache lines.? Cache lines are a power of 2 of contiguous bytes which are typically 32-256 in size.? The most common cache line size is 64 bytes.?? False sharing is a term which applies when threads unwittingly impact the performance of each other while modifying independent variables sharing the same cache line.? Write contention on cache lines is the single most limiting factor on achieving scalability for parallel threads of execution in an SMP system.? I’ve heard false sharing described as the silent performance killer because it is far from obvious when looking at code.
class {int x ,int y} ?x和y被放在同一個高速緩存區,如果一個線程修改x;那么另外一個線程修改y,必須等待x修改完成后才能實施。
?
L1 Cache(一級緩存)是CPU第一層高速緩存,分為數據緩存和指令緩存。內置的L1高速緩存的容量和結構對CPU的性能影響較大,不過高速緩沖存儲器均由靜態RAM組成,結構較復雜,在CPU管芯面積不能太大的情況下,L1級高速緩存的容量不可能做得太大。一般服務器CPU的L1緩存的容量通常在32—4096KB。
L2 Cache 都在CPU中
L3 Cache(三級緩存),分為兩種,早期的是外置,現在的都是內置的。而它的實際作用即是,L3緩存的應用可以進一步降低內存延遲,同時提升大數據量計算時處理器的性能。降低內存延遲和提升大數據量計算能力對游戲都很有幫助。而在服務器領域增加L3緩存在性能方面仍然有顯著的提升。比方具有較大L3緩存的配置利用物理內存會更有效,故它比較慢的磁盤I/O子系統可以處理更多的數據請求。具有較大L3緩存的處理器提供更有效的文件系統緩存行為及較短消息和處理器隊列長度。
從圖中可以看出為多核共享的
To achieve linear scalability with number of threads, we must ensure no two threads write to the same variable or cache line.? Two threads writing to the same variable can be tracked down at a code level.?? To be able to know if independent variables share the same cache line we need to know the memory layout, or we can get a tool to tell us.? Intel VTune is such a profiling tool.? In this article I’ll explain how memory is laid out for Java objects and how we can pad out our cache lines to avoid false sharing.
?
可見每個cpu核心或者線程都會可能往同一個緩存行寫數據;并且對共享變量,同時cpu核心會有各自的緩存行
雖然兩個線程修改各種獨立變量,但是因為這些獨立變量被放在同一個高速緩存區,性能就影響了。測試結果。
當多核CPU線程同時修改在同一個高速緩存行各自獨立的變量時,會不自不覺地影響性能,這就發生了偽共享False sharing,偽共享是性能的無聲殺手。
?
當多核CPU線程同時修改在同一個高速緩存行各自獨立的變量時,會不自不覺地影響性能,這就發生了偽共享False sharing,偽共享是性能的無聲殺手。
這里強調多核,是因為單核CPU模擬出的多線程不會嚴格意義上同時訪問緩存行,所以性能影響不大
解決方便是將高速緩存剩余的字節填充填滿(pad),確保不發生多個字段被擠入一個高速緩存區,下面測試結果圖就是和填充后性能比較。
實現字節填充的框架有 Disruptor,在RingBuffer中實現填充。關于Disruptor可見infoQ這個視頻,用1毫秒的延時得到100K+ TPS吞吐量,
JDK的ArrayQueue并行環境不見得是最快的,該視頻后面討論很多,讓人大跌眼鏡啊,開放源碼多有好處啊,別人能發現你不能發現的漏洞。
Disruptor
?
Disruptor沒有像JDK的LinkedBlockQueue等那樣使用鎖,針對CPU高速緩存進行了優化。
原來我們以為多個線程同時寫一個類的字段會發生爭奪,這是多線程基本原理,所以使用了鎖機制,保證這個共用字段(資源)能夠某個時刻只能一個線程寫,但是這樣做的壞處是:有可能發生死鎖。
比如1號線程先后訪問共享資源A和B;而2號線程先后訪問共享資源B和A,因為在資源A和資源B都有鎖,那么1號在訪問資源A時,資源A上鎖了,準備訪問資源B,但是無法訪問,因為與此同時;而2號線程在訪問資源B,資源B鎖著呢,正準備訪問資源A,發現資源A被1號線程鎖著呢,結果彼此無限等待彼此下去,死鎖類似邏輯上自指悖論。
所以,鎖是壞的,破壞性能,鎖是并發計算的大敵。
我們回到隊列上,一把一個隊列有至少兩個線程:生產者和消費者,這就具備了資源爭奪的前提,這兩個線程一般彼此守在隊列的進出兩端,表面上好像沒有訪問共享資源,實際上隊列存在兩個共享資源:隊列大小或指針.
除了共享資源寫操作上存在資源爭奪問題外,Disruptor的LMAX團隊發現Java在多核CPU情況下有偽共享問題:
CPU會把數據從內存加載到高速緩存中 ,這樣可以獲得更好的性能,高速緩存默認大小是64 Byte為一個區域,CPU機制限制只能一個CPU的一個線程訪問(寫)這個高速緩存區。
CPU在將主內存中數據加載到高速緩沖時,如果發現被加載的數據不足64字節,那么就會加載多個數據,以填滿自己的64字節,悲催就發生了,恰恰hotspot JVM中對象指針等大小都不會超過64字節,這樣一個高速緩沖中可能加載了兩個對象指針,一個CPU一個高速緩沖,雙核就是兩個CPU各自一個高速緩沖,那么兩個高速緩沖中各有兩個對象指針,都是指向相同的兩個對象。
因為一個CPU只能訪問(寫)自己高速緩存區中數據,相當于給這個數據加鎖,那么另外一個CPU同時訪問自己高速緩沖行中同樣數據時將會被鎖定不能訪問。
這就發生與鎖機制類似的性能陷進,Disruptor的解決辦法是填滿高速緩沖的64字節,不是對象指針等數據不夠64字節嗎?那么加一些字節填滿64字節,這樣CPU將數據加載到高速緩沖時,就只能加載一個了,剛剛好啊。
所以,盡管兩個線程是在寫兩個不同的字段值,也會因為雙核CPU底層機制發生偽裝的共享,并沒有真正共享,其實還是排他性的獨享。
現在我們大概知道RingBuffer是個什么東東了:
1.ring buffer是一個大的數組.
2.RingBuffer里所有指針都是Java longs (64字節) 不斷永遠向前計數,如后面圖,不斷在圓環中循環。
3.RingBuffer只有當前序列號,沒有終點序列號,其中數據不會被取出后消除,這樣以便實現從過去某個序列號到當前序列號的重放,這樣當消費者說沒有接受到生產者發送的消息,生產者還可以再次發送,這點是一種原子性的“事務”機制。
This new pattern is an ideal foundation for any asynchronous event processing architecture where high-throughput and low-latency is required.
?
Concurrent execution of code is about two things, mutual exclusion and visibility of change.
Read and write operations require that all changes are made visible to other threads. However only contended write operations require the mutual exclusion of the changes.
?
Locks provide mutual exclusion and ensure that the visibility of change occurs in an ordered manner. Locks are incredibly expensive because they require arbitration when contended.
?
We will illustrate the cost of locks with a simple demonstration. The focus of this experiment is to call a function which increments a 64-bit counter in a loop 500 million times. This can be executed by a single thread on a 2.4Ghz Intel Westmere EP in just 300ms if written in Java. The language is unimportant to this experiment and results will be similar across all languages with the same basic primitives.
Once a lock is introduced to provide mutual exclusion, even when the lock is as yet un-contended, the cost goes up significantly. The cost increases again, by orders of magnitude, when two or more threads begin to contend. The results of this simple experiment are shown in the table below
?
However CAS operations are not free of cost. The processor must lock its instruction pipeline to ensure atomicity and employ a memory barrier to make the changes visible to other threads. CAS operations are available in Java by using the java.util.concurrent.Atomic* classes.
The processors need only guarantee that program logic produces the same results regardless of execution order.
?
barriter:make the memory state within a processor visible to other processors.
disruptor 是為了解決消費者大于生產者的問題
參考
?
偽共享 :http://mechanical-sympathy.blogspot.com/2011/07/false-sharing.html
http://www.jdon.com/jivejdon/thread/42451#23138636
volatile:http://www.infoq.com/cn/articles/ftf-java-volatile
緩存行:http://ecee.colorado.edu/~ecen2120/Manual/caches/cache.html
disruptor:http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-why-its-so-fast_22.html
源碼:http://code.google.com/p/disruptor/
memory barrier 屏障:http://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html
總結
以上是生活随笔為你收集整理的从缓存行出发理解volatile变量、伪共享False sharing、disruptor的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: HBase最佳实践-读性能优化策略
- 下一篇: JVM调优系列:(二)JVM运行时数据区