摘要:并發(fā)編程的挑戰(zhàn)并發(fā)編程的目的是為了讓程序運行的更快,但是,并不是啟動更多的線程就能讓程序最大限度的并發(fā)執(zhí)行。的實現(xiàn)原理與應用在多線程并發(fā)編程中一直是元老級角色,很多人都會稱呼它為重量級鎖。
并發(fā)編程的挑戰(zhàn)
并發(fā)編程的目的是為了讓程序運行的更快,但是,并不是啟動更多的線程就能讓程序最大限度的并發(fā)執(zhí)行。如果希望通過多線程執(zhí)行任務讓程序運行的更快,會面臨非常多的挑戰(zhàn):
(1)上下文切換
(2)死鎖
(3)資源限制(硬件和軟件)
即使是單核處理器也支持多線程執(zhí)行代碼,CPU通過給每個線程分配CPU時間片來實現(xiàn)這個機制。時間片一般只有幾十毫秒(ms)。
CPU通過時間片分配算法來循環(huán)執(zhí)行任務,當前任務執(zhí)行一個時間片后會切換到下一個任務。但是,在切換前會保存上一個任務的狀態(tài),以便下次切換回這個任務時,可以再加載這個任務的狀態(tài)。所以任務從保存到再加載的過程就是一次上下文切換。上下文切換會影響多線程執(zhí)行的速度。
使用Lmbench3可以測量上下文切換的時長。
使用vmstat可以測量上下文切換的次數(shù)。
vmstat 1 :測試一秒鐘上下文切換的次數(shù)。
CS(Context Switch)表示上下文切換的次數(shù)。
如何減少上下文切換?
(1)無鎖并發(fā)編程(將數(shù)據(jù)的ID按照Hash算法取模分段,不同線程處理不同段的數(shù)據(jù))
(2)CAS算法(Java的Atomic包使用CAS算法來更新數(shù)據(jù),而不需要枷鎖)
(3)使用最少線程(避免創(chuàng)建不需要的線程,比如任務很少,卻創(chuàng)建了很多線程,導致大量線程處于等待狀態(tài))
(4)協(xié)程(在單線程里實現(xiàn)多任務的調度,并在單線程里維護多個任務間的切換)
實戰(zhàn):減少上下文切換?
通過減少線上大量WAITING的線程,來減少上下文切換次數(shù)。
第一步:用jstack命令dump線程信息,看看pid為3117的進程里的線程都在做什么。
/java/bin/jstack 31177 > /home/dump17
第二步:統(tǒng)計所有線程分別處于什么狀態(tài),發(fā)現(xiàn)300多個線程處于WAITING狀態(tài)。
grep java.lang.Thread.State dump17 | awk "{print $2$3$4$5}" | sort | uniq -c
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
51 TIMED_WAITING(sleeping)
305 WAITING(onobjectmonitor)
3 TIMED_WAITING(parking)
第三步:打開dump文件查看處于WAITING(onobjectmonitor)的線程在做什么。發(fā)現(xiàn)這些線程基本全是JBOSS的工作線程,說明JBOSS線程池里線程接收的任務太少,大量線程都閑著。
第四步:減少JBOSS的工作線程數(shù),找到JBOSS線程池配置信息,將maxThreads降到100。
第五步: 重啟,發(fā)現(xiàn)WAITING減少了175個。
一旦出現(xiàn)死鎖,業(yè)務是可感知的,因為不能繼續(xù)提供服務了,那么只能通過dump線程查看到底哪個線程出現(xiàn)了問題。
避免死鎖的幾個常見方法:
(1)避免一個線程同時獲取多個鎖
(2)避免一個線程在鎖內同時占用多個資源,盡量保證每個鎖只占用一個資源。
(3)嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
(3)對于數(shù)據(jù)庫鎖,加鎖和解鎖必須在一個數(shù)據(jù)庫連接里,否則會出現(xiàn)解鎖失敗的情況。
資源限制是指在進行并發(fā)編程時,程序的執(zhí)行速度受限于計算機硬件資源或軟件資源。
硬件資源限制:帶寬,硬盤讀寫速度,CPU處理速度。
軟件資源限制:數(shù)據(jù)庫的連接數(shù)和socket連接數(shù)。
對于java開發(fā)工程師而言,強烈建議多使用JDK并發(fā)包提供的并發(fā)容器和工具類來解決并發(fā)問題,以為這些類都已經通過了充分的測試和優(yōu)化,均可解決本章提到的幾個挑戰(zhàn)。
Java中所使用的并發(fā)機制依賴于JVM的實現(xiàn)和CPU的指令。
Java代碼——>Java字節(jié)碼——>JVM——>匯編指令——>CPU上執(zhí)行。
Volatile的應用
可見性:當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。
在多線程并發(fā)編程中synchronized和volatile都扮演著重要的角色,volatile是輕量級的synchronized,它在多處理器開發(fā)中保證了共享變量的"可見性"。
volatile比synchronized的使用和執(zhí)行成本更低,以為它不會引起線程上下文的切換和調度。
volatile定義:
Java語言規(guī)范第3版中對volatile的定義如下:Java編程語言允許線程訪問共享變量,為了確保共享變量能被準確和一致地更新,線程應該確保通過排它鎖多帶帶獲得這個變量。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個字段被聲明稱volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。
術語 | 描述 |
---|---|
內存屏障 | 是一組處理器指令,用于實現(xiàn)對內存操作的順序限制 |
原子操作 | 不可中斷的一個或一些列操作 |
緩存行填充 | 當處理器識別到從內存中讀取的操作數(shù)是可緩存的,處理器讀取整個高速緩存行到適當?shù)木彺妫↙1,L2,L3的或所有) |
緩存命中 | 如果進行高速緩存行填充操作的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操作數(shù),而不是從內存讀取 |
寫命中 | 當處理器將操作數(shù)寫回到一個內存緩存的區(qū)域時,它首先會檢查這個緩存的內存地址是否在緩存行中,如果存在一個有效的緩存行,則處理器將這個操作數(shù)寫回到緩存,而不是寫回到內存,這個操作被稱為寫命中。 |
寫缺失 | 一個有效的緩存行被寫入到不存在的內存區(qū)域。 |
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架
構軟件開發(fā)者手冊可知,Lock前綴的指令在多核處理器下會引發(fā)了兩件事情。
1)將當前處理器緩存行的數(shù)據(jù)寫回到系統(tǒng)內存。
2)這個寫回內存的操作會使在其他CPU里緩存了該內存地址的數(shù)據(jù)無效。
為了提高處理速度,處理器不直接和內存進行通信,而是先將系統(tǒng)內存的數(shù)據(jù)讀到內部
緩存(L1,L2或其他)后再進行操作,但操作完不知道何時會寫到內存。如果對聲明了volatile的變量進行寫操作,JVM就會向處理器發(fā)送一條Lock前綴的指令,將這個變量所在緩存行的數(shù)據(jù)寫回到系統(tǒng)內存。但是,就算寫回到內存,如果其他處理器緩存的值還是舊的,再執(zhí)行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的緩存是一致的,就會實現(xiàn)緩存一致性協(xié)議,每個處理器通過嗅探在總線上傳播的數(shù)據(jù)來檢查自己緩存的值是不是過期了,當
處理器發(fā)現(xiàn)自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀
態(tài),當處理器對這個數(shù)據(jù)進行修改操作的時候,會重新從系統(tǒng)內存中把數(shù)據(jù)讀到處理器緩存
里。
下面來具體講解volatile的兩條實現(xiàn)原則
1)Lock前綴指令會引起處理器緩存回寫到內存。Lock前綴指令導致在執(zhí)行指令期間,聲
言處理器的LOCK#信號。在多處理器環(huán)境中,LOCK#信號確保在聲言該信號期間,處理器可以
獨占任何共享內存。但是,在最近的處理器里,LOCK#信號一般不鎖總線,而是鎖緩存,畢
竟鎖總線開銷的比較大。在8.1.4節(jié)有詳細說明鎖定操作對處理器緩存的影響,對于Intel486和
Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#信號。但在P6和目前的處理器中,如果訪問的內存區(qū)域已經緩存在處理器內部,則不會聲言LOCK#信號。相反,它會鎖定這塊內存區(qū)域的緩存并回寫到內存,并使用緩存一致性機制來確保修改的原子性,此操作被稱為“緩存鎖
定”,緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區(qū)域數(shù)據(jù)。
2)一個處理器的緩存回寫到內存會導致其他處理器的緩存無效。IA-32處理器和Intel 64處
理器使用MESI(修改、獨占、共享、無效)控制協(xié)議去維護內部緩存和其他處理器緩存的一致
性。在多核處理器系統(tǒng)中進行操作的時候,IA-32和Intel 64處理器能嗅探其他處理器訪問系統(tǒng)內存和它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統(tǒng)內存和其他處理器的緩存的數(shù)據(jù)在總線上保持一致。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處于共享狀態(tài),那么正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執(zhí)行緩存行填充。
在多線程并發(fā)編程中synchronized一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,隨著Java SE 1.6對synchronized進行了各種優(yōu)化之后,有些情況下它就并不那么重了。本文詳細介紹Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。
先來看下利用synchronized實現(xiàn)同步的基礎:Java中的每一個對象都可以作為鎖。具體表現(xiàn)
為以下3種形式。
對于普通同步方法,鎖是當前實例對象。
對于靜態(tài)同步方法,鎖是當前類的Class對象。
對于同步方法塊,鎖是Synchonized括號里配置的對象。
當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。
那么鎖到底存在哪里呢?鎖里面會存儲什么信息呢?
從JVM規(guī)范中可以看到Synchonized在JVM里的實現(xiàn)原理,JVM基于進入和退出Monitor對象來實現(xiàn)方法同步和代碼塊同步,但兩者的實現(xiàn)細節(jié)不一樣。代碼塊同步是使用monitorenter和monitorexit指令實現(xiàn)的,而方法同步是使用另外一種方式實現(xiàn)的,細節(jié)在JVM規(guī)范里并沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現(xiàn)。
monitorenter指令是在編譯后插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯(lián),當且一個monitor被持有后,它將處于鎖定狀態(tài)。線程執(zhí)行monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。
Java對象頭
synchronized用的鎖是存在Java對象頭里的。
鎖的升級與對比
Java SE 1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有4種狀態(tài),級別從低到高依次是:無鎖狀態(tài)、偏向鎖狀態(tài)、輕量級鎖狀態(tài)和重量級鎖狀態(tài),這幾個狀態(tài)會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率,下文會詳細分析。
1.偏向鎖
HotSpot 的作者經過研究發(fā)現(xiàn),大多數(shù)情況下,鎖不僅不存在多線程競爭,而且總是由同
一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊并
獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出
同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否
存儲著指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需
要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
(1)偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現(xiàn)才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執(zhí)行的字節(jié)碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程不處于活動狀態(tài),則將對象頭設置成無鎖狀態(tài);如果線程仍然活著,擁有偏向鎖的棧會被執(zhí)行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線程。
(2)偏向鎖的撤銷
偏向鎖在Java 6和Java 7里是默認啟用的,但是它在應用程序啟動幾秒鐘之后才激活,如有必要可以使用JVM參數(shù)來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序里所有的鎖通常情況下處于競爭狀態(tài),可以通過JVM參數(shù)關閉偏向鎖:XX:UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態(tài)。
2.輕量級鎖
(1)輕量級鎖加鎖
線程在執(zhí)行同步塊之前,JVM會先在當前線程的棧楨中創(chuàng)建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
(2)輕量級鎖解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發(fā)生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級
成重量級鎖,就不會再恢復到輕量級鎖狀態(tài)。當鎖處于這個狀態(tài)下,其他線程試圖獲取鎖時,
都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪
的奪鎖之爭。
3.鎖的優(yōu)缺點對比
鎖 | 優(yōu)點 | 缺點 | 使用場景 | |
---|---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法相比僅存在納秒級的差距 | 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 適用于只有一個線程訪問同步塊場景 | |
輕量級鎖 | 競爭的線程不會阻塞,提高了程序的響應速度 | 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU | 追求響應時間,同步塊執(zhí)行速度非常快 | |
重量級鎖 | 線程競爭不適用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢 | 最求吞吐量,同步塊執(zhí)行速度較慢 |
原子操作的實現(xiàn)原理
原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意
為“不可被中斷的一個或一系列操作”。在多處理器上實現(xiàn)原子操作就變得有點復雜。讓我們
一起來聊一聊在Intel處理器和Java里是如何實現(xiàn)原子操作的。
1.術語定義
術語名稱 | 英文 | 解釋 | |
---|---|---|---|
緩存行 | Cache line | 緩存的最小操作單位 | |
比較并交換 | Compare And Swap | CAS操作需要輸入兩個數(shù)值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發(fā)生變化,如果沒有發(fā)生變化,才交換成新值,發(fā)生了變化則不交換 | |
CPU流水線 | CPU pipeline | CPU流水線的工作方式就像工業(yè)生產上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然后將一個X86指令分成5~6步后再由這些電路單元分別執(zhí)行,這樣就能實現(xiàn)在一個CPU時鐘周期完成一條指令,因此提高CPU的運算速度 | |
內存順序沖突 | Memory order violation | 內存順序沖突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現(xiàn)這個內存順序沖突時,CPU必須清空流水線 |
2.處理器如何實現(xiàn)原子操作
32位IA-32處理器使用基于對緩存加鎖或總線加鎖的方式來實現(xiàn)多處理器之間的原子操作。首先處理器會自動保證基本的內存操作的原子性。處理器保證從系統(tǒng)內存中讀取或者寫入一個字節(jié)是原子的,意思是當一個處理器讀取一個字節(jié)時,其他處理器不能訪問這個字節(jié)的內存地址。Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行里進行16/32/64位的操作是原子的,但是復雜的內存操作處理器是不能自動保證其原子性的,比如跨總線寬度、跨多個緩存行和跨頁表的訪問。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證復雜內存操作的原子性。
(1)使用總線鎖保證原子性
第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那么共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之后共享變量的值會和期望的不一致。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。
原因可能是多個處理器同時從各自的緩存中讀取變量i,分別進行加1操作,然后分別寫入系統(tǒng)內存中。那么,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內存地址的緩存。
處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那么該處理器可以獨占共享內存。
(2)使用緩存鎖保證原子性
第二個機制是通過緩存鎖定來保證原子性。在同一時刻,我們只需保證對某個內存地址的操作是原子性即可,但總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數(shù)據(jù),所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優(yōu)化。
頻繁使用的內存會緩存在處理器的L1、L2和L3高速緩存里,那么原子操作就可以直接在處理器內部緩存中進行,并不需要聲明總線鎖,在Pentium 6和目前的處理器中可以使用“緩存鎖定”的方式來實現(xiàn)復雜的原子性。所謂“緩存鎖定”是指內存區(qū)域如果被緩存在處理器的緩存行中,并且在Lock操作期間被鎖定,那么當它執(zhí)行鎖操作回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,并允許它的緩存一致性機制來保證操作的原子性,因為緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區(qū)域數(shù)據(jù),當其他處理器回寫已被鎖定的緩存行的數(shù)據(jù)時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那么CPU2就不能同時緩存i的緩存行。
但是有兩種情況下處理器不會使用緩存鎖定。
第一種情況是:當操作的數(shù)據(jù)不能被緩存在處理器內部,或操作的數(shù)據(jù)跨多個緩存行(cache line)時,則處理器會調用總線鎖定。
第二種情況是:有些處理器不支持緩存鎖定。對于Intel 486和Pentium處理器,就算鎖定的內存區(qū)域在處理器的緩存行中也會調用總線鎖定。
針對以上兩個機制,我們通過Intel處理器提供了很多Lock前綴的指令來實現(xiàn)。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些操作數(shù)和邏輯指令(如ADD、OR)等,被這些指令操作的內存區(qū)域就會加鎖,導致其他處理器不能同時訪問它。
3.Java如何實現(xiàn)原子操作
在Java中可以通過鎖和循環(huán)CAS的方式來實現(xiàn)原子操作。
(1)使用循環(huán)CAS實現(xiàn)原子操作
JVM中的CAS操作正是利用了處理器提供的CMPXCHG指令實現(xiàn)的。自旋CAS實現(xiàn)的基本思路就是循環(huán)進行CAS操作直到成功為止。
(2)CAS實現(xiàn)原子操作的三大問題
在Java并發(fā)包中有一些并發(fā)框架也使用了自旋CAS的方式來實現(xiàn)原子操作比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三
大問題。ABA問題,循環(huán)時間長開銷大,以及只能保證一個共享變量的原子操作。
1)ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發(fā)生變化,如果沒有發(fā)生變化
則更新,但是如果一個值原來是A,變成了B,又變成了A,那么使用CAS進行檢查時會發(fā)現(xiàn)它
的值沒有發(fā)生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面
追加上版本號,每次變量更新的時候把版本號加1,那么A→B→A就會變成1A→2B→3A。從
Java 1.5開始,JDK的Atomic包里提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等于預期引用,并且檢查當前標志是否等于預期標志,如果全部相等,則以原子方式將該引用和該標志的值設置為給定的更新值。
2)循環(huán)時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執(zhí)行開銷。如果JVM能支持處理器提供的pause指令,那么效率會有一定的提升。pause指令有兩個作用:第一,它可以延遲流水線執(zhí)行指令(de-pipeline),使CPU不會消耗過多的執(zhí)行資源,延遲的時間取決于具體實現(xiàn)的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出循環(huán)的時候因內存順序沖突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush)從而提高CPU的執(zhí)行效率。
3)只能保證一個共享變量的原子操作。當對一個共享變量執(zhí)行操作時,我們可以使用循環(huán)CAS的方式來保證原子操作,但是對多個共享變量操作時,循環(huán)CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如,有兩個共享變量i=2,j=a,合并一下ij=2a,然后用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象里來進行CAS操作。
(3)使用鎖機制實現(xiàn)原子操作
鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區(qū)域。JVM內部實現(xiàn)了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現(xiàn)鎖的方式都用了循環(huán)CAS,即當一個線程想進入同步塊的時候使用循環(huán)CAS的方式來獲取鎖,當它退出同步塊的時候使用循環(huán)CAS釋放鎖。
Java線程之間的通信對程序員完全透明,內存可見性問題很容易困擾Java程序員。
Java內存模型的基礎
在并發(fā)編程中,需要處理兩個關鍵問題:線程之間如何通信及線程之間如何同步(這里的線程是指并發(fā)執(zhí)行的活動實體)。通信是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。
在共享內存的并發(fā)模型里,線程之間共享程序的公共狀態(tài),通過寫-讀內存中的公共狀態(tài)進行隱式通信。在消息傳遞的并發(fā)模型里,線程之間沒有公共狀態(tài),線程之間必須通過發(fā)送消息來顯式進行通信。
同步是指程序中用于控制不同線程間操作發(fā)生相對順序的機制。在共享內存并發(fā)模型里,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執(zhí)行。在消息傳遞的并發(fā)模型里,由于消息的發(fā)送必須在消息的接收之前,因此同步是隱式進行的。
Java的并發(fā)采用的是共享內存模型,Java線程之間的通信總是隱式進行,整個通信過程對程序員完全透明。如果編寫多線程程序的Java程序員不理解隱式進行的線程之間通信的工作機制,很可能會遇到各種奇怪的內存可見性問題。
Java內存模型的抽象結構
在Java中,所有實例域、靜態(tài)域和數(shù)組元素都存儲在堆內存中,堆內存在線程之間共享(本章用“共享變量”這個術語代指實例域,靜態(tài)域和數(shù)組元素)。局部變量(Local Variables),方法定義參數(shù)(Java語言規(guī)范稱之為Formal Method Parameters)和異常處理器參數(shù)(Exception Handler Parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存、寫緩沖區(qū)、寄存器以及其他的硬件和編譯器優(yōu)化。Java內存模型的抽象示意如圖3-1所示。
從圖3-1來看,如果線程A與線程B之間要通信的話,必須要經歷下面2個步驟。
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A之前已更新過的共享變量。
下面通過示意圖(見圖3-2)來說明這兩個步驟。
如圖3-2所示,本地內存A和本地內存B由主內存中共享變量x的副本。假設初始時,這3個內存中的x值都為0。線程A在執(zhí)行時,把更新后的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改后的x值刷新到主內存中,此時主內存中的x值變?yōu)榱?。隨后,線程B到主內存中去讀取線程A更新后的x值,此時線程B的本地內存的x值也變?yōu)榱?。
從整體來看,這兩個步驟實質上是線程A在向線程B發(fā)送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為Java程序員提供內存可見性保證。
從源代碼到指令序列的重排序
在執(zhí)行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。
1)編譯器優(yōu)化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執(zhí)行順序。
2)指令級并行的重排序。現(xiàn)代處理器采用了指令級并行技術(Instruction-Level
Parallelism,ILP)來將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性,處理器可以改變語句對應
機器指令的執(zhí)行順序。
3)內存系統(tǒng)的重排序。由于處理器使用緩存和讀/寫緩沖區(qū),這使得加載和存儲操作看上去可能是在亂序執(zhí)行。
從Java源代碼到最終實際執(zhí)行的指令序列,會分別經歷下面3種重排序,如圖3-3所示。
上述的1屬于編譯器重排序,2和3屬于處理器重排序。這些重排序可能會導致多線程程序出現(xiàn)內存可見性問題。對于編譯器,JMM的編譯器重排序規(guī)則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對于處理器重排序,JMM的處理器重排序規(guī)則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之為Memory Fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序。
JMM屬于語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
并發(fā)編程模型的分類
為了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分為4類。
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現(xiàn)代的多處
理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。執(zhí)行該屏障開銷會很昂
貴,因為當前處理器通常要把寫緩沖區(qū)中的數(shù)據(jù)全部刷新到內存中(Buffer Fully Flush)。
happens-before簡介
JSR-133使用happens-before的概念來闡述操作之間的內存可見性。在JMM中,如果一個操作執(zhí)行的結果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關系。這里提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。
與程序員密切相關的happens-before規(guī)則如下。
程序順序規(guī)則:一個線程中的每個操作,happens-before于該線程中的任意后續(xù)操作。
監(jiān)視器鎖規(guī)則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
volatile變量規(guī)則:對一個volatile域的寫,happens-before于任意后續(xù)對這個volatile域的
讀。
傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
注意兩個操作之間具有happens-before關系,并不意味著前一個操作必須要在后一個操作之前執(zhí)行!happens-before僅僅要求前一個操作(執(zhí)行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)。happens-before的定義很微妙,后文會具體說明happens-before為什么要這么定義。
happens-before與JMM的關系如圖3-5所示。
如圖3-5所示,一個happens-before規(guī)則對應于一個或多個編譯器和處理器重排序規(guī)則。對于Java程序員來說,happens-before規(guī)則簡單易懂,它避免Java程序員為了理解JMM提供的內存
可見性保證而去學習復雜的重排序規(guī)則以及這些規(guī)則的具體實現(xiàn)方法。
重排序
重排序是指編譯器和處理器為了優(yōu)化程序性能而對指令序列進行重新排序的一種手段。
數(shù)據(jù)依賴性
如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數(shù)據(jù)依賴性。數(shù)據(jù)依賴分為下列3種類型,如表3-4所示。
名稱 | 示例代碼 | 說明 |
---|---|---|
寫后讀 | a=1;b=a; | 寫一個變量之后,再讀這個 位置 |
寫后寫 | a=1;a=2; | 寫一個變量之后,再寫這個變量 |
讀后寫 | a=b;b=1; | 讀一個變量之后,再寫這個變量 |
上面3種情況,只要重排序兩個操作的執(zhí)行順序,程序的執(zhí)行結果就會被改變。
前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數(shù)據(jù)依賴性,編譯器和處理器不會改變存在數(shù)據(jù)依賴關系的兩個操作的執(zhí)行順序。
這里所說的數(shù)據(jù)依賴性僅針對單個處理器中執(zhí)行的指令序列和單個線程中執(zhí)行的操作,不同處理器之間和不同線程之間的數(shù)據(jù)依賴性不被編譯器和處理器考慮。
as-if-serial語義
as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)
程序的執(zhí)行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關系的操作做重排序,因為這種重排序會改變執(zhí)行結果。但是,如果操作之間不存在數(shù)據(jù)依賴關系,這些操作就可能被編譯器和處理器重排序。為了具體說明,請看下面計算圓面積的代碼示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi r r; // C
A和C之間存在數(shù)據(jù)依賴關系,同時B和C之間也存在數(shù)據(jù)依賴關系。因此在最終執(zhí)行的指令序列中,C不能被重排序到A和B的前面。但A和B之間沒有數(shù)據(jù)依賴關系,編譯器和處理器可以重排序A和B之間的執(zhí)行順序。
程序順序規(guī)則
根據(jù)happens-before的程序順序規(guī)則,上面計算圓的面積的示例代碼存在3個happens-before關系。
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
這里A happens-before B,但實際執(zhí)行時B卻可以排在A之前執(zhí)行(看上面的重排序后的執(zhí)
行順序)。如果A happens-before B,JMM并不要求A一定要在B之前執(zhí)行。JMM僅僅要求前一個操作(執(zhí)行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前。這里操作A的執(zhí)行結果不需要對操作B可見;而且重排序操作A和操作B后的執(zhí)行結果,與操作A和操作B按happens-before順序執(zhí)行的結果一致。在這種情況下,JMM會認為這種重排序并不非法(not illegal),JMM允許這種重排序。
在計算機中,軟件技術和硬件技術有一個共同的目標:在不改變程序執(zhí)行結果的前提下,盡可能提高并行度。編譯器和處理器遵從這一目標,從happens-before的定義我們可以看出,JMM同樣遵從這一目標。
重排序對多線程的影響
class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } Public void reader() { if (flag) { // 3 int i = a * a; // 4 …… } } }
flag變量是個標記,用來標識變量a是否已被寫入。這里假設有兩個線程A和B,A首先執(zhí)行
writer()方法,隨后B線程接著執(zhí)行reader()方法。線程B在執(zhí)行操作4時,能否看到線程A在操作
1對共享變量a的寫入呢?
答案是:不一定能看到。
在單線程程序中,對存在控制依賴的操作重排序,不會改變執(zhí)行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執(zhí)行結果。
順序一致性
順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型作為參照。
數(shù)據(jù)競爭與順序一致性
當程序未正確同步時,就可能會存在數(shù)據(jù)競爭。Java內存模型規(guī)范對數(shù)據(jù)競爭的定義如下。
在一個線程中寫一個變量,在另一個線程讀同一個變量,而且寫和讀沒有通過同步來排序。
當代碼中包含數(shù)據(jù)競爭時,程序的執(zhí)行往往產生違反直覺的結果。如果一個多線程程序能正確步,這個程序將是一個沒有數(shù)據(jù)競爭的程序。
JMM對正確同步的多線程程序的內存一致性做了如下保證。
如果程序是正確同步的,程序的執(zhí)行將具有順序一致性(Sequentially Consistent)——即程序的執(zhí)行結果與該程序在順序一致性內存模型中的執(zhí)行結果相同。馬上我們就會看到,這對于程序員來說是一個極強的保證。這里的同步是指廣義上的同步,包括對常用同步原語(synchronized、volatile和final)的正確使用。
順序一致性內存模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它為程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性。
1)一個線程中的所有操作必須按照程序的順序來執(zhí)行。
2)(不管程序是否同步)所有線程都只能看到一個單一的操作執(zhí)行順序。在順序一致性內存模型中,每個操作都必須原子執(zhí)行且立刻對所有線程可見。
假設有兩個線程A和B并發(fā)執(zhí)行。其中A線程有3個操作,它們在程序中的順序是:A1→A2→A3。B線程也有3個操作,它們在程序中的順序是:B1→B2→B3。
假設這兩個線程使用監(jiān)視器鎖來正確同步:A線程的3個操作執(zhí)行后釋放監(jiān)視器鎖,隨后B線程獲取同一個監(jiān)視器鎖。那么程序在順序一致性模型中的執(zhí)行效果將如圖3-11所示。
現(xiàn)在我們再假設這兩個線程沒有做同步,下面是這個未同步程序在順序一致性模型中的執(zhí)行示意圖,如圖3-12所示。
未同步程序在順序一致性模型中雖然整體執(zhí)行順序是無序的,但所有線程都只能看到一個一致的整體執(zhí)行順序。以上圖為例,線程A和B看到的執(zhí)行順序都是:B1→A1→A2→B2→A3→B3。之所以能得到這個保證是因為順序一致性內存模型中的每個操作必須立即對任意線程可見。
但是,在JMM中就沒有這個保證。未同步程序在JMM中不但整體的執(zhí)行順序是無序的,而且所有線程看到的操作執(zhí)行順序也可能不一致。比如,在當前線程把寫過的數(shù)據(jù)緩存在本地內存中,在沒有刷新到主內存之前,這個寫操作僅對當前線程可見;從其他線程的角度來觀察,會認為這個寫操作根本沒有被當前線程執(zhí)行。只有當前線程把本地內存中寫過的數(shù)據(jù)刷新到主內存之后,這個寫操作才能對其他線程可見。在這種情況下,當前線程和其他線程看到的操作執(zhí)行順序將不一致。
同步程序的順序一致性效果
class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer() { // 獲取鎖 a = 1; flag = true; } // 釋放鎖 public synchronized void reader() { // 獲取鎖 if (flag) { int i = a; …… } // 釋放鎖 } }
順序一致性模型中,所有操作完全按程序的順序串行執(zhí)行。而在JMM中,臨界區(qū)內的代碼可以重排序(但JMM不允許臨界區(qū)內的代碼“逸出”到臨界區(qū)之外,那樣會破壞監(jiān)視器的語義)。JMM會在退出臨界區(qū)和進入臨界區(qū)這兩個關鍵時間點做一些特別處理,使得線程在這兩個時間點具有與順序一致性模型相同的內存視圖(具體細節(jié)后文會說明)。雖然線程A在臨界區(qū)內做了重排序,但由于監(jiān)視器互斥執(zhí)行的特性,這里的線程B根本無法“觀察”到線程A在臨界區(qū)內的重排序。這種重排序既提高了執(zhí)行效率,又沒有改變程序的執(zhí)行結果。
從這里我們可以看到,JMM在具體實現(xiàn)上的基本方針為:在不改變(正確同步的)程序執(zhí)行結果的前提下,盡可能地為編譯器和處理器的優(yōu)化打開方便之門。
JMM不保證未同步程序的執(zhí)行結果與該程序在順序一致性模型中的執(zhí)行結果一致。因為如果想要保證執(zhí)行結果一致,JMM需要禁止大量的處理器和編譯器的優(yōu)化,這對程序的執(zhí)行性能會產生很大的影響。
未同步程序在兩個模型中的執(zhí)行特性有如下幾個差異。
1)順序一致性模型保證單線程內的操作會按程序的順序執(zhí)行,而JMM不保證單線程內的操作會按程序的順序執(zhí)行(比如上面正確同步的多線程程序在臨界區(qū)內的重排序)
2)順序一致性模型保證所有線程只能看到一致的操作執(zhí)行順序,而JMM不保證所有線程能看到一致的操作執(zhí)行順序。
3)JMM不保證對64位的long型和double型變量的寫操作具有原子性,而順序一致性模型保證對所有的內存讀/寫操作都具有原子性。
第3個差異與處理器總線的工作機制密切相關。在計算機中,數(shù)據(jù)通過總線在處理器和內存之間傳遞。每次處理器和內存之間的數(shù)據(jù)傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為總線事務(Bus Transaction)。總線事務包括讀事務(Read Transaction)和寫事務(Write Transaction)。讀事務從內存?zhèn)魉蛿?shù)據(jù)到處理器,寫事務從處理器傳送數(shù)據(jù)到內存,每個事務會讀/寫內存中一個或多個物理上連續(xù)的字。
在一些32位的處理器上,如果要求對64位數(shù)據(jù)的寫操作具有原子性,會有比較大的開銷。為了照顧這種處理器,Java語言規(guī)范鼓勵但不強求JVM對64位的long型變量和double型變量的寫操作具有原子性。當JVM在這種處理器上運行時,可能會把一個64位long/double型變量的寫操作拆分為兩個32位的寫操作來執(zhí)行。這兩個32位的寫操作可能會被分配到不同的總線事務中執(zhí)行,此時對這個64位變量的寫操作將不具有原子性。
注意,在JSR-133之前的舊內存模型中,一個64位long/double型變量的讀/寫操作可以被拆分為兩個32位的讀/寫操作來執(zhí)行。從JSR-133內存模型開始(即從JDK5開始),僅僅只允許把一個64位long/double型變量的寫操作拆分為兩個32位的寫操作來執(zhí)行,任意的讀操作在JSR-133中都必須具有原子性(即任意讀操作必須要在單個讀事務中執(zhí)行)。
volatile的內存語義
當聲明共享變量為volatile后,對這個變量的讀/寫將會很特別。為了揭開volatile的神秘面紗,下面將介紹volatile的內存語義及volatile內存語義的實現(xiàn)。
鎖的語義決定了臨界區(qū)代碼的執(zhí)行具有原子性。這意味著,即使是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具有原子性。如果是多個volatile操作或類似于volatile++這種復合操作,這些操作整體上不具有原子性。
簡而言之,volatile變量自身具有下列特性。
可見性。對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入。
原子性。對任意單個volatile變量的讀/寫具有原子性,但類似于volatile++這種復合操不具有原子性。
volatile內存語義的實現(xiàn)
重排序分為編譯器重排序和處理器重排序。為了實現(xiàn)volatile內存語義,JMM會分別限制這兩種類型的重排序類型。
當?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
當?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
當?shù)谝粋€操作是volatile寫,第二個操作是volatile讀時,不能重排序。
為了實現(xiàn)volatile的內存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內存屏障插入策略。
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的后面插入一個StoreLoad屏障。
在每個volatile讀操作的后面插入一個LoadLoad屏障。
在每個volatile讀操作的后面插入一個LoadStore屏障。
上述內存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內存語義。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/68483.html
摘要:相比與其他操作系統(tǒng)包括其他類系統(tǒng)有很多的優(yōu)點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。因為多線程競爭鎖時會引起上下文切換。減少線程的使用。很多編程語言中都有協(xié)程。所以如何避免死鎖的產生,在我們使用并發(fā)編程時至關重要。 系列文章傳送門: Java多線程學習(一)Java多線程入門 Java多線程學習(二)synchronized關鍵字(1) java多線程學習(二)syn...
摘要:因為多線程競爭鎖時會引起上下文切換。減少線程的使用。舉個例子如果說服務器的帶寬只有,某個資源的下載速度是,系統(tǒng)啟動個線程下載該資源并不會導致下載速度編程,所以在并發(fā)編程時,需要考慮這些資源的限制。 最近私下做一項目,一bug幾日未解決,總惶恐。一日頓悟,bug不可怕,怕的是項目不存在bug,與其懼怕,何不與其剛正面。 系列文章傳送門: Java多線程學習(一)Java多線程入門 Jav...
摘要:開始學習也有一段時間了,一些基礎的書也掃了一遍了。最近慢慢開始看和,后者的話和有類似之處,都是一些編程經驗的編程的世界里好多的東西都是相同的。這里其實是對的最佳實踐,之后該對象已經變成一個過期的引用了,此時就應該清空這個引用。 開始學習java也有一段時間了,一些基礎的書也掃了一遍了(think in java/core java volume 1)。最近慢慢開始看和,后者的話和有類似...
摘要:學習編程的本最佳書籍這些書涵蓋了各個領域,包括核心基礎知識,集合框架,多線程和并發(fā),內部和性能調優(yōu),設計模式等。擅長解釋錯誤及錯誤的原因以及如何解決簡而言之,這是學習中并發(fā)和多線程的最佳書籍之一。 showImg(https://segmentfault.com/img/remote/1460000018913016); 來源 | 愿碼(ChainDesk.CN)內容編輯 愿碼Slo...
閱讀 468·2021-10-09 09:57
閱讀 473·2019-08-29 18:39
閱讀 814·2019-08-29 12:27
閱讀 3030·2019-08-26 11:38
閱讀 2670·2019-08-26 11:37
閱讀 1295·2019-08-26 10:59
閱讀 1382·2019-08-26 10:58
閱讀 992·2019-08-26 10:48