Java核心(三)併發中的執行緒同步與鎖
樂觀鎖、悲觀鎖、公平鎖、自旋鎖、偏向鎖、輕量級鎖、重量級鎖、鎖膨脹...難理解?不存的!來,話不多說,帶你飆車。
上一篇介紹了執行緒池的使用,在享受執行緒池帶給我們的效能優勢之外,似乎也帶來了另一個問題:執行緒安全的問題。
那什麼是執行緒的安全問題呢?
一、執行緒安全問題的產生
執行緒安全問題:指的是在多執行緒程式設計中,同時操作同一個可變的資源之後,造成的實際結果與預期結果不一致的問題。
比如:A和B同時向C轉賬10萬元。如果轉賬操作不具有原子性,A在向C轉賬時,讀取了C的餘額為20萬,然後加上轉賬的10萬,計算出此時應該有30萬,但還未來及將30萬寫回C的賬戶,此時B的轉賬請求過來了,B發現C的餘額為20萬,然後將其加10萬並寫回。然後A的轉賬操作繼續——將30萬寫回C的餘額。這種情況下C的最終餘額為30萬,而非預期的40萬。
如果上面的內容您還沒有理解,沒關係,我們來看下面非安全執行緒的模擬程式碼:
public class ThreadSafeSample {
public int number;
public void add() {
for (int i = 0; i < 100000; i++) {
int former = number++;
int latter = number;
if (former != latter-1){
System.out.printf("非相等 former=" + former + " latter=" + latter);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeSample threadSafeSample = new ThreadSafeSample();
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
threadSafeSample.add();
}
});
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
threadSafeSample.add();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
複製程式碼
我電腦執行的結果: 非相等 => former=5555 latter=6061
可以看到,僅僅是兩個執行緒的低度併發,就非常容易碰到 former 和 latter 不相等的情況。這是因為,在兩次取值的過程中,其他執行緒可能已經修改了number.
二、執行緒安全的解決方案
執行緒安全的解決方案分為以下幾個維度(參考《碼出高效:Java開發手冊》):
- 資料單執行緒可見(單執行緒操作自己的資料是不存線上程安全問題的,ThreadLocal就是採用這種解決方案);
- 資料只讀;
- 使用執行緒安全類(比如StringBuffer就是一個執行緒安全類,內部是使用synchronized實現的);
- 同步與鎖機制;
解決執行緒安全核心思想是:“要麼只讀,要麼加鎖”,解決執行緒安全的關鍵在於合理的使用Java提供的執行緒安全包java.util.concurrent簡稱JUC。
三、執行緒同步與鎖
Java 5 以前,synchronized是僅有的同步手段,Java 5的時候增加了ReentrantLock(再入鎖)它的語義和synchronized基本相同,比synchronized更加靈活,可以做到更多的細節控制,比如鎖的公平性/非公平性指定。
3.1 synchronized
synchronized 是 Java 內建的同步機制,它提供了互斥的語義和可見性,當一個執行緒已經獲取當前鎖時,其他試圖獲取的執行緒只能等待或者阻塞在那裡。
3.1.1 synchronized 使用
synchronized 可以用來修飾方法和程式碼塊。
3.1.1.1 修飾程式碼塊
synchronized (this) {
int former = number++;
int latter = number;
//...
}
複製程式碼
3.1.1.2 修飾方法
public synchronized void add() {
//...
}
複製程式碼
3.1.2 synchronized 底層實現原理
synchronized 是由一對 monitorenter/monitorexit 指令實現的,Monitor 物件是同步的基本實現單元。在 Java 6 之前,Monitor的實現完全是依靠作業系統內部的互斥鎖,因為需要進行使用者態到核心態的切換,所以同步操作是一個無差別的重量級操作,效能也很低。但在Java 6的時候,JVM 對此進行了大刀闊斧地改進,提供了三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏向鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其效能。
3.1.2.1 偏向鎖/輕量級鎖/重量級鎖
偏向鎖是為了解決在沒有多執行緒的訪問下,儘量減少鎖帶來的效能開銷。
輕量級鎖是指當鎖是偏向鎖的時候,被另一個執行緒所訪問,偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,提高效能。
重量級鎖是指當鎖為輕量級鎖的時候,另一個執行緒雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的執行緒進入阻塞,效能降低。
3.1.2.2 鎖膨脹(升級)原理
Java 6 之後優化了 synchronized 實現方式,使用了偏向鎖升級為輕量級鎖再升級到重量級鎖的方式,減低了鎖帶來的效能消耗,也就是我們常說的鎖膨脹或者叫鎖升級,那麼它是怎麼實現鎖升級的呢?
鎖膨脹(升級)原理: 在鎖物件的物件頭裡面有一個ThreadId欄位,在第一次訪問的時候ThreadId為空,JVM讓其持有偏向鎖,並將ThreadId設定為其執行緒id,再次進入的時候會先判斷ThreadId是否尤其執行緒id一致,如果一致則可以直接使用,如果不一致,則升級偏向鎖為輕量級鎖,通過自旋迴圈一定次數來獲取鎖,不會堵塞,執行一定次數之後就會升級為重量級鎖,進入堵塞,整個過程就是鎖膨脹(升級)的過程。
3.1.2.3 自旋鎖
自旋鎖是指嘗試獲取鎖的執行緒不會立即阻塞,而是採用迴圈的方式去嘗試獲取鎖,這樣的好處是減少執行緒上下文切換的消耗,缺點是迴圈會消耗CPU。
3.1.2.4 樂觀鎖/悲觀鎖
悲觀鎖和樂觀鎖並不是某個具體的“鎖”而是一種是併發程式設計的基本概念。
悲觀鎖認為對於同一個資料的併發操作,一定是會發生修改的,哪怕沒有修改,也會認為修改。因此對於同一個資料的併發操作,悲觀鎖採取加鎖的形式。悲觀的認為,不加鎖的併發操作一定會出問題。
樂觀鎖則與 Java 併發包中的 AtomicFieldUpdater 類似,也是利用 CAS 機制,並不會對資料加鎖,而是通過對比資料的時間戳或者版本號,來實現樂觀鎖需要的版本判斷。
3.1.2.5 公平鎖/非公平鎖
公平鎖是指多個執行緒按照申請鎖的順序來獲取鎖。
非公平鎖是指多個執行緒獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的執行緒比先申請的執行緒優先獲取鎖。
如果使用 synchronized 使用的是非公平鎖,是不可設定的,這也是主流作業系統執行緒排程的選擇。通用場景中,公平性未必有想象中的那麼重要,Java 預設的排程策略很少會導致 “飢餓”發生。非公平鎖的吞吐量大於公平鎖。
非公平鎖吞吐量大於公平鎖的原因:
比如A佔用鎖的時候,B請求獲取鎖,發現被A佔用之後,堵塞等待被喚醒,這個時候C同時來獲取A佔用的鎖,如果是公平鎖C後來者發現不可用之後一定排在B之後等待被喚醒,而非公平鎖則可以讓C先用,在B被喚醒之前C已經使用完成,從而節省了C等待和喚醒之間的效能消耗,這就是非公平鎖比公平鎖吞吐量大的原因。
3.2 ReentrantLock
ReentrantLock只能修飾程式碼塊,使用ReentrantLock必須手動unlock釋放鎖,不然鎖永遠會被佔用。
3.2.1 ReentrantLock 使用
ReentrantLock reentrantLock = new ReentrantLock(true); // 設定為true為公平鎖,預設是非公平鎖
reentrantLock.lock();
try {
}finally {
reentrantLock.unlock();
}
複製程式碼
3.2.2 ReentrantLock 優勢
-
具備嘗試非阻塞地獲取鎖的特性:當前執行緒嘗試獲取鎖,如果這一時刻鎖沒有被其他執行緒獲取到,則成功獲取並持有鎖;
-
能被中斷地獲取鎖的特性:與synchronized不同,獲取到鎖的執行緒能夠響應中斷,當獲取到鎖的執行緒被中斷時,中斷異常將會被丟擲,同時鎖會被釋放;
-
超時獲取鎖的特性:在指定的時間範圍內獲取鎖;如果截止時間到了仍然無法獲取鎖則返回。
3.2.3 ReentrantLock 注意事項
- 在finally中釋放鎖,目的是保證在獲取鎖之後,最終能夠被釋放;
- 不要將獲取鎖的過程寫在try塊內,因為如果在獲取鎖時發生了異常,異常丟擲的同時,也會導致鎖無故被釋放;
- ReentrantLock提供了一個newCondition的方法,以便使用者在同一鎖的情況下可以根據不同的情況執行等待或喚醒的動作;
3.3 synchronized和ReentrantLock區別
從效能角度,synchronized 早期的實現比較低效,對比 ReentrantLock,大多數場景效能都相差較大。但是在 Java 6 中對其進行了非常多的改進,在高競爭情況下,ReentrantLock 仍然有一定優勢。在大多數情況下,無需太糾結於效能,還是考慮程式碼書寫結構的便利性、可維護性等。
主要區別如下:
- ReentrantLock使用起來比較靈活,但是必須有釋放鎖的配合動作;
- ReentrantLock必須手動獲取與釋放鎖,而synchronized不需要手動釋放和開啟鎖;
- ReentrantLock只適用於程式碼塊鎖,而synchronized可用於修飾方法、程式碼塊等;
參考資料
《碼出高效:Java開發手冊》
Java核心技術36講:t.cn/EwUJvWA
Java中的鎖分類:www.cnblogs.com/qifengshi/p…
課程推薦: