我在鎖的使用與死鎖的避免一文中,介紹了需要持有多把鎖時,如何使用和釋放鎖,當時出於文章篇幅和文章結構限制,重點說明了一種方法。本文再次結合轉賬業務,把持有多把鎖的業務場景下,鎖使用的幾種方法,做一個彙總說明。
1. 轉賬業務分析
“轉賬業務”本身容易理解,不用文字贅述,直接看類的程式碼即可:
public class Account {
private final long id;
private int balance;
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)方法可以實現。但在併發場景下,若同時存在多筆轉賬交易,由於資料競爭,需要上鎖來保證執行緒安全。
如果是新手,首先想到的上鎖方式可能是下面這樣的:
public void transfer(Account target, int amount) {
synchronized (this) {
if (this.balance > amount) {
this.balance -= amount;
target.balance += amount;
}
}
}
但這樣做是錯誤的,this
這把鎖可以保護自己的餘額 this.balance
,卻保護不了別人的餘額 target.balance
,如果同時發生A向B轉賬,B向C轉賬,對B餘額的讀寫,還是存在資料競爭的。
用鎖 this
保護 this.balance
和 target.balance
的如上面示意圖,對於A、B、C多人併發轉賬時B的資料競爭,再詳細說明一下:
假設有 A、B、C 三個賬戶,餘額都是 200 元,我們用兩個執行緒分別執行兩個轉賬操作:賬戶 A 轉給賬戶 B 100 元,賬戶 B 轉給賬戶 C 100 元,最後我們期望的結果應該是賬戶 A 的餘額是 100 元,賬戶 B 的餘額是 200 元, 賬戶 C 的餘額是 300 元。
假設執行緒 1 執行賬戶 A 轉賬戶 B 的操作,執行緒 2 執行賬戶 B 轉賬戶 C 的操作,這兩個執行緒分別在兩顆 CPU 上同時執行,
this
鎖並不能做到兩個操作的互斥。因為執行緒 1 鎖定的是賬戶 A 的例項
(A.this)
,而執行緒 2 鎖定的是賬戶 B 的例項(B.this
),所以這兩個執行緒可以同時進入臨界區transfer()
。這時,執行緒 1 和執行緒 2 都會讀到賬戶 B 的餘額為 200,導致最終賬戶 B 的餘額可能是 300,也可能是100,就是不可能是 200:
- 執行緒 1 後於執行緒 2 寫 B.balance,執行緒 2 寫的 B.balance 值被執行緒 1 覆蓋,則300
- 執行緒 1 先於執行緒 2 寫 B.balance,執行緒 1 寫的 B.balance 值被執行緒 2 覆蓋,則100
上面這段文字,對於併發程式設計的新手同學,可能需要多看幾遍,理解後就會發現,上面場景中,B之所以存在資料競爭,是因為this
鎖是鎖在物件上,A向B轉賬時,在B上加了A鎖,B向C轉賬時,在B上加了B鎖,所以兩個執行緒不存在搶同一把鎖的情況,不構成互斥關係,均能進入臨界區;要解決這個問題,就需要A、B共享同一個鎖,如:
class Account {
private Object lock;
private int balance;
private Account();
public Account(Object lock) {
this.lock = lock;
}
void transfer(Account target, int amount){
synchronized(lock) {
if (this.balance > amount) {
this.balance -= amount;
target.balance += amount;
}
}
}
}
這樣確實能解決問題,但是有瑕疵:它要求在建立 A, B兩個物件時傳入同一個鎖物件,否則還是會存在上文分析到的資料競爭。在真實的專案場景中,建立 Account 物件的程式碼很可能分散在多個工程中,傳入共享的 lock 真的很難,故這個方式盡顯幫我們理解鎖的使用,並不實用。
因此在實踐中,以上述分析為依託,需要進一步設計可行的解決方案。
2. 實戰可行的各種死鎖避免方法
2.1 鎖在類上
雖然上一節末尾提到的讓A、B共享同一把鎖的方案不實用,但在實踐中確實存在一把鎖,可以保證被A、B兩個物件共享,即用類作為鎖:
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amount){
synchronized(Account.class) {
if (this.balance > amount) {
this.balance -= amount;
target.balance += amount;
}
}
}
}
Account.class
是所有 Account 物件共享的,而且它是 Java 虛擬機器在載入 Account 類的時候建立的,所以我們不用擔心它的唯一性。這個實踐可行,不過問題在於鎖的顆粒度太大了,即便兩個併發轉賬並不相關,如執行緒1執行A向B轉賬,執行緒2執行C向D轉賬,也會互斥,變為序列,嚴重影響系統效能。
2.2 鎖在物件上
為避免在“鎖在類上”帶來的巨大效能損耗,還是要採用在物件上加鎖的方案,在轉賬業務中,既然一個物件鎖鎖不住A、B兩個物件,那就每個物件用各自的鎖來鎖定,即:
public void transfer(Account target, int amount) {
synchronized (this.getId()) {
synchronized (target.getId()) {
// 為了讓程式碼更清晰,轉賬業務邏輯程式碼獨立到另一個方法中了
transferAccount(target, amount);
}
}
}
protected void transferAccount(Account target, int amount) {
if (this.balance > amount) {
this.balance -= amount;
target.balance += amount;
}
}
這樣,A向B轉賬時,B上加的是B鎖,B向C轉賬時,B上加的也是B鎖,從而避免了B上的資料競爭。 不過這段程式碼時有bug的,存在死鎖隱患。即,若兩個執行緒同時發起A向B的轉賬(執行緒1),和B向A的轉賬(執行緒2),執行緒1獲取A鎖時,執行緒2獲取了B鎖,此時,執行緒1由於獲取不到B鎖,會進入等待,而執行緒2由於獲取不到A鎖,也會進入等待,於是兩個執行緒都會無限等待下去,也就是死鎖。
這段程式碼,在A、B併發互相轉賬時,我們可以用jvm工具檢視死鎖狀態:
命令列可以用jstack檢視,結果如下:
jstack -l 6003
Java stack information for the threads listed above:
===================================================
"pool-1-thread-11":
at org.gevinzone.threadsafe.DeadLockAccount.transfer(DeadLockAccount.java:14)
- waiting to lock <0x00000007156c1078> (a java.lang.Long)
- locked <0x00000007156c1060> (a java.lang.Long)
at org.gevinzone.Main.lambda$concurrentAccountTransfer$0(Main.java:46)
at org.gevinzone.Main$$Lambda$1/81628611.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
"pool-1-thread-8":
at org.gevinzone.threadsafe.DeadLockAccount.transfer(DeadLockAccount.java:14)
- waiting to lock <0x00000007156c1060> (a java.lang.Long)
- locked <0x00000007156c1078> (a java.lang.Long)
at org.gevinzone.Main.lambda$concurrentAccountTransfer$1(Main.java:51)
at org.gevinzone.Main$$Lambda$2/931919113.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.
也可以用jconsole在UI中檢視:
所以,鎖在物件上時,重點是避免死鎖問題,下面將介紹比較常用的幾種持有多個鎖時,死鎖的避免方法。
2.2.1 注意加鎖順序
如上面死鎖的分析,都是先對自己上鎖,然後對target上鎖,即account1 先對account1 上鎖,然後在對account2 上鎖,而account2 是先對account2上鎖,再對account1上鎖,這造成了迴圈等待,從而死鎖;若兩個物件上鎖順序一致,如都先對account1 上鎖,再對account2 上鎖,就對迴圈解套了,可以避免死鎖。
public void transfer(Account target, int amount) {
Account left = this.getId() < target.getId() ? this : target;
Account right = this.getId() < target.getId() ? target : this;
synchronized (left.getId()) {
synchronized (right.getId()) {
transferAccount(target, amount);
}
}
}
2.2.2 加鎖超時釋放
如上面死鎖的分析,兩個執行緒都是在獲取第二個物件鎖時,由於獲取不到,進入等待,而自己持有的鎖在等待也無法釋放,從而永久的等待下去,產生了死鎖;所以,可以對等待加一個超時時間,超時後不在繼續等待,而是釋放自己持有的鎖,就可以避免死鎖了。
public void transferWithTryLock(TryLockAccount target, int amount) {
while (true) {
if (lock.tryLock()) {
try {
if (target.lock.tryLock()) {
try {
transferAccount(target, amount);
break;
} finally {
target.lock.unlock();
}
}
} finally {
lock.unlock();
}
}
// 防止活鎖
randomSleepForLiveLock();
}
}
2.2.3 加鎖中斷釋放
死鎖時,為避免執行緒處於等待狀態無法響應,可以使用lock.lockInterruptibly()
,這樣,執行緒處於等待狀態時,依然可以響應中斷,丟擲異常,這樣我們可以通過從外部中斷執行緒,打破死鎖。
如,把死鎖的程式碼改成使用lock.lockInterruptibly()
,當死鎖後,呼叫thread.interrupt()
,可以打破死鎖。
public void transfer(LockInterruptableAccount target, int amount) {
try {
lock.lockInterruptibly();
try {
target.lock.lockInterruptibly();
transferAccount(target, amount);
} finally {
target.lock.unlock();
}
} catch (InterruptedException ignored) {
} finally {
lock.unlock();
}
}
用下面程式碼起2個執行緒測試死鎖和死鎖的打破,若註釋掉thread1.interrupt();
,下面程式碼會死鎖,但如果中斷thread1
或thread2
,死鎖立刻被打破
private void lockInterruptableTest() throws InterruptedException {
LockInterruptableAccount a = new LockInterruptableAccount(1, 1000);
LockInterruptableAccount b = new LockInterruptableAccount(2, 1000);
System.out.println("before...");
System.out.printf("A: %d, B: %d\n", a.getBalance(), b.getBalance());
BiConsumer<LockInterruptableAccount, LockInterruptableAccount> consumer =
(o1, o2) -> o1.transfer(o2, 1);
Thread thread1 = new Thread(() -> consumer.accept(a, b));
Thread thread2 = new Thread(() -> consumer.accept(b, a));
thread1.start();
thread2.start();
thread1.interrupt();
thread1.join();
thread2.join();
System.out.println("after...");
System.out.printf("A: %d, B: %d\n", a.getBalance(), b.getBalance());
}
2.2.4 多把鎖轉換為一把鎖進行加鎖
這裡並非用類鎖這種大顆粒度的鎖。而是我們在上面死鎖分析中,發現對於A、B兩個物件的上鎖,不是原子級的,正是由於執行緒先鎖定一個物件,再去鎖定另一個物件,帶來了死鎖隱患。如果可以同時鎖定兩個物件,同時釋放兩個物件,也能避免死鎖。這裡,我們可以用一個鎖來實現原子級鎖定2個物件的操作,在轉賬執行緒中,如果能同時鎖定2個物件,則進行轉賬,否則等待;當兩個物件能被同時鎖定時,再喚醒執行緒。即:
先設計一個Allocator類用於鎖定和釋放物件:
public class Allocator {
private final HashSet<Object> container;
public void apply(Object from, Object to) throws InterruptedException {
synchronized (this) {
while (container.contains(from) || container.contains(to)) {
wait();
}
container.add(from);
container.add(to);
}
}
public void free(Object from, Object to) {
synchronized (this) {
container.remove(from);
container.remove(to);
notifyAll();
}
}
在轉賬執行緒中使用:
public void transfer(Account target, int amount) {
try {
allocator.apply(this, target);
transferAccount(target, amount);
allocator.free(this, target);
} catch (InterruptedException exception) {
exception.printStackTrace();
}
}
3. 其他上鎖的方式
除了synchronized
和併發包中的lock外,訊號量也可以用做鎖,避免死鎖的方式,與上文相同。
(1)注意上鎖順序
public void transferWithSemaphore(Account target, int amount) {
SemaphoreAccount left = this.getId() < target.getId() ? this : (SemaphoreAccount)target;
SemaphoreAccount right = this.getId() < target.getId() ? (SemaphoreAccount)target : this;
try {
left.semaphore.acquire();
try {
right.semaphore.acquire();
transferAccount(target, amount);
} finally {
right.semaphore.release();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
left.semaphore.release();
}
}
(2)加鎖超時釋放
public void transferWithSemaphore(TrySemaphoreAccount target, int amount) {
while (true) {
if (semaphore.tryAcquire()) {
try {
if (target.semaphore.tryAcquire()) {
try {
transferAccount(target, amount);
break;
} finally {
target.semaphore.release();
}
}
} finally {
semaphore.release();
}
}
// 防止活鎖
randomSleepForLiveLock();
}
}
What's More
本文程式碼,可以在GitHub上檢視。
本文同步發表於我的微信公眾號,歡迎關注。
注:轉載本文,請與Gevin聯絡
歡迎關注我的微信公眾賬號