從JMM提及,做爲一名JAVA開發,特別在多線程編程實踐中,瞭解和熟悉JAVA內存模型是頗有必要的。剛開始接觸內存模型的時候,有不少概念很是陌生,好比happens-before,可見性,順序性等等。要理解這些關鍵詞,須要先對編譯器、處理器的知識有一些瞭解。
還有一些框架例如disruptor,在設計的時候就考慮了CPU的特色,充分發揮CPU的性能。要理解這類框架,也須要對處理器有必定了解。java
先看下面這個表格,一些場景下的延時,好比CPU執行一條指令大約是1納秒,從L1 cache獲取數據須要0.5納米,從主存中取數據須要100納秒等等。node
經典的RISC pipeline由如下幾步組成:golang
取指令編程
譯指令後端
執行數組
內存訪問緩存
寄存器回寫多線程
因爲訪問主存的延時和指令執行的延時不在一個數量級,因此CPU通常會使用訪問速度更快的緩存,現代處理器的緩存通常是分爲三級,下圖是通常CPU的緩存結構。每個CPU核共享L一、L2 Cache,全部的CPU核共享L3 Cache。架構
在JVM中,咱們都知道對象都是存在於內存堆中的,也就是主存中,而對於CPU來講,它並不關心程序中操做的對象,它只關心對某個內存塊的讀和寫,爲了讓讀寫速度更快,CPU會首先把數據從主存中的數據以cache line的粒度讀到CPU cache中,一個cache line通常是64 bytes。假設程序中讀取某一個int變量,CPU並非只從主存中讀取4個字節,而是會一次性讀取64個字節,而後放到cpu cache中。由於每每緊挨着的數據,更有可能在接下來會被使用到。好比遍歷一個數組,由於數組空間是連續的,因此並非每次取數組中的元素都要從主存中去拿,第一次從主存把數據放到cache line中,後續訪問的數據頗有可能已經在cache中了,併發
cache hit
CPU獲取的內存地址在cache中存在,叫作cache hit。
cache miss
若是CPU的訪問的內存地址不在L1 cache中,就叫作L1 cache miss,因爲訪問主存的速度遠遠慢於指令的執行速度,一旦發生cache miss,CPU就會在上一級cache中獲取,最差的狀況須要從主存中獲取。一旦要從主存中獲取數據,當前指令的執行相對來講就會顯得很是慢。
cache associativity
根據內存和cache的映射關係不一樣,有三種映射方式。
direct mapped
mapped方式查詢最快,由於只有一個坑,只須要比較一次。可是容易發生衝突。
n-way set associative
n-way associative是一種折中的方式,能夠有較高的緩存命中率,又不至於每次查詢比較慢。
full associative
只要cache沒有滿還能把主存中的數據放到cache中,可是查詢的時候須要全掃描,效率低。
其實direct mapped和full associative是n-way associative的特殊形式。
下面這張圖是我看到的最容易理解的資料。
如今的CPU通常都有多個核,咱們知道當某個核讀取某個內存地址時,會把這個內存地址附近的64個字節放到當前核的cache line中,假設此時另一個CPU核同時把這部分數據放到了對應的cache line中,這時候這64字節的數據實際上有三份,兩份在CPU cache中,一份在主存中。天然而然就要考慮到數據一致性的問題,如何保證在某一個核中的數據作了改動時,其它的數據副本也能感知到變化呢?是由緩存一致性協議來保證的。緩存一致性協議也叫做MESI協議。簡單的來講,就是CPU的cache line被標記爲如下四種狀態之一。
Modified
當前cache line中的數據被CPU修改過,而且只在當前覈對應的cache中,數據尚未被回寫到主存中,那麼當前cache line就處於Modified狀態。若是這個時候其它的核須要讀取該cache line中的,須要把當前cache line中的數據回寫到主存中去。一旦回寫到主存中去後,當前cache line的狀態變爲Shared
Exclusive
當前cache line只在一個核對應的cache中,數據和主存中的數據一致。若是有另一個核讀取當前cache line,則狀態變爲Shared,若是當前核修改了其中的數據,則變成Modified狀態。
Shared
若是cache line處於Shared狀態,則表示該cache line在其它覈對應的cache中也有副本,並且這兩個副本和主存中的數據一致。
Invalid
若是cache line處於Invalid狀態,則表示這塊cache line中的數據已經無效了,若是要讀取其中的數據的話,須要從新從主存中獲取。
只有cache line處於Exclusive或者Modified狀態時才能進行寫操做。若是處於Shared狀態,那麼要先廣播一個消息(Request For Ownership),invalidate其它覈對應的cache line。
若是cache line處於Modified狀態,那麼須要能探測到其它試圖讀取該cache line的操做。
若是cache line處於Shared狀態,它必須監聽其它cache的invalidate信息,一旦其它核修改了對應的cache line,其它cache 中對應的cache line須要變爲invalid狀態。
MESI協議中有兩個行爲效率會比較低,
當cache line狀態爲Invalid時,須要寫入數據。
把cache line的狀態變爲invalid
CPU經過store buffer和invalid queue來下降延時。
當在invalid狀態進行寫入時,首先會給其它CPU核發送invalid消息,而後把當前寫入的數據寫入到store buffer中。而後在某個時刻在真正的寫入到cache line中。因爲不是立刻寫入到cache line中,因此當前核若是要讀cache line中的數據,須要先掃描store buffer,同時其它CPU核是看不到當前核store buffer中的數據的。除非store buffer中的數據被刷到cache中。
對於invalid queue,當收到invalid消息時,cache line不會立刻變成invalid狀態,而是把消息寫入invalid queue中。和store buffer不一樣的是當前核是沒法掃描invalid queue的。
爲了保證數據的一致性,這就須要memory barrier了。store barrier會把store buffer中的數據刷到cache中,read barrier會執行invalid queue中的消息。
注意
要保證數據的一致性,僅僅有MESI協議還不夠,一般還須要memory barrier的配合。
memory barrier的做用有兩個
保證數據的可見性 咱們知道,內存中的數據除了在內存中的副本,還有可能在各個核的CPU中,當某個核修改了對應cache中的數據後,這時其它核中對應內存地址的數據還有主存中的數據就不是最新的了,其它核爲了可以讀取到最新的數據,須要執行memory barrier指令,把store buffer中的修改寫到主存中。
防止指令之間的重排序 前面講到一條指令的執行會分爲幾個步驟,也就是pipeline,爲了獲得更高的性能,編譯器或者處理器有可能會改變指令的執行順序,以此來提升指令執行的並行度。無論是編譯器仍是處理器的重排序,都要遵照as-if-serial語義。as-if-serial說的是,無論怎麼重排序,在單線程中執行這些指令,其結果應該是同樣的。在多線程的狀況下,須要memory barrier來保證總體的順序,不然會出現意想不到的結果。
不一樣的處理器架構的memory barrier也不太同樣,以Intel x86爲例,有三種memory barrier
store barrier
對應sfence指令
保證了sfence先後store指令的順序,防止重排序。
經過刷新store buffer保證了sfence以後的store指令全局可見以前,sfence以前的store要指令要先全局可見。
load barrier
對應lfence指令,
保證了lfence先後的load指令的順序,防止重排序。
刷新load buffer。
full barrier
對應mfence指令
保證了mfence先後的store和load指令的順序,防止重排序。
保證了mfence以後的store指令全局可見以前,mfence以前的store指令要先全局可見。
以java中的volatile爲例,volatile的語義有幾點:
volatile的操做是原子的
volatile的操做是全局可見的
在必定程度上防止重排序
通常是經過插入內存屏障或者具備屏障功能的其它指令(如lock指令)來保證上面的第二和第三點。
總結
內存屏障自己很是複雜,不一樣的處理器的實現也很不同,編譯期間和運行期間都有內存屏障,上面是以X86爲例,作了簡單的介紹。可是無論是哪一個平臺,他們都是解決兩個問題,一個是指令的排序,另外一個是全局可見性。
前面我講到,內存中的數據是以cache line爲單位從內存中讀到CPU cache中的,好比有兩個變量X,Y,在內存中他們倆很是近,那麼頗有可能在讀X的時候,Y也被放到了相同cache line中。假設Thread1須要不停的寫X,好比在一個循環中,而Thread2須要不停的寫Y,那麼在Thread1寫X的時候,須要Invalid其餘cache中對應的cache line,Thread2寫Y的時候也要作一樣的事情,這樣就會不停的碰到上面說過的MESI協議的兩個比較耗時的操做:
當cache line狀態爲Invalid時,須要寫入數據。
把cache line的狀態變爲invalid
會嚴重影響性能。
解決false sharing也比較簡單,作padding就能夠了。下面這段代碼是disruptor中Sequence的一段代碼,通常cache line是64 byte,long類型的value加上7個long作padding,正好是64byte,這樣當以Sequence[]的方式使用時,不一樣下標的Sequence對象就會落在不一樣的cache line中。
咱們知道CPU的核數量是有限的,通常是1-32核不等,而現代操做系統是多任務操做系統,同一時刻在運行的進程數量通常都會遠遠超過CPU的核數量。簡單的說就是並行運行的任務數量最多就是CPU的核數,可是併發運行的任務數量能夠有不少。打個比方,對於單核的CPU,若是不能併發運行多個任務的話,那麼全部任務都會是串行的,假設某個任務進行一次遠程調用,而遠程調用的時間比較長,那麼這樣的系統效率將會很是低,若是能併發執行的話,在一個任務等待的時候,操做系統能夠把CPU時間片分給其它其它任務運行,而前一個任務等待完畢後,操做系統再次調度CPU,從新讓它繼續運行,這樣對於使用者來講,感受就像是同時在運行多個任務。
context switch的開銷
保存和恢復context
那是否是併發運行的任務越多越好呢?答案固然是否認的,併發運行任務帶來的最大的缺點就是上下文切換(context switch)帶來的開銷。上下文(context)指的是當前任務運行時,在CPU寄存器,程序計數器中保存的狀態。每一次進行線程切換時,須要保存當前全部寄存器、程序計數器、棧指針等,等線程切換回來的時候又要對這些內容進行恢復。
污染CPU緩存
當頻繁的進行線程切換的時候,由於運行的任務不同了,對應的CPU cache中的數據也不同,當被阻塞的線程從新執行的時候,CPU cache中的內容極可能已經發生了變化,之前在緩存中的數據可能要從新從主存中加載。
所以,在系統設計的時候,應該儘可能避免沒必要要的上下文切換。好比nodejs、golang、actor model、netty等等這些併發模型,都減小了沒必要要的上下文切換。
關於阿里百川
阿里百川(baichuan.taobao.com)是阿里巴巴集團「雲」+「端」的核心戰略是阿里巴巴集團無線開放平臺,基於世界級的後端服務和成熟的商業組件,經過「技術、商業及大數據」的開放,爲移動創業者提供可快速搭建App、商業化APP並提高用戶體驗的解決方案;同時提供多元化的創業服務-物理空間、孵化運營、創業投資等,爲移動創業者提供全面保障。