併發編程的目的是爲了讓程序運行的更快,可是,並非啓動更多的線程就能讓程序最大限度的併發執行。若是但願經過多線程執行任務讓程序運行的更快,會面臨很是多的挑戰:
(1)上下文切換
(2)死鎖
(3)資源限制(硬件和軟件)
即便是單核處理器也支持多線程執行代碼,CPU經過給每一個線程分配CPU時間片來實現這個機制。時間片通常只有幾十毫秒(ms)。
CPU經過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。可是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,能夠再加載這個任務的狀態。因此任務從保存到再加載的過程就是一次上下文切換。上下文切換會影響多線程執行的速度。
使用Lmbench3能夠測量上下文切換的時長。
使用vmstat能夠測量上下文切換的次數。
vmstat 1 :測試一秒鐘上下文切換的次數。
CS(Context Switch)表示上下文切換的次數。java
如何減小上下文切換?
(1)無鎖併發編程(將數據的ID按照Hash算法取模分段,不一樣線程處理不一樣段的數據)
(2)CAS算法(Java的Atomic包使用CAS算法來更新數據,而不須要枷鎖)
(3)使用最少線程(避免建立不須要的線程,好比任務不多,卻建立了不少線程,致使大量線程處於等待狀態)
(4)協程(在單線程裏實現多任務的調度,並在單線程裏維護多個任務間的切換)程序員
實戰:減小上下文切換?
經過減小線上大量WAITING的線程,來減小上下文切換次數。
第一步:用jstack命令dump線程信息,看看pid爲3117的進程裏的線程都在作什麼。
/java/bin/jstack 31177 > /home/dump17
第二步:統計全部線程分別處於什麼狀態,發現300多個線程處於WAITING狀態。
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)的線程在作什麼。發現這些線程基本全是JBOSS的工做線程,說明JBOSS線程池裏線程接收的任務太少,大量線程都閒着。
第四步:減小JBOSS的工做線程數,找到JBOSS線程池配置信息,將maxThreads降到100。
第五步: 重啓,發現WAITING減小了175個。算法
一旦出現死鎖,業務是可感知的,由於不能繼續提供服務了,那麼只能經過dump線程查看到底哪一個線程出現了問題。
避免死鎖的幾個常見方法:
(1)避免一個線程同時獲取多個鎖
(2)避免一個線程在鎖內同時佔用多個資源,儘可能保證每一個鎖只佔用一個資源。
(3)嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
(3)對於數據庫鎖,加鎖和解鎖必須在一個數據庫鏈接裏,不然會出現解鎖失敗的狀況。數據庫
資源限制是指在進行併發編程時,程序的執行速度受限於計算機硬件資源或軟件資源。
硬件資源限制:帶寬,硬盤讀寫速度,CPU處理速度。
軟件資源限制:數據庫的鏈接數和socket鏈接數。
對於java開發工程師而言,強烈建議多使用JDK併發包提供的併發容器和工具類來解決併發問題,覺得這些類都已經經過了充分的測試和優化,都可解決本章提到的幾個挑戰。編程
Java中所使用的併發機制依賴於JVM的實現和CPU的指令。
Java代碼——>Java字節碼——>JVM——>彙編指令——>CPU上執行。數組
Volatile的應用
可見性:當一個線程修改一個共享變量時,另一個線程能讀到這個修改的值。
在多線程併發編程中synchronized和volatile都扮演着重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變量的"可見性"。
volatile比synchronized的使用和執行成本更低,覺得它不會引發線程上下文的切換和調度。
volatile定義:
Java語言規範第3版中對volatile的定義以下:Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保經過排它鎖單獨得到這個變量。Java語言提供了volatile,在某些狀況下比鎖更加方便。若是一個字段被聲明稱volatile,Java線程內存模型確保全部線程看到這個變量的值是一致的。緩存
術語 | 描述 |
---|---|
內存屏障 | 是一組處理器指令,用於實現對內存操做的順序限制 |
原子操做 | 不可中斷的一個或一些列操做 |
緩存行填充 | 當處理器識別到從內存中讀取的操做數是可緩存的,處理器讀取整個高速緩存行到適當的緩存(L1,L2,L3的或全部) |
緩存命中 | 若是進行高速緩存行填充操做的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操做數,而不是從內存讀取 |
寫命中 | 當處理器將操做數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,若是存在一個有效的緩存行,則處理器將這個操做數寫回到緩存,而不是寫回到內存,這個操做被稱爲寫命中。 |
寫缺失 | 一個有效的緩存行被寫入到不存在的內存區域。 |
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#信號通常不鎖總線,而是鎖緩存,畢
竟鎖總線開銷的比較大。在8.1.4節有詳細說明鎖定操做對處理器緩存的影響,對於Intel486和
Pentium處理器,在鎖操做時,老是在總線上聲言LOCK#信號。但在P6和目前的處理器中,若是訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK#信號。相反,它會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操做被稱爲「緩存鎖
定」,緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據。
2)一個處理器的緩存回寫到內存會致使其餘處理器的緩存無效。IA-32處理器和Intel 64處
理器使用MESI(修改、獨佔、共享、無效)控制協議去維護內部緩存和其餘處理器緩存的一致
性。在多核處理器系統中進行操做的時候,IA-32和Intel 64處理器能嗅探其餘處理器訪問系統內存和它們的內部緩存。處理器使用嗅探技術保證它的內部緩存、系統內存和其餘處理器的緩存的數據在總線上保持一致。例如,在Pentium和P6 family處理器中,若是經過嗅探一個處理器來檢測其餘處理器打算寫內存地址,而這個地址當前處於共享狀態,那麼正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執行緩存行填充。安全
在多線程併發編程中synchronized一直是元老級角色,不少人都會稱呼它爲重量級鎖。可是,隨着Java SE 1.6對synchronized進行了各類優化以後,有些狀況下它就並不那麼重了。本文詳細介紹Java SE 1.6中爲了減小得到鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。多線程
先來看下利用synchronized實現同步的基礎:Java中的每個對象均可以做爲鎖。具體表現
爲如下3種形式。併發
當一個線程試圖訪問同步代碼塊時,它首先必須獲得鎖,退出或拋出異常時必須釋放鎖
。
那麼鎖到底存在哪裏呢?鎖裏面會存儲什麼信息呢?
從JVM規範中能夠看到Synchonized在JVM裏的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但二者的實現細節不同。代碼塊同步是使用monitorenter
和monitorexit
指令實現的,而方法同步是使用另一種方式實現的,細節在JVM規範裏並無詳細說明。可是,方法的同步一樣可使用這兩個指令來實現。
monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每一個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行monitorenter指令時,將會嘗試獲取對象所對應的monitor的全部權,即嘗試得到對象的鎖。
Java對象頭
synchronized用的鎖是存在Java對象頭裏的。
鎖的升級與對比
Java SE 1.6爲了減小得到鎖和釋放鎖帶來的性能消耗,引入了「偏向鎖」和「輕量級鎖」,在Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭狀況逐漸升級。鎖能夠升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提升得到鎖和釋放鎖的效率,下文會詳細分析。
1.偏向鎖
HotSpot 的做者通過研究發現,大多數狀況下,鎖不只不存在多線程競爭,並且老是由同
一線程屢次得到,爲了讓線程得到鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並
獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,之後該線程在進入和退出
同步塊時不須要進行CAS操做來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否
存儲着指向當前線程的偏向鎖。若是測試成功,表示線程已經得到了鎖。若是測試失敗,則需
要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):若是沒有設置,則使用CAS競爭鎖;若是設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
(1)偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,因此當其餘線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。偏向鎖的撤銷,須要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,而後檢查持有偏向鎖的線程是否活着,若是線程不處於活動狀態,則將對象頭設置成無鎖狀態;若是線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼從新偏向於其餘線程,要麼恢復到無鎖或者標記對象不適合做爲偏向鎖,最後喚醒暫停的線程。
(2)偏向鎖的撤銷
偏向鎖在Java 6和Java 7裏是默認啓用的,可是它在應用程序啓動幾秒鐘以後才激活,若有必要可使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。若是你肯定應用程序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:XX:UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。
2.輕量級鎖
(1)輕量級鎖加鎖
線程在執行同步塊以前,JVM會先在當前線程的棧楨中建立用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱爲Displaced Mark Word。而後線程嘗試使用CAS將對象頭中的Mark Word替換爲指向鎖記錄的指針。若是成功,當前線程得到鎖,若是失敗,表示其餘線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
(2)輕量級鎖解鎖
輕量級解鎖時,會使用原子的CAS操做將Displaced Mark Word替換回到對象頭,若是成功,則表示沒有競爭發生。若是失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
由於自旋會消耗CPU,爲了不無用的自旋(好比得到鎖的線程被阻塞住了),一旦鎖升級
成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其餘線程試圖獲取鎖時,
都會被阻塞住,當持有鎖的線程釋放鎖以後會喚醒這些線程,被喚醒的線程就會進行新一輪
的奪鎖之爭。
3.鎖的優缺點對比
鎖 | 優勢 | 缺點 | 使用場景 | |
---|---|---|---|---|
偏向鎖 | 加鎖和解鎖不須要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 | 若是線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 適用於只有一個線程訪問同步塊場景 | |
輕量級鎖 | 競爭的線程不會阻塞,提升了程序的響應速度 | 若是始終得不到鎖競爭的線程,使用自旋會消耗CPU | 追求響應時間,同步塊執行速度很是快 | |
重量級鎖 | 線程競爭不適用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢 | 最求吞吐量,同步塊執行速度較慢 |
原子操做的實現原理
原子(atomic)本意是「不能被進一步分割的最小粒子」,而原子操做(atomic operation)意
爲「不可被中斷的一個或一系列操做」。在多處理器上實現原子操做就變得有點複雜。讓咱們
一塊兒來聊一聊在Intel處理器和Java裏是如何實現原子操做的。
1.術語定義
術語名稱 | 英文 | 解釋 | |
---|---|---|---|
緩存行 | Cache line | 緩存的最小操做單位 | |
比較並交換 | Compare And Swap | CAS操做須要輸入兩個數值,一箇舊值(指望操做前的值)和一個新值,在操做期間先比較舊值有沒有發生變化,若是沒有發生變化,才交換成新值,發生了變化則不交換 | |
CPU流水線 | CPU pipeline | CPU流水線的工做方式就像工業生產上的裝配流水線,在CPU中由5~6個不一樣功能的電路單元組成一條指令處理流水線,而後將一個X86指令分紅5~6步後再由這些電路單元分別執行,這樣就能實如今一個CPU時鐘週期完成一條指令,所以提升CPU的運算速度 | |
內存順序衝突 | Memory order violation | 內存順序衝突通常是由假共享引發的,假共享是指多個CPU同時修改同一個緩存行的不一樣部分而引發其中一個CPU的操做無效,當出現這個內存順序衝突時,CPU必須清空流水線 |
2.處理器如何實現原子操做
32位IA-32處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操做。
首先處理器會自動保證基本的內存操做的原子性。處理器保證從系統內存中讀取或者寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其餘處理器不能訪問這個字節的內存地址。Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行裏進行16/32/64位的操做是原子的,可是複雜的內存操做處理器是不能自動保證其原子性的,好比跨總線寬度、跨多個緩存行和跨頁表的訪問。可是,處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操做的原子性。
(1)使用總線鎖保證原子性
第一個機制是經過總線鎖保證原子性。若是多個處理器同時對共享變量進行讀改寫操做(i++就是經典的讀改寫操做),那麼共享變量就會被多個處理器同時進行操做,這樣讀改寫操做就不是原子的,操做完以後共享變量的值會和指望的不一致。舉個例子,若是i=1,咱們進行兩次i++操做,咱們指望的結果是3,可是有可能結果是2。
緣由多是多個處理器同時從各自的緩存中讀取變量i,分別進行加1操做,而後分別寫入系統內存中。那麼,想要保證讀改寫共享變量的操做是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操做緩存了該共享變量內存地址的緩存。
處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其餘處理器的請求將被阻塞住,那麼該處理器能夠獨佔共享內存。
(2)使用緩存鎖保證原子性
第二個機制是經過緩存鎖定來保證原子性。在同一時刻,咱們只需保證對某個內存地址的操做是原子性便可,但總線鎖定把CPU和內存之間的通訊鎖住了,這使得鎖按期間,其餘處理器不能操做其餘內存地址的數據,因此總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。
頻繁使用的內存會緩存在處理器的L一、L2和L3高速緩存裏,那麼原子操做就能夠直接在處理器內部緩存中進行,並不須要聲明總線鎖,在Pentium 6和目前的處理器中可使用「緩存鎖定」的方式來實現複雜的原子性。所謂「緩存鎖定」是指內存區域若是被緩存在處理器的緩存行中,而且在Lock操做期間被鎖定,那麼當它執行鎖操做回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並容許它的緩存一致性機制來保證操做的原子性,由於緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其餘處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那麼CPU2就不能同時緩存i的緩存行。
可是有兩種狀況下處理器不會使用緩存鎖定。
第一種狀況是:當操做的數據不能被緩存在處理器內部,或操做的數據跨多個緩存行(cache line)時,則處理器會調用總線鎖定。
第二種狀況是:有些處理器不支持緩存鎖定。對於Intel 486和Pentium處理器,就算鎖定的內存區域在處理器的緩存行中也會調用總線鎖定。
針對以上兩個機制,咱們經過Intel處理器提供了不少Lock前綴的指令來實現。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其餘一些操做數和邏輯指令(如ADD、OR)等,被這些指令操做的內存區域就會加鎖,致使其餘處理器不能同時訪問它。
3.Java如何實現原子操做
在Java中能夠經過鎖和循環CAS的方式來實現原子操做。
(1)使用循環CAS實現原子操做
JVM中的CAS操做正是利用了處理器提供的CMPXCHG
指令實現的。自旋CAS實現的基本思路就是循環進行CAS操做直到成功爲止。
(2)CAS實現原子操做的三大問題
在Java併發包中有一些併發框架也使用了自旋CAS的方式來實現原子操做好比LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操做,可是CAS仍然存在三
大問題。ABA問題,循環時間長開銷大,以及只能保證一個共享變量的原子操做。
1)ABA問題。由於CAS須要在操做值的時候,檢查值有沒有發生變化,若是沒有發生變化
則更新,可是若是一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它
的值沒有發生變化,可是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面
追加上版本號,每次變量更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。從
Java 1.5開始,JDK的Atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的做用是首先檢查當前引用是否等於預期引用,而且檢查當前標誌是否等於預期標誌,若是所有相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。
2)循環時間長開銷大。自旋CAS若是長時間不成功,會給CPU帶來很是大的執行開銷。若是JVM能支持處理器提供的pause指令,那麼效率會有必定的提高。pause指令有兩個做用:第一,它能夠延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零;第二,它能夠避免在退出循環的時候因內存順序衝突(Memory Order Violation)而引發CPU流水線被清空(CPU Pipeline Flush)從而提升CPU的執行效率。
3)只能保證一個共享變量的原子操做。當對一個共享變量執行操做時,咱們可使用循環CAS的方式來保證原子操做,可是對多個共享變量操做時,循環CAS就沒法保證操做的原子性,這個時候就能夠用鎖。還有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操做。好比,有兩個共享變量i=2,j=a,合併一下ij=2a,而後用CAS來操做ij。從Java 1.5開始,JDK提供了AtomicReference
類來保證引用對象之間的原子性,就能夠把多個變量放在一個對象裏來進行CAS操做。
(3)使用鎖機制實現原子操做
鎖機制保證了只有得到鎖的線程纔可以操做鎖定的內存區域。JVM內部實現了不少種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了循環CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。
Java線程之間的通訊對程序員徹底透明,內存可見性問題很容易困擾Java程序員。
Java內存模型的基礎
在併發編程中,須要處理兩個關鍵問題:線程之間如何通訊及線程之間如何同步(這裏的線程是指併發執行的活動實體)。通訊是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通訊機制有兩種:共享內存和消息傳遞。
在共享內存的併發模型裏,線程之間共享程序的公共狀態,經過寫-讀內存中的公共狀態進行隱式通訊。在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過發送消息來顯式進行通訊。
同步是指程序中用於控制不一樣線程間操做發生相對順序的機制。在共享內存併發模型裏,同步是顯式進行的
。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。
Java的併發採用的是共享內存模型,Java線程之間的通訊老是隱式進行
,整個通訊過程對程序員徹底透明。若是編寫多線程程序的Java程序員不理解隱式進行的線程之間通訊的工做機制,極可能會遇到各類奇怪的內存可見性問題。
Java內存模型的抽象結構
在Java中,全部實例域、靜態域和數組元素都存儲在堆內存中,堆內存在線程之間共享
(本章用「共享變量」這個術語代指實例域,靜態域和數組元素)。局部變量
(Local Variables),方法定義參數
(Java語言規範稱之爲Formal Method Parameters)和異常處理器參數
(Exception Handler Parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
Java線程之間的通訊由Java內存模型(本文簡稱爲JMM)控制
,JMM決定一個線程對共享變量的寫入什麼時候對另外一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(Main Memory)中,每一個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化。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在執行時,把更新後的x值(假設值爲1)臨時存放在本身的本地內存A中。當線程A和線程B須要通訊時,線程A首先會把本身本地內存中修改後的x值刷新到主內存中,此時主內存中的x值變爲了1。隨後,線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變爲了1。
從總體來看,這兩個步驟實質上是線程A在向線程B發送消息,並且這個通訊過程必需要通過主內存。JMM經過控制主內存與每一個線程的本地內存之間的交互,來爲Java程序員提供內存可見性保證。
從源代碼到指令序列的重排序
在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。重排序分3種類型。
1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。
2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level
Parallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應
機器指令的執行順序。
3)內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。
從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序,如圖3-3所示。
上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會致使多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是全部的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,經過內存屏障指令來禁止特定類型的處理器重排序。
JMM屬於語言級的內存模型,它確保在不一樣的編譯器和不一樣的處理器平臺之上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。
併發編程模型的分類
爲了保證內存可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分爲4類。
StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。現代的多處
理器大多支持該屏障(其餘類型的屏障不必定被全部處理器支持)。執行該屏障開銷會很昂
貴,由於當前處理器一般要把寫緩衝區中的數據所有刷新到內存中(Buffer Fully Flush)。
happens-before簡介
JSR-133使用happens-before的概念來闡述操做之間的內存可見性。在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必需要存在happens-before關係。這裏提到的兩個操做既能夠是在一個線程以內,也能夠是在不一樣線程之間。
與程序員密切相關的happens-before規則以下。
程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的
讀。
傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
注意兩個操做之間具備happens-before關係,並不意味着前一個操做必需要在後一個操做以前執行!happens-before僅僅要求前一個操做(執行的結果)對後一個操做可見,且前一個操做按順序排在第二個操做以前(the first is visible to and ordered before the second)。happens-before的定義很微妙,後文會具體說明happens-before爲何要這麼定義。
happens-before與JMM的關係如圖3-5所示。
如圖3-5所示,一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程序員來講,happens-before規則簡單易懂,它避免Java程序員爲了理解JMM提供的內存
可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法。
重排序
重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段。
數據依賴性
若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。數據依賴分爲下列3種類型,如表3-4所示。
名稱 | 示例代碼 | 說明 |
---|---|---|
寫後讀 | a=1;b=a; | 寫一個變量以後,再讀這個 位置 |
寫後寫 | a=1;a=2; | 寫一個變量以後,再寫這個變量 |
讀後寫 | a=b;b=1; | 讀一個變量以後,再寫這個變量 |
上面3種狀況,只要重排序兩個操做的執行順序,程序的執行結果就會被改變。
前面提到過,編譯器和處理器可能會對操做作重排序。編譯器和處理器在重排序時,會遵照數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操做的執行順序。這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮。
as-if-serial語義
as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)
程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。
爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就可能被編譯器和處理器重排序。爲了具體說明,請看下面計算圓面積的代碼示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi r r; // C
A和C之間存在數據依賴關係,同時B和C之間也存在數據依賴關係。所以在最終執行的指令序列中,C不能被重排序到A和B的前面。但A和B之間沒有數據依賴關係,編譯器和處理器能夠重排序A和B之間的執行順序。
程序順序規則
根據happens-before的程序順序規則,上面計算圓的面積的示例代碼存在3個happens-before關係。
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
這裏A happens-before B,但實際執行時B卻能夠排在A以前執行(看上面的重排序後的執
行順序)。若是A happens-before B,JMM並不要求A必定要在B以前執行。JMM僅僅要求前一個操做(執行的結果)對後一個操做可見,且前一個操做按順序排在第二個操做以前。這裏操做A的執行結果不須要對操做B可見;並且重排序操做A和操做B後的執行結果,與操做A和操做B按happens-before順序執行的結果一致。在這種狀況下,JMM會認爲這種重排序並不非法(not illegal),JMM容許這種重排序。
在計算機中,軟件技術和硬件技術有一個共同的目標:在不改變程序執行結果的前提下,儘量提升並行度。編譯器和處理器聽從這一目標,從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首先執行
writer()方法,隨後B線程接着執行reader()方法。線程B在執行操做4時,可否看到線程A在操做
1對共享變量a的寫入呢?
答案是:不必定能看到。
在單線程程序中,對存在控制依賴的操做重排序,不會改變執行結果(這也是as-if-serial語義容許對存在控制依賴的操做作重排序的緣由);但在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。
順序一致性
順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型做爲參照。
數據競爭與順序一致性
當程序未正確同步時,就可能會存在數據競爭。Java內存模型規範對數據競爭的定義以下。
在一個線程中寫一個變量,在另外一個線程讀同一個變量,並且寫和讀沒有經過同步來排序。
當代碼中包含數據競爭時,程序的執行每每產生違反直覺的結果。若是一個多線程程序能正確步,這個程序將是一個沒有數據競爭的程序。
JMM對正確同步的多線程程序的內存一致性作了以下保證。
若是程序是正確同步的,程序的執行將具備順序一致性(Sequentially Consistent)——即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。立刻咱們就會看到,這對於程序員來講是一個極強的保證。這裏的同步是指廣義上的同步,包括對經常使用同步原語(synchronized、volatile和final)的正確使用。
順序一致性內存模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它爲程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性。
1)一個線程中的全部操做必須按照程序的順序來執行。
2)(無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性內存模型中,每一個操做都必須原子執行且馬上對全部線程可見。
假設有兩個線程A和B併發執行。其中A線程有3個操做,它們在程序中的順序是:A1→A2→A3。B線程也有3個操做,它們在程序中的順序是:B1→B2→B3。
假設這兩個線程使用監視器鎖來正確同步:A線程的3個操做執行後釋放監視器鎖,隨後B線程獲取同一個監視器鎖。那麼程序在順序一致性模型中的執行效果將如圖3-11所示。
如今咱們再假設這兩個線程沒有作同步,下面是這個未同步程序在順序一致性模型中的執行示意圖,如圖3-12所示。
未同步程序在順序一致性模型中雖然總體執行順序是無序的,但全部線程都只能看到一個一致的總體執行順序。以上圖爲例,線程A和B看到的執行順序都是:B1→A1→A2→B2→A3→B3。之因此能獲得這個保證是由於順序一致性內存模型中的每一個操做必須當即對任意線程可見。
可是,在JMM中就沒有這個保證。未同步程序在JMM中不但總體的執行順序是無序的,並且全部線程看到的操做執行順序也可能不一致。好比,在當前線程把寫過的數據緩存在本地內存中,在沒有刷新到主內存以前,這個寫操做僅對當前線程可見;從其餘線程的角度來觀察,會認爲這個寫操做根本沒有被當前線程執行。只有當前線程把本地內存中寫過的數據刷新到主內存以後,這個寫操做才能對其餘線程可見。在這種狀況下,當前線程和其餘線程看到的操做執行順序將不一致。
同步程序的順序一致性效果
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; …… } // 釋放鎖 } }
順序一致性模型中,全部操做徹底按程序的順序串行執行。而在JMM中,臨界區內的代碼能夠重排序(但JMM不容許臨界區內的代碼「逸出」到臨界區以外,那樣會破壞監視器的語義)。JMM會在退出臨界區和進入臨界區這兩個關鍵時間點作一些特別處理,使得線程在這兩個時間點具備與順序一致性模型相同的內存視圖(具體細節後文會說明)。雖然線程A在臨界區內作了重排序,但因爲監視器互斥執行的特性,這裏的線程B根本沒法「觀察」到線程A在臨界區內的重排序。這種重排序既提升了執行效率,又沒有改變程序的執行結果。
從這裏咱們能夠看到,JMM在具體實現上的基本方針爲:在不改變(正確同步的)程序執行結果的前提下,儘量地爲編譯器和處理器的優化打開方便之門。
JMM不保證未同步程序的執行結果與該程序在順序一致性模型中的執行結果一致。由於若是想要保證執行結果一致,JMM須要禁止大量的處理器和編譯器的優化,這對程序的執行性能會產生很大的影響。
未同步程序在兩個模型中的執行特性有以下幾個差別。
1)順序一致性模型保證單線程內的操做會按程序的順序執行,而JMM不保證單線程內的操做會按程序的順序執行(好比上面正確同步的多線程程序在臨界區內的重排序)
2)順序一致性模型保證全部線程只能看到一致的操做執行順序,而JMM不保證全部線程能看到一致的操做執行順序。
3)JMM不保證對64位的long型和double型變量的寫操做具備原子性,而順序一致性模型保證對全部的內存讀/寫操做都具備原子性。
第3個差別與處理器總線的工做機制密切相關。在計算機中,數據經過總線在處理器和內存之間傳遞。每次處理器和內存之間的數據傳遞都是經過一系列步驟來完成的,這一系列步驟稱之爲總線事務(Bus Transaction)。總線事務包括讀事務(Read Transaction)和寫事務(Write Transaction)。讀事務從內存傳送數據處處理器,寫事務從處理器傳送數據到內存,每一個事務會讀/寫內存中一個或多個物理上連續的字。
在一些32位的處理器上,若是要求對64位數據的寫操做具備原子性,會有比較大的開銷。爲了照顧這種處理器,Java語言規範鼓勵但不強求JVM對64位的long型變量和double型變量的寫操做具備原子性。當JVM在這種處理器上運行時,可能會把一個64位long/double型變量的寫操做拆分爲兩個32位的寫操做來執行。這兩個32位的寫操做可能會被分配到不一樣的總線事務中執行,此時對這個64位變量的寫操做將不具備原子性。
注意,在JSR-133以前的舊內存模型中,一個64位long/double型變量的讀/寫操做能夠被拆分爲兩個32位的讀/寫操做來執行。從JSR-133內存模型開始(即從JDK5開始),僅僅只容許把一個64位long/double型變量的寫操做拆分爲兩個32位的寫操做來執行,任意的讀操做在JSR-133中都必須具備原子性(即任意讀操做必需要在單個讀事務中執行)。
volatile的內存語義
當聲明共享變量爲volatile後,對這個變量的讀/寫將會很特別。爲了揭開volatile的神祕面紗,下面將介紹volatile的內存語義及volatile內存語義的實現。鎖的語義決定了臨界區代碼的執行具備原子性。這意味着,即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。
簡而言之,volatile變量自身具備下列特性。
可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
原子性。對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操不具備原子性。
volatile內存語義的實現
重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。
爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。
在每一個volatile寫操做的前面插入一個StoreStore屏障。
在每一個volatile寫操做的後面插入一個StoreLoad屏障。
在每一個volatile讀操做的後面插入一個LoadLoad屏障。
在每一個volatile讀操做的後面插入一個LoadStore屏障。
上述內存屏障插入策略很是保守,但它能夠保證在任意處理器平臺,任意的程序中都能獲得正確的volatile內存語義。