1. 程式人生 > >Java高效併發之樂觀鎖悲觀鎖、(互斥同步、非互斥同步)

Java高效併發之樂觀鎖悲觀鎖、(互斥同步、非互斥同步)

樂觀鎖和悲觀鎖

首先我們理解下兩種不同思路的鎖,樂觀鎖和悲觀鎖
這兩種鎖機制,是在多使用者環境併發控制的兩種所機制。下面看百度百科對樂觀鎖和悲觀鎖兩種鎖機制的定義:

樂觀鎖( Optimistic Locking ) 相對悲觀鎖而言,樂觀鎖機制採取了更加寬鬆的加鎖機制。悲觀鎖大多數情況下依靠資料庫的鎖機制實現,以保證操作最大程度的獨佔性。但隨之而來的就是資料庫效能的大量開銷,特別是對長事務而言,這樣的開銷往往無法承受。而樂觀鎖機制在一定程度上解決了這個問題。樂觀鎖,大多是基於資料版本( Version )記錄機制實現。何謂資料版本?即為資料增加一個版本標識,在基於資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 “version” 欄位來實現。讀取出資料時,將此版本號一同讀出,之後更新時,對此版本號加一。此時,將提交資料的版本資料與資料庫表對應記錄的當前版本資訊進行比對,如果提交的資料版本號大於資料庫表當前版本號,則予以更新,否則認為是過期資料。
悲觀鎖

(Pessimistic Lock),正如其名,具有強烈的獨佔和排他特性。它指的是對資料被外界(包括本系統當前的其他事務,以及來自外部系統的事務處理)修改持保守態度,因此,在整個資料處理過程中,將資料處於鎖定狀態。悲觀鎖的實現,往往依靠資料庫提供的鎖機制(也只有資料庫層提供的鎖機制才能真正保證資料訪問的排他性,否則,即使在本系統中實現了加鎖機制,也無法保證外部系統不會修改資料)。

簡而言之:
悲觀鎖:假定會發生併發衝突,遮蔽一切可能違反資料完整性的操作。[1]
樂觀鎖:假設不會發生併發衝突,只在提交操作時檢查是否違反資料完整性。[1] 樂觀鎖不能解決髒讀的問題。
Java中的樂觀鎖和悲觀鎖:我們都知道,cpu是時分複用的,也就是把cpu的時間片,分配給不同的thread/process輪流執行,時間片與時間片之間,需要進行cpu切換,也就是會發生程序的切換。切換涉及到清空暫存器,快取資料。然後重新載入新的thread所需資料。當一個執行緒被掛起時,加入到阻塞佇列,在一定的時間或條件下,在通過notify(),notifyAll()喚醒回來。在某個資源不可用的時候,就將cpu讓出,把當前等待執行緒切換為阻塞狀態。等到資源(比如一個共享資料)可用了,那麼就將執行緒喚醒,讓他進入runnable狀態等待cpu排程。這就是典型的悲觀鎖的實現。獨佔鎖是一種悲觀鎖,synchronized就是一種獨佔鎖,它假設最壞的情況,並且只有在確保其它執行緒不會造成干擾的情況下執行,會導致其它所有需要鎖的執行緒掛起,等待持有鎖的執行緒釋放鎖。
但是,由於在程序掛起和恢復執行過程中存在著很大的開銷。當一個執行緒正在等待鎖時,它不能做任何事,所以悲觀鎖有很大的缺點。舉個例子,如果一個執行緒需要某個資源,但是這個資源的佔用時間很短,當執行緒第一次搶佔這個資源時,可能這個資源被佔用,如果此時掛起這個執行緒,可能立刻就發現資源可用,然後又需要花費很長的時間重新搶佔鎖,時間代價就會非常的高。
所以就有了樂觀鎖的概念,他的核心思路就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止。在上面的例子中,某個執行緒可以不讓出cpu,而是一直while迴圈,如果失敗就重試,直到成功為止。所以,當資料爭用不嚴重時,樂觀鎖效果更好。比如CAS就是一種樂觀鎖思想的應用。
JDK1.5中引入了底層的支援,在int、long和物件的引用等型別上都公開了CAS的操作,並且JVM把它們編譯為底層硬體提供的最有效的方法,在執行CAS的平臺上,執行時把它們編譯為相應的機器指令。在java.util.concurrent.atomic包下面的所有的原子變數型別中,比如AtomicInteger,都使用了這些底層的JVM支援為數字型別的引用型別提供一種高效的CAS操作。
在CAS操作中,會出現ABA問題。就是如果V的值先由A變成B,再由B變成A,那麼仍然認為是發生了變化,並需要重新執行演算法中的步驟。有簡單的解決方案:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號,即使這個值由A變為B,然後為變為A,版本號也是不同的。AtomicStampedReference和AtomicMarkableReference支援在兩個變數上執行原子的條件更新。AtomicStampedReference更新一個“物件-引用”二元組,通過在引用上加上“版本號”,從而避免ABA問題,AtomicMarkableReference將更新一個“物件引用-布林值”的二元組。

引用《深入理解Java虛擬機器第二版》中原文:

