實戰:併發轉賬業務中避免死鎖的各種方法

Gevin發表於2022-06-13

enter image description here

我在鎖的使用與死鎖的避免一文中,介紹了需要持有多把鎖時,如何使用和釋放鎖,當時出於文章篇幅和文章結構限制,重點說明了一種方法。本文再次結合轉賬業務,把持有多把鎖的業務場景下,鎖使用的幾種方法,做一個彙總說明。

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餘額的讀寫,還是存在資料競爭的。

138ec3ba149e21009828fef3342b3216.png

用鎖 this 保護 this.balancetarget.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. 執行緒 1 後於執行緒 2 寫 B.balance,執行緒 2 寫的 B.balance 值被執行緒 1 覆蓋,則300
  2. 執行緒 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中檢視:

6f591d1b1b591a3eb7eab42971e045ee.png

所以,鎖在物件上時,重點是避免死鎖問題,下面將介紹比較常用的幾種持有多個鎖時,死鎖的避免方法。

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();,下面程式碼會死鎖,但如果中斷thread1thread2,死鎖立刻被打破

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聯絡




如果您覺得Gevin的文章有價值,就請Gevin喝杯茶吧!

|

歡迎關注我的微信公眾賬號

實戰:併發轉賬業務中避免死鎖的各種方法

相關文章