2.java併發機制的底層實現
一、volatile的應用
1.1、volatile的實現原理
- 原子性:
操作A和操作B:
對於操作A來說:操作B要麼不執行,要麼完全執行完。B對於A就有原子性
- 可見性:(加鎖)
一個執行緒對一個變數進行修改,另外一個執行緒可以立馬感知到,必須等待。
- 有序性:
程式碼執行的順序和大腦想象的順序是一樣的,所見即所知。先定義誰,先執行誰。
volatile:只能滿足可見性和有序性
術語 | 英文單詞 | 術語描述 |
---|---|---|
記憶體屏障 | memory | 是一組處理器的指令,用於實現對記憶體操作的順序限制 |
緩衝行 | cache line | CPU快取記憶體中可以分配的最小儲存單位。處理器填寫快取行時,會載入整個快取行,現代CPU需要執行幾百次CPU指令 |
原子操作 | atomic operations | 不可中斷的一系列操作 |
快取行填充 | cache line fill | 見百度www.4399.com |
快取命中 | cache hit | 見百度www.4399.com |
寫命中 | write hit | 見百度www.4399.com |
寫缺失 | write misses the cache | 一個有效的快取行被寫入到一個不存在的記憶體區域 |
Lock字首的指令在多核處理器下回引發兩件事情:
1、將當前處理器快取行的資料寫會到主記憶體
2、這個寫回記憶體的操作會使在其他CPU裡快取了該記憶體的地址的資料無效
總結:修改了主記憶體的資料,其他執行緒會立刻感知到
如果對聲明瞭 volatile 的變數進行了 寫操作,JVM就會向處理器傳送一條Lock 字首指令,將這個變數所在的快取行的資料寫回到系統記憶體。但是,就算是寫回到系統記憶體,如果其他處理器快取的值還是舊的,在執行計算操作就會有問題。所以,在多核處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己的快取是否是過期的,當處理器發現自己快取過期了,就會將當前處理器的快取設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理快取中
下面來講解volatile的兩條實現原則:
-
1、Lock字首指令會引起處理器快取寫到記憶體。(不鎖匯流排,開銷大,所以使用“快取鎖定”)
-
2、一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。
1.2、volatile的使用優化
著名的Java併發程式設計大師Doug lea在JDK 7的併發包裡新增一個佇列集合類Linked-TransferQueue,它在使用volatile變數時,用一種追加位元組的方式來優化隊列出隊和入隊的效能。LinkedTransferQueue的程式碼如下。
佇列中的頭部節點
private transient f?inal PaddedAtomicReference<QNode> head;
佇列中的尾部節點
private transient f?inal PaddedAtomicReference<QNode> tail;
static f?inal class PaddedAtomicReference <T> extends AtomicReference T> {
使用很多4個位元組的引用追加到64個位元組
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;
省略其他程式碼
}
1.2.1、追加位元組能優化效能?
1.2.2、為什麼追加64位元組能夠提高併發程式設計的效率呢?
1.2.3、那麼是不是在使用volatile變數時都應該追加到64位元組呢?答:不是的,在兩種情況下不適應。
-
1、快取行非64位元組寬的處理器
-
2、共享變數不會被頻繁的寫。
二、synchronized的實現原理與應用
-
對於同步方法,鎖的是當前物件(this)
-
對於靜態同步方法,鎖的是當前類的Class的物件
-
對於同步程式碼塊,鎖的是()裡面的物件
JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,但是兩者實現的細節不一樣。
-
程式碼塊同步使用的monitorenter和monitorexit指令實現的
-
方法同步使用另外一種方式實現的,細節在JVM規範裡沒有說。但是方法的同步同樣是使用這兩個指令號來實現的。
monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit插入到方法結束處和異常處,必須成對兒出現。任何物件都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。執行緒執行到monitorenet指令時,將會嘗試獲取物件所對應的monitor的所有權,即嘗試獲得物件的鎖。
2.1、java物件頭
synchronized用的鎖是存在java物件頭裡面的。如果物件是陣列型別,則虛擬機器用3個字寬(word)儲存物件頭;如果物件是非陣列型別,則用2個字寬來儲存物件頭。1字寬=4位元組;
長度 | 內容 | 說明 |
---|---|---|
32/64bit | Mark Word | 儲存物件的hashCode、鎖資訊、分代年齡 |
32/64bit | Class Metadata Address | 儲存到物件型別資料的指標 |
32/64bit | Arrays Length | 陣列的長度(如果當前物件是陣列) |
2.2、鎖的升級與對比
鎖一共有四種狀態的 級別 從低到高:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,這幾種狀態會隨著競爭情況逐漸升級,注意:不能降級。
2.2.1、偏向鎖
為了讓執行緒獲得鎖的代價低而引入的偏向鎖。
當一個執行緒訪問同步程式碼塊並且獲得鎖的時候,會在物件頭和棧幀的鎖記錄中儲存著偏向鎖的執行緒ID,以後該執行緒進入或者退出程式碼塊時,不需要進行CAS來操作加鎖和解鎖,只需要簡單測試一下物件頭裡是否存著指向當前執行緒的偏向鎖。
如果失敗了還需要進行CAS操作。
2.2.1.1、偏向鎖的撤銷
偏向鎖使用了一種** 等到 競爭出現 才釋放鎖的機制,,所以,當其他執行緒嘗試競爭偏向鎖的時候,持有偏向鎖的執行緒才會釋放。 (弱勢群體)**
2.2.1.2、偏向鎖的關閉
偏向鎖在JDK6和7中是預設啟動的。但是是有延遲的。
關閉延遲引數:-XX:BiasedLockingStartupDelay=0。
如果你確定所有鎖通常情況下處於競爭狀態,可以關閉偏向鎖。
關閉偏向鎖: -XX:UseBiasedLocking=false。那麼程式會預設進入輕量級鎖的狀態。
2.2.2、輕量級鎖
2.2.2.1、輕量級鎖的加鎖
執行緒在執行同步程式碼快** 之前,JVM會先在當前執行緒的棧幀裡建立用於儲存鎖記錄的空間,並將物件頭的Mark Word複製到鎖記錄中,官方稱:Displaced Mark Word。
然後執行緒嘗試使用CAS將物件頭的Mark Word替換為指向鎖記錄的指標。**
如果成功,獲得鎖;如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲得鎖。
2.2.2.1、輕量級鎖的解鎖
輕量級鎖的解鎖時,會使原子的CAS操作將Displaced Mark Word 替換回到物件頭。
如果成功,表示沒有競爭發生,解鎖成功。
如果失敗,表示當前鎖存在競爭,鎖就會膨脹成為“重量級鎖”(因為兩個執行緒同時競爭鎖,導致鎖升級)
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級,就不會在恢復到輕量級鎖的狀態。
2.2.3、鎖的優缺點對比
鎖 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的,和執行非同步程式碼塊速度差距很小 | 如果執行緒之間存在著鎖競爭,會帶來額外的鎖撤銷的消耗 | 適用於只有一個執行緒訪問同步程式碼快場景 |
輕量級鎖 | 競爭的執行緒不會阻塞,提高了執行緒的響應速度 | 如果始終得不到鎖競爭的執行緒,會使用自旋來消耗CPU | 追求響應速度,同步程式碼快執行速度非常快 |
重量級鎖 | 執行緒競爭不使用自旋,不會消耗CPU | 執行緒阻塞,響應速度慢 | 追求吞吐量,同步程式碼快執行速度較長 |
三、原子操作實現原理
3.1、術語定義
3.2、處理器如何實現原子操作
32位IA-32處理器採用的是匯流排加鎖或者快取加鎖的方式來實現原子操作。首先處理器會自動保證基本的操作原子性。
1、使用匯流排鎖來保證原子性。
第一個機制就是通過匯流排鎖來保證原子性。如果多個處理器同時對共享變數進行修改時,就會有執行緒安全問題,結果可能會和自己想要的結果不一樣。
如果想要保證讀寫共享操作是原子的,就必須保證CPU1讀改寫的時候,CPU2不能操作該變數。
** 所謂匯流排鎖:使用處理器提供的LOCK # 訊號,當一個處理器在總線上輸出訊號時,其他處理器的請求就會被阻塞,那麼該處理器會獨佔共享記憶體。**
2、使用快取鎖來保證原子性
第二個機制就是通過快取鎖來保證原子性。我們只需要保證對某個記憶體地址的操作時原子的就OK啦,但是匯流排鎖是把CPU和記憶體之間的通訊全鎖住了,這使得鎖定期間,別的記憶體也不能修改啦,所以匯流排鎖的開銷比較大。所以才出現了快取鎖。
所謂快取鎖:記憶體區域如果被快取在處理器的快取行中,並且Lock操作期間被鎖定,那麼他執行鎖操作回寫到記憶體時,處理器不在總線上 發出Lock # 訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證原子性。
快取一致性:會阻止同時修改由兩個以上處理器快取的記憶體區資料,當其他處理器回寫已被鎖定的快取行資料時,會使得快取行資料失效。
例如:當CPU1修改了快取行中的i
時,使用了快取鎖,那麼CPU2就不能同時快取i
所在的快取行。
但是有兩種情況不會使用快取鎖定:
1、當操作的資料不能被快取到處理器的內部時,或者操作的額資料橫跨多個快取行,則處理器需要使用匯流排鎖定。
2、有的處理器不支援快取鎖定。
3.3、java如何實現原子操作的
在java中通過鎖和迴圈CAS的方式來實現原子操作。
3.3.1、使用迴圈CAS來實現原子操作
使用java.util.atomic包下的原子類。
3.3.2、使用CAS實現原子操作的三大問題
1、ABA問題:使用版本號
因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,最後又變回了A,那麼CAS在檢查的時候,就沒有發現他的變化,實際上是發生了變化,ABA問題的解決思路就是加版本號,每次變化讓版本號+1即可,那麼原來的** A->B->A就變成了現在的A1->B2->A3。從JDK1.5開始,JDK的Atomic包中提供了一個類AtomicStampedReference(原子時間戳引用)來解決ABA問題。這個類的compareAndSet方法的作用就是首先檢驗當前引用是否等於預期引用,並且檢查當前標誌(版本)是否等於預期標誌(版本),如果全部相等,則以原子方式將引用和該標誌的值設定為給定的更新值**
public boolean compareAndSet(V expectedReference, 預期引用
V newReference, 更新後的引用
int expectedStamp, 預期標誌(版本)
int newStamp) 更新後的標誌(版本)
2、迴圈時間長開銷大:
自旋CAS如果長時間不成功,那麼會一直佔用CPU資源,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令,那麼效率會有一定的提升。
3、只能保證一個共享變數的原子操作:
當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性。這個時候可以用鎖。還有一個巧妙的方法,就是把多個共享變數合併成一個共享變數來操作。比如,有兩個共享變數 i=2,j=a,合併一下 ij=2a,然後用CAS來操作 ij。從jdk1.5開始,jdk提供了AtomicReference類來保證引用物件之間的原子性,就可以把多個變數放在一個物件裡來進行CAS操作了。
3.3.3、使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的執行緒才能夠操作鎖定的記憶體區域。JVM內部實現了很多鎖機制,偏向鎖、輕量級鎖、互斥鎖。有意思的是 除了 偏向鎖,JVM實現鎖的方式都是用了迴圈CAS,即當一個執行緒想進入同步程式碼快的時候使用迴圈CAS的方式來獲取鎖,當它退出同步程式碼塊的時候迴圈CAS釋放鎖。