《並發編程的藝術》閱讀筆記第二章。主要針對 volatile 和 synchronized 做了總結
一、volatile 的應用#
如果 volatile 變量修飾符使用得當,它比 synchronized 的使用和執行成本更低,
因為它不會引起線程上下文的切換和調度。
1. volatile 的定義和實現原理#
有 volatile 變量修飾符的共享變量進行寫操作的時候會多出一個 lock 前綴的指令
lock 前綴的指令在多核處理器中引發兩件事情
(1)將當前處理器快取行的數據寫回內存
但是鎖總線開銷比較大,因此現在的 LOCK 信號基本鎖快取,使用快取一致性機制確保修改的原子性(快取鎖定)
(2)這個寫回內存的操作會使在其他 CPU 裡快取了該內存地址的數據無效
- MESI (修改、獨占、共享、無效) 控制協議維護內存和其他處理器快取一致性
- 嗅探技術保證內部快取、系統內存和其他數據快取在總線上保持一致
為了提高處理速度,處理器不直接和內存通信,而是先將內存中的數據讀到 cache 中再進行操作,但操作完全不知道何時會寫到內存。如果對聲明了 volatile 變量的進行寫操作,JVM 就會向處理器發送一條 Lock 前綴的指令,將這個變量所在快取行的數據寫回到系統內存。但是,就算寫回到內存,如果其他處理器快取的值還是舊的,再進行計算操作就會有問題。所以,多處理器下,要實行快取一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己快取的值是不是過期,如果過期,就將當前處理器的快取行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器快取中。
2. volatile 使用優化#
將共享變量追加到 64 字節 (貌似不生效了,在源碼中沒看到)
LinkedTransferQueue
一些處理器的 Cache 的高速快取行是 64 字節,不支持部分填充快取行。通過追加到 64 字節的方式來填滿高速快取的快取行,避免頭結點和尾結點加載到同一個快取行,使頭、尾結點在修改時不會互相鎖定。
並不是所有使用 volatile 變量的時候都要追加到 64 字節
- 快取行非 64 字節寬的處理器不適用
- 共享變量不會被頻繁讀寫的情況不適用,反而會因為追加字節導致性能消耗增加
二、synchronized 的實現原理與應用(重量級鎖)#
synchronize 基礎:java 中的每個對象都可以作為鎖。具體表現為 3 種形式:
- 對於普通同步方法,鎖是當前實例對象
- 對於靜態同步方法,鎖是當前類的 class 對象
- 對於同步方法塊,鎖是 synchronize 括號裡的對象
鎖到底存在哪裡?鎖裡面會存儲什麼信息?・
Synchronized 在 JVM 中的實現原理#
JVM 基於進入和退出Monitor
對象來實現方法同步和代碼塊同步,但兩者的表現細節不同。
代碼塊同步使用monitorenter
和monitorexit
指令實現,而方法同步是使用另外一種實現方式實現的,細節並沒有在 JVM 中說明。但是,方法同步同樣可以使用上述兩個指令實現。
monitorenter
指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit
是插入到方法結束處和異常處,JVM 要保證每個monitorenter
必須有對應的monitorexit
與之配對。- 任何對象都有一個
monitor
與之關聯,且當一個monitor
被持有後,它將處於鎖定狀態。線程執行到monitorenter
指令時,將會嘗試獲取對象所對應的monitor
的所有權,即嘗試獲得對象的鎖。
同步方法使用ACC_SYNCHRONIZED
標識來實現。即 JVM 通過在方法訪問標識符 (flags
) 中加入 **ACC_SYNCHRONIZED
來實現同步功能。同步方法會在 class 文件中的access_flags
中存放ACC_SYNCHRONIZED
**,而access_flags
是存放在常量池的
同步方法是隱式的。一個同步方法會在運行時常量池中的method_info結構體中存放ACC_SYNCHRONIZED標識符。當一個線程訪問方法時,會去檢查是否存在ACC_SYNCHRONIZED標識,如果存在,則先要獲得對應的monitor鎖,然後執行方法。當方法執行結束 (不管是正常return還是拋出異常) 都會釋放對應的monitor鎖。如果此時有其他線程也想要訪問這個方法時,會因得不到monitor鎖而阻塞。當同步方法中拋出異常且方法內沒有捕獲,則在向外拋出時會先釋放已獲得的monitor鎖
monitor#
管程 (英語:Monitors,也稱為監視器) 是一種程序結構,結構內的多個子程序(對象或模塊)形成的多個工作線程互斥訪問共享資源。
這是一個概念,目的是為了簡化同步調用的過程,封裝了同步操作,避免直接使用 PV 信號量。在 java 中的具體實現就是ObjectMonitor
ObjcetMonitor的關鍵字段
_count
:記錄 owner 線程獲取鎖的次數。這句話很好理解,這也決定了 synchronized 是可重入的。_owner
:指向擁有該對象的線程_WaitSet
:存放處於 wait 狀態的線程隊列。_EntryList
:存放等待鎖而被 block 的線程隊列。
- 想要獲取 monitor 的線程先進入 monitor 的__EntryList 隊列阻塞等待
- 如果在程序裡調用了 wait () 方法,則該線程進入_WaitSet 隊列,wait () 會釋放 monitor 鎖,即將_owner 賦值為 null 並進入_WaitSet 隊列阻塞等待
- 當程序裡其他線程調用了 notify/notifyAll 方法時,就會喚醒_WaitSet 中的某個線程,這個線程就會再次嘗試獲取 monitor 鎖。如果成功,則就會成為 monitor 的 owner。
具體實現
這個網上有很多相關實現方法。
1、java 對象頭#
synchronized
用的鎖是存在 java 對象頭裡
數組類型 3 個字寬(3*4 字節)
非數字類型 2 個字寬(2*4 字節)
mark word 裡存儲的數據會隨著鎖標誌位的變化而變化
64 位虛擬機下的存儲結構
2、鎖的升級與對比#
from JDK1.6
鎖一共有 4 中狀態,由低到高為:無鎖、偏向鎖、輕量級鎖、重量級鎖,這幾個狀態會隨著競爭情況逐漸升級。
鎖可以升級但不能降級(提高獲得鎖和釋放鎖的效率)
偏向鎖:#
大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低,引入了偏向鎖。
當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀的鎖記錄裡存儲鎖偏向的線程ID
,以後該線程在進入和退出同步塊時不需要進行 CAS 操作來加鎖和解鎖,只需簡單測試對象頭裡的mark word
裡是否存儲著指向當前線程的偏向鎖。
如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下mark word
中偏向鎖的標識是否設置成 1:如果沒有設置,則使用CAS競爭鎖
;如果設置了,則嘗試使用 CAS 將對象頭的偏向鎖指向當前線程。
(1)偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間上沒有正在執行的字節碼)。
首先 == 暫停 == 擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活著,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的mark word
要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作為偏向鎖。
這一塊還是不清楚,得上網再看看
如果另外的線程和現有線程競爭偏向鎖,是如何判斷是否繼續偏向的?
(2)關閉偏向鎖
java6、7 默認啟用偏向鎖,但是在程序啟動後會有幾秒延遲,如有必要可以關閉延遲
-XX:BiasedLockingStartupDelay=0
, 如果確定程序裡所有的鎖通常處於競爭狀態,可以通過 JVM 參數關閉偏向鎖:-XX:UseBaisedLocking=false
, 那麼程序會默認進入輕量級鎖狀態
輕量級鎖#
(1)加鎖
線程在執行同步塊之前,JVM 會先在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark word
複製到鎖記錄中(Displaced Mark World
)。然後線程嘗試使用 CAS 將對象頭中的Mark word
替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
(2)解鎖
解鎖時,會使用原子的 CAS 操作將Displaced Mark Word
替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
因為自旋會消耗 CPU,為了避免無用的自旋,一旦升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他線程試圖獲取鎖時,都会被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
3、鎖的優缺點對比#
-
偏向鎖:
優點:加鎖解鎖不需要額外的消耗
缺點:如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 適用場景:適用於只有一個線程訪問同步塊的場景
-
輕量級鎖:
優點:競爭的線程不會阻塞,提高了程序的響應速度
缺點:如果始終得不到鎖競爭的線程,使用自旋會消耗 CPU
使用場景:追求響應時間,同步執行速度非常快 -
重量級鎖:
優點:線程競爭不使用自旋,不會消耗 CPU
缺點:線程阻塞,響應時間慢
適用場景:追求吞吐量,同步塊執行時間較長
自我理解#
-
偏向鎖一開始指向持有鎖的線程,之後出現鎖競爭後撤銷偏向鎖,偏向鎖是否會進化到輕量級鎖存疑。
-
輕量級鎖一開始需要複製一份 markworld 內容(即 hashcode 或者其他所鎖信息)到本地線程棧幀,然後 markworld 修改為指向線程的指針
-
如果出現鎖競爭,markworld 將膨脹為重量級鎖
三、原子操作的實現原理#
1、術語#
CAS:比較並交換
快取行:快取的最小操作單位
內存順序衝突:一般由假共享引起,出現內存順序衝突時,CPU必須清空流水線
假共享:多個CPU同時修改同一快取行的不同部分而引起其中一個CPU的操作無效
2、處理器實現原子操作#
(1)通過總線鎖保證原子性
如果多個處理器同時對共享變量進行讀改寫操作(i++),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一致。
總線鎖:當一個處理器在總線上輸出LOCK#
信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨占共享內存。
(2)使用快取鎖保證原子性
總線鎖定把 CPU 和內存之間的通信鎖住了,鎖定期間,其他處理器不能操作其他內存地址的數據,因此總線鎖定的開銷比較大。
快取鎖定:內存區域如果被緩存在處理器的快取行中,並且在LOCK
期間被鎖定,那麼當他執行鎖操作會寫到內存時,處理器不在總線上聲言LOCK #
信號,而是修改內部的內存地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改由兩個以上處理器快取的內存區域數據,當其他處理器回寫已被鎖定的快取行的數據時,會使快取行無效。
兩種情況不會使用快取鎖定:
1、數據不能被緩存在處理器內部,或操作的數據跨多個快取行,此時用總線鎖定
2、有些處理器不支持快取鎖定
3、Java 實現原子操作(鎖和循環 CAS)#
(1)循環 CAS 機制
處理器的CMPXCHG
指令
自旋 CAS:循環進行 CAS 操作直至成功為止
CAS 實現原子操作的三大問題:
-
ABA 問題:A 到 B 再到 A,CAS 檢查值時會以為沒有發生變化,實際卻發生了變化,解決方式是在變量前面追加版本號:1A 到 2B 到 3C
AtomicStampedReference 類解決方法:
-
循環時間長開銷大:自旋 CAS 如果長時間不成功,會給 CPU 帶來非常大的執行開銷。
-
只能保證一個共享變量的原子操作:此時用鎖或者將幾個共享變量合併
(2)鎖機制
除了偏向鎖,另外兩種鎖都使用了循環 CAS 機制,即當一個線程進入同步塊的時候使用循環 CAS 的方式獲取鎖,當他退出同步塊的時候使用循環 CAS 釋放鎖。