13.2.2 執行緒安全的實現方法
瞭解了什麼是執行緒安全之後,緊接著的一個問題就是我們應該如何實現執行緒安全,這聽
起來似乎是一件由程式碼如何編寫來決定的事情,確實,如何實現執行緒安全與程式碼編寫有很大
的關係,但虛擬機器提供的同步和鎖機制也起到了非常重要的作用。本節中,程式碼編寫如何實
現執行緒安全和虛擬機器如何實現同步與鎖這兩者都會有所涉及,相對而言更偏重後者一些,只
要讀者瞭解了虛擬機器執行緒安全手段的運作過程,自己去思考程式碼如何編寫並不是一件困難的
事情。
1.互斥同步
互斥同步(Mutual Exclusion&Synchronization)是常見的一種併發正確性保障手段。同步


是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一個(或者是一些,
使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區(Critical
Section)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式。因此,在這
4個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。
在Java中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯
之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令,這兩個位元組
碼都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java程式中的
synchronized明確指定了物件引數,那就是這個物件的reference;如果沒有明確指定,那就根
據synchronized修飾的是例項方法還是類方法,去取對應的物件例項或Class物件來作為鎖對
象。
根據虛擬機器規範的要求,在執行monitorenter指令時,首先要嘗試獲取物件的鎖。如果這
個物件沒被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1,相應的,在
執行monitorexit指令時會將鎖計數器減1,當計數器為0時,鎖就被釋放。如果獲取物件鎖失
敗,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。
在虛擬機器規範對monitorenter和monitorexit的行為描述中,有兩點是需要特別注意的。首
先,synchronized同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題。其
次,同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。第12章講過,Java的
執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來
幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間。
對於程式碼簡單的同步塊(如被synchronized修飾的getter()或setter()方法),狀態轉換消
耗的時間有可能比使用者程式碼執行的時間還要長。所以synchronized是Java語言中一個重量級
(Heavyweight)的操作,有經驗的程式設計師都會在確實必要的情況下才使用這種操作。而虛擬
機本身也會進行一些優化,譬如在通知作業系統阻塞執行緒之前加入一段自旋等待過程,避免
頻繁地切入到核心態之中。
除了synchronized之外,我們還可以使用java.util.concurrent(下文稱J.U.C)包中的重入鎖
(ReentrantLock)來實現同步,在基本用法上,ReentrantLock與synchronized很相似,他們都
具備一樣的執行緒重入特性,只是程式碼寫法上有點區別,一個表現為API層面的互斥鎖
(lock()和unlock()方法配合try/finally語句塊來完成),另一個表現為原生語法層面的互
斥鎖。不過,相比synchronized,ReentrantLock增加了一些高階功能,主要有以下3項:等待可
中斷、可實現公平鎖,以及鎖可以繫結多個條件。
等待可中斷是指當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等
待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而
非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。
synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布林
值的建構函式要求使用公平鎖。
鎖繫結多個條件是指一個ReentrantLock物件可以同時繫結多個Condition物件,而在
synchronized中,鎖物件的wait()和notify()或notifyAll()方法可以實現一個隱含的條
件,如果要和多於一個的條件關聯的時候,就不得不額外地新增一個鎖,而ReentrantLock則
無須這樣做,只需要多次呼叫newCondition()方法即可。
如果需要使用上述功能,選用ReentrantLock是一個很好的選擇,那如果是基於效能考慮
呢?關於synchronized和ReentrantLock的效能問題,Brian Goetz對這兩種鎖在JDK 1.5與單核處
理器,以及JDK 1.5與雙Xeon處理器環境下做了一組吞吐量對比的實驗 [1] ,實驗結果如圖13-1
和圖13-2所示。
圖 13-1 JDK 1.5、單核處理器下兩種鎖的吞吐量對比
從圖13-1和圖13-2可以看出,多執行緒環境下synchronized的吞吐量下降得非常嚴重,而
ReentrantLock則能基本保持在同一個比較穩定的水平上。與其說ReentrantLock效能好,還不
如說synchronized還有非常大的優化餘地。後續的技術發展也證明了這一點,JDK 1.6中加入
了很多針對鎖的優化措施(13.3節我們就會講解這些優化措施),JDK 1.6釋出之後,人們就
發現synchronized與ReentrantLock的效能基本上是完全持平了。因此,如果讀者的程式是使用
JDK 1.6或以上部署的話,效能因素就不再是選擇ReentrantLock的理由了,虛擬機器在未來的性
能改進中肯定也會更加偏向於原生的synchronized,所以還是提倡在synchronized能實現需求
的情況下,優先考慮使用synchronized來進行同步。
圖 13-2 JDK 1.5、雙Xeon處理器下兩種鎖的吞吐量對比
2.非阻塞同步
互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也稱
為阻塞同步(Blocking Synchronization)。從處理問題的方式上說,互斥同步屬於一種悲觀的
併發策略,總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題,無論
共享資料是否真的會出現競爭,它都要進行加鎖(這裡討論的是概念模型,實際上虛擬機器會
優化掉很大一部分不必要的加鎖)、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞
的執行緒需要喚醒等操作。隨著硬體指令集的發展,我們有了另外一個選擇:基於衝突檢測的
樂觀併發策略,通俗地說,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成
功了;如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施
就是不斷地重試,直到成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,
因此這種同步操作稱為非阻塞同步(Non-Blocking Synchronization)。

為什麼筆者說使用樂觀併發策略需要“硬體指令集的發展”才能進行呢?因為我們需要操
作和衝突檢測這兩個步驟具備原子性,靠什麼來保證呢?如果這裡再使用互斥同步來保證就
失去意義了,所以我們只能靠硬體來完成這件事情,硬體保證一個從語義上看起來需要多次
操作的行為只通過一條處理器指令就能完成,

這類指令常用的有:
測試並設定(Test-and-Set)。
獲取並增加(Fetch-and-Increment)。
交換(Swap)。
比較並交換(Compare-and-Swap,下文稱CAS)。
載入連結/條件儲存(Load-Linked/Store-Conditional,下文稱LL/SC)。


其中,前面的3條是20世紀就已經存在於大多數指令集之中的處理器指令,後面的兩條

是現代處理器新增的,而且這兩條指令的目的和功能是類似的。在IA64、x86指令集中有
cmpxchg指令完成CAS功能,在sparc-TSO也有casa指令實現,而在ARM和PowerPC架構下,
則需要使用一對ldrex/strex指令來完成LL/SC的功能。
CAS指令需要有3個運算元,分別是記憶體位置(在Java中可以簡單理解為變數的記憶體地
址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當
V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新
了V的值,都會返回V的舊值,上述的處理過程是一個原子操作。
在JDK  1.5之後,Java程式中才可以使用CAS操作,該操作由sun.misc.Unsafe類裡面的
compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供,虛擬機器在內部對
這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器CAS指令,沒有方
法呼叫的過程,或者可以認為是無條件內聯進去了 [2] 。
由於Unsafe類不是提供給使用者程式呼叫的類(Unsafe.getUnsafe()的程式碼中限制了只有
啟動類載入器(Bootstrap  ClassLoader)載入的Class才能訪問它),因此,如果不採用反射
手段,我們只能通過其他的Java  API來間接使用它,如J.U.C包裡面的整數原子類,其中的
compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作。
我們不妨拿一段在第12章中沒有解決的問題程式碼來看看如何使用CAS操作來避免阻塞同
步,程式碼如程式碼清單12-1所示。我們曾經通過這段20個執行緒自增10000次的程式碼來證明
volatile變數不具備原子性,那麼如何才能讓它具備原子性呢?把“race++”操作或increase()
方法用同步塊包裹起來當然是一個辦法,但是如果改成如程式碼清單13-4所示的程式碼,那效率
將會提高許多。
程式碼清單13-4 Atomic的原子自增運算
/**
*Atomic變數自增運算測試
*
*@author
*/
public class AtomicTest{
public static AtomicInteger race=new AtomicInteger(0);
public static void increase(){
race.incrementAndGet();
}
private static final int THREADS_COUNT=20;
public static void main(String[]args)throws Exception{
Thread[]threads=new Thread[THREADS_COUNT];
for(int i=0;i<THREADS_COUNT;i++){
threads[i]=new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<10000;i++){
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(race);
}
}
執行結果如下:
200000
使用AtomicInteger代替int後,程式輸出了正確的結果,一切都要歸功於
incrementAndGet()方法的原子性。它的實現其實非常簡單,如程式碼清單13-5所示。
程式碼清單13-5 incrementAndGet()方法的JDK原始碼
/**
*Atomically increment by one the current value.
*@return the updated value
*/
public final int incrementAndGet(){
for(;){
int current=get();
int next=current+1;
if(compareAndSet(current,next))
return next;
}
}
incrementAndGet()方法在一個無限迴圈中,不斷嘗試將一個比當前值大1的新值賦給
自己。如果失敗了,那說明在執行“獲取-設定”操作的時候值已經有了修改,於是再次迴圈
進行下一次操作,直到設定成功為止。
儘管CAS看起來很美,但顯然這種操作無法涵蓋互斥同步的所有使用場景,並且CAS從
語義上來說並不是完美的,存在這樣的一個邏輯漏洞:如果一個變數V初次讀取的時候是A
值,並且在準備賦值的時候檢查到它仍然為A值,那我們就能說它的值沒有被其他執行緒改變
過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認
為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA”問題。J.U.C包為了解決這個問題,
提供了一個帶有標記的原子引用類“AtomicStampedReference”,它可以通過控制變數值的版本
來保證CAS的正確性。不過目前來說這個類比較“雞肋”,大部分情況下ABA問題不會影響程
序併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

鎖優化
高效併發是從JDK 1.5到JDK 1.6的一個重要改進,HotSpot虛擬機器開發團隊在這個版本上
花費了大量的精力去實現各種鎖優化技術,如適應性自旋(Adaptive  Spinning)、鎖消除
(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向
鎖(Biased Locking)等,這些技術都是為了線上程之間更高效地共享資料,以及解決競爭問
題,從而提高程式的執行效率。