我在寫《併發場景下資料寫入功能的實現》時提過,在併發場景下,如果存在資料競爭,則需要用鎖來保證執行緒安全。鎖會增加編碼的複雜度,也會降低程式碼的執行效率,還潛在死鎖、活鎖等隱患。活鎖,通常加個隨機的等待時間,做幾次重試就可以避免,故本對“鎖的使用”和“死鎖的避免”做近一步說明。
1. 鎖的使用
鎖的使用套路是:
- 在訪問共享資源之前,先獲取鎖
- 如果獲取鎖成功,則訪問共享資源
- 訪問結束,釋放鎖,以便其他執行緒繼續訪問共享資源
對程式碼上鎖,可以使用java 關鍵字synchronized
或java併發包中的Lock
,如:
private Object lock = new Object();
public void visitShareResWithLock() {
synchronized (lock) {
// 在這裡安全的訪問共享資源
}
}
private Lock lock = new ReentrantLock();
public void visitShareResWithLock() {
lock.lock();
try {
// 在這裡安全的訪問共享資源
} finally {
lock.unlock();
}
}
再次強調,鎖的使用是有代價的:
- 加鎖和解鎖過程,需要CPU時間,有效能損耗;鎖的使用可能會帶來執行緒等待,降低程式的執行效能
- 鎖的使用不當,會造成死鎖,且除錯不便。
故鎖的使用,一定要注意鎖的釋放,且只有在併發環境中,共享資源不支援併發訪問,或者說併發訪問共享資源會導致系統錯誤的情況下,才需要使用鎖。
具體來說,鎖的使用,要注意以下幾點:
(1) 一定要 unlock
synchronized 關鍵字,會對程式碼塊加鎖,程式碼塊執行結束,鎖自動釋放,比較方便,效能不比並發包中的Lock差,推薦使用。 使用併發包中的Lock時,需要顯式呼叫lock()和unlock()語句,要注意使用try{} finally 方式,把unlock()放到finally裡,避免異常時無法解鎖的情況。
另外,像Python等語言,有with lock 語法,簡化鎖的try{} finally寫法,java中預設不支援,如果必要,可以自己封裝實現
(2) 注意鎖的顆粒度
鎖,會讓程式碼從可並行訪問,轉為序列訪問,在多核場景下,降低了程式碼的並行執行效率,影響系統效能。因此,鎖的顆粒度越小越好,以儘量減小程式碼執行的序列度。
(3) 可重入鎖/不可重入鎖
可重入鎖,指一個執行緒,可以多次獲取同一把鎖;而不可重入鎖,指即使是同一個執行緒,當獲取了一把鎖後,如果想再次獲取這把鎖,也會失敗。 如果上鎖的程式碼塊中,存在遞迴,或者多次上鎖的邏輯,一定要確認這段程式碼中用的鎖,是不是可重入的,如果不是,會產生死鎖
Java中常用的synchronized
, ReentrantLock
, ReentrantReadWriteLock
等,都是可重入鎖,比讀寫鎖效能更好的StampedLock
,是不可重入鎖。
(4) 公平鎖/非公平鎖
公平鎖,指多個執行緒按照申請鎖的順序去獲得鎖,所有執行緒都在佇列裡排隊,這樣就保證了佇列中的第一個先得到鎖。 公平鎖的優點是,所有的執行緒能依次得到資源,不會餓死在佇列中;缺點是,會增加一個維護排隊佇列的開銷
非公平鎖,指多個執行緒不按照申請鎖的順序去獲得鎖,而是同時搶鎖,搶到則執行緒繼續往下執行,搶不到則等待,下次被喚醒時再次搶鎖 非公平鎖的優點是,整體開銷比公平鎖要低一點,缺點是,可能某個執行緒長時間獲取不到鎖。
實際應用中,如果業務場景不要求嚴格的公平,通常使用非公平鎖就夠了。
2. 死鎖的避免
2.1 不用上鎖
如果沒有鎖,則不用擔心死鎖問題。上鎖是因為存在資料競爭,如果能夠透過一些變通方式避免資料競爭,則不需要考慮鎖的使用及死鎖問題。如之前文章中提到的併發場景下寫入流水碼的業務,如果透過讀取資料庫中最新流水碼資料來決定下一個流水碼的大小,會存在對資料範圍的競爭,需要上鎖實現,而如果把流水碼的計算轉移到單執行緒的redis中去,則避免了資料競爭,這樣就不需要程式碼中進行上鎖。
2.2 加鎖解鎖放在同一方法中,注意確保鎖的釋放
依然是下面這段程式碼,再次強調,若使用lock(),則一定要在finally裡進行unlock(),確保程式碼正常執行和異常退出時,都能把鎖釋放掉。
private Lock lock = new ReentrantLock();
public void visitShareResWithLock() {
lock.lock();
try {
// 在這裡安全的訪問共享資源
} finally {
lock.unlock();
}
}
2.3 注意一把鎖時的死鎖
下面這段程式碼,只用到一個鎖,會產生死鎖麼?
public void visitShareResWithLock() {
lock.lock(); // 獲取鎖
try {
lock.lock(); // 再次獲取鎖,會導致死鎖嗎?
} finally {
lock.unlock();
}
上文對此已做過分析,是否會死鎖,取決於程式碼中的鎖是不是可重入鎖;是,則不會死鎖,否,則程式碼會卡在第二個上鎖語句上,等待鎖的釋放,一直等下去。
再次說明,一段程式碼中,若只上鎖一次,或多次上鎖間沒有巢狀,不會產生死鎖;若多次巢狀上鎖(如遞迴,或呼叫其他上鎖的方法等),則要注意鎖是不是可重入鎖,避免死鎖的發生。
2.4 持有多把鎖時,格外注意鎖的持有和釋放
當業務邏輯需要對多個資源上鎖時,是最複雜也最容易出現死鎖的,要格外注意,同時儘量避免持有為多個資源上鎖的業務。
本節以“轉賬業務”為依託,說明持有多把鎖時,如何避免死鎖。
public class Account {
private final long id;
private int balance;
private final Object dummyLock = new Object();
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return this.balance;
}
public void transfer(Account target, int amount){
if (this.balance > amount) {
this.balance -= amount;
target.balance += amount;
}
}
如上面程式碼,若兩個Account
物件(account1, account2)進行轉賬,呼叫account1.transfer(account2, 100)方法可以實現。但在併發場景下,若同時存在多筆轉賬交易,由於資料競爭,需要上鎖來保證執行緒安全。考慮到鎖的顆粒度,我們可以最好把鎖加在物件上,而不是加在類上,由於account1和account2兩個物件都存在資料競爭,所以兩個物件都要加鎖,故上鎖後的程式碼如下:
public void transferWithDeadLock(Account target, int amount) {
synchronized (this.dummyLock) {
synchronized (target.dummyLock) {
transfer(target, amount);
}
}
}
這段程式碼是存在“死鎖”隱患的。若併發進行兩個賬號的互相轉賬,即併發呼叫account1.transfer(account2, 100)和account2.transfer(account1, 100),可能發生的情況是,account1鎖住account1.dummyLock時,account2鎖住了account2.dummyLock,此時,account1 繼續往下執行,嘗試對account2.dummyLock
加鎖時,由於account2.dummyLock
已經被account2加鎖了,account1的執行緒會進入等待,同理,account2的執行緒也會進入等待,而且兩個執行緒各自持有的鎖在等待時也不會釋放,從而產生死鎖,兩個執行緒會一直等待下去。
參考這個死鎖的分析,通常避免死鎖有以下幾種套路:
- 注意加鎖/解鎖順序
- 加鎖超時釋放
- 加鎖中斷釋放
- 多把鎖轉換為一把鎖進行加鎖
本文以“注意加鎖順序”為例,解決死鎖問題:
如上面死鎖的分析,方法中,都是先對自己上鎖,然後對target上鎖,即account1 先對account1 上鎖,然後在對account2 上鎖,而account2 是先對account2上鎖,再對account1上鎖,這造成了迴圈等待,從而死鎖;若兩個物件上鎖順序一致,如都先對account1 上鎖,再對account2 上鎖,就對迴圈解套了,可以避免死鎖。
所以這裡,我們對Account類做個改造,加一個自增的id欄位,上鎖統一按id從小到大的順序上鎖,就可以避免死鎖了:
public void transferSafeWithLockInOrder(Account target, int amount) {
Account left = this.id < target.id ? this : target;
Account right = this.id < target.id ? target : this;
synchronized (left.dummyLock) {
synchronized (right.dummyLock) {
transfer(target, amount);
}
}
}
考慮到本文篇幅,持有多把鎖時,如何避免死鎖並未進一步展開,有興趣的同學可以檢視實戰:併發轉賬業務中避免死鎖的各種方法
What's More
本文同步發表於我的微信公眾號,歡迎關注。
注:轉載本文,請與Gevin聯絡
歡迎關注我的微信公眾賬號