併發王者課-鉑金1:探本溯源-為何說Lock介面是Java中鎖的基礎

秦二爺發表於2021-06-16

歡迎來到《併發王者課》,本文是該系列文章中的第14篇

黃金系列中,我們介紹了併發中一些問題,比如死鎖、活鎖、執行緒飢餓等問題。在併發程式設計中,這些問題無疑都是需要解決的。所以,在鉑金系列文章中,我們會從併發中的問題出發,探索Java所提供的鎖的能力以及它們是如何解決這些問題的。

作為鉑金系列文章的第一篇,我們將從Lock介面開始介紹,因為它是Java中鎖的基礎,也是併發能力的基礎。

一、理解Java中鎖的基礎:Lock介面

在青銅系列文章中,我們介紹了通過synchronized關鍵字實現對方法和程式碼塊加鎖的用法。然而,雖然synchronized非常好用、易用,但是它的靈活度卻十分有限,不能靈活地控制加鎖和釋放鎖的時機。所以,為了更靈活地使用鎖,並滿足更多的場景需要,就需要我們能夠自主地定義鎖。於是,就有了Lock介面

理解Lock最直觀的方式,莫過於直接在JDK所提供的併發工具類中找到它,如下圖所示:

可以看到,Lock介面提供了一些能力API,並有一些具體的實現,如ReentrantLock、ReentrantReadWriteLock等。

1. Lock的五個核心能力API

  • void lock():獲取鎖。如果當前鎖不可用,則會被阻塞直至鎖釋放
  • void lockInterruptibly():獲取鎖並允許被中斷。這個方法和lock()類似,不同的是,它允許被中斷並丟擲中斷異常
  • boolean tryLock():嘗試獲取鎖。會立即返回結果,而不會被阻塞
  • boolean tryLock(long timeout, TimeUnit timeUnit):嘗試獲取鎖並等待一段時間。這個方法和tryLock(),但是它會根據引數等待–會,如果在規定的時間內未能獲取到鎖就會放棄
  • void unlock():釋放鎖。

2. Lock的常見實現

在Java併發工具類中,Lock介面有一些實現,比如:

  • ReentrantLock:可重入鎖;
  • ReentrantReadWriteLock:可重入讀寫鎖;

除了列舉的兩個實現外,還有一些其他實現類。對於這些實現,暫且不必詳細瞭解,後面會詳細介紹。在目前階段,你需要理解的是Lock是它們的基礎

二、自定義Lock

接下來,我們基於前面的示例程式碼,看看如何將synchronized版本的鎖用Lock來實現。

 public static class WildMonster {
   private boolean isWildMonsterBeenKilled;
   
   public synchronized void killWildMonster() {
     String playerName = Thread.currentThread().getName();
     if (isWildMonsterBeenKilled) {
       System.out.println(playerName + "未斬殺野怪失敗...");
       return;
     }
     isWildMonsterBeenKilled = true;
     System.out.println(playerName + "斬獲野怪!");
   }
 }

1. 實現一把簡單的鎖

建立類WildMonsterLock並實現Lock介面,WildMonsterLock將是取代synchronized的關鍵:

// 自定義鎖
public class WildMonsterLock implements Lock {
    private boolean isLocked = false;

    // 實現lock方法
    public void lock() {
        synchronized (this) {
            while (isLocked) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            isLocked = true;
        }
    }
    
    // 實現unlock方法
    public void unlock() {
        synchronized (this) {
            isLocked = false;
            this.notify();
        }
    }
}

在實現Lock介面時,你需要實現它上述的所有方法。不過,為了簡化程式碼方便展示,我們移除了WildMonsterLock類中的tryLock等方法。

對於waitnotify方法的時候,如果你不熟悉的話,可以檢視青銅系列的文章。這裡需要提醒的是,notify在使用時務必要和wait是同一個監視器

基於剛才定義的WildMonsterLock,建立WildMonster類,並在方法killWildMonster中使用WildMonsterLock物件,從而取代synchronized.

// 使用剛才自定義的鎖
 public static class WildMonster {
   private boolean isWildMonsterBeenKilled;

   public void killWildMonster() {
     // 建立鎖物件
     Lock lock = new WildMonsterLock(); 
     // 獲取鎖
     lock.lock(); 
     try {
       String playerName = Thread.currentThread().getName();
       if (isWildMonsterBeenKilled) {
         System.out.println(playerName + "未斬殺野怪失敗...");
         return;
       }
       isWildMonsterBeenKilled = true;
       System.out.println(playerName + "斬獲野怪!");
     } finally {
       // 執行結束後,無論如何不要忘記釋放鎖
       lock.unlock();
     }
   }
}

輸出結果如下:

哪吒斬獲野怪!
典韋未斬殺野怪失敗...
蘭陵王未斬殺野怪失敗...
鎧未斬殺野怪失敗...

Process finished with exit code 0

從結果中可以看到:只有哪吒一人斬獲了野怪,其他幾個英雄均以失敗告終,結果符合預期。這說明,WildMonsterLock達到了和synchronized一致的效果。

不過,這裡有細節需要注意。在使用synchronized時我們無需關心鎖的釋放,JVM會幫助我們自動完成。然而,在使用自定義的鎖時,一定要使用try...finally來確保鎖最終一定會被釋放,否則將造成後續執行緒被阻塞的嚴重後果。

2. 實現可重入的鎖

synchronized中,鎖是可以重入的所謂鎖的可重入,指的是鎖可以被執行緒重複或遞迴呼叫。比如,加鎖物件中存在多個加鎖方法時,當執行緒在獲取到鎖進入其中任一方法後,執行緒應該可以同時進入其他的加鎖方法,而不會出現被阻塞的情況。當然,前提條件是這個加鎖的方法用的是同一個物件的鎖(監視器)。

在下面這段程式碼中,方法A和B都是同步方法,並且A中呼叫B. 那麼,執行緒在呼叫A時已經獲得了當前物件的鎖,那麼執行緒在A中呼叫B時可以直接呼叫,這就是鎖的可重入性。


public class WildMonster {
    public synchronized void A() {
        B();
    }
    
    public synchronized void B() {
       doSomething...
    }
}

所以,為了讓我們自定義的WildMonsterLock也支援可重入,我們需要對程式碼進行一點改動。

public class WildMonsterLock implements Lock {
    private boolean isLocked = false;
   
    // 重點:增加欄位儲存當前獲得鎖的執行緒
    private Thread lockedBy = null;
    // 重點:增加欄位記錄上鎖次數
    private int lockedCount = 0;

    public void lock() {
        synchronized (this) {
            Thread callingThread = Thread.currentThread();
            // 重點:判斷是否為當前執行緒
            while (isLocked && lockedBy != callingThread) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            isLocked = true;
            lockedBy = callingThread;
            lockedCount++;
        }
    }

    public void unlock() {
        synchronized (this) {
            // 重點:判斷是否為當前執行緒
            if (Thread.currentThread() == this.lockedBy) {
                lockedCount--;
                if (lockedCount == 0) {
                    isLocked = false;
                    this.notify();
                }
            }
        }
    }
}

在新的WildMonsterLock中,我們增加了this.lockedBylockedCount欄位,並在加鎖和解鎖時增加對執行緒的判斷。在加鎖時,如果當前執行緒已經獲得鎖,那麼將不必進入等待。而在解鎖時,只有當前執行緒能解鎖

lockedCount欄位則是為了保證解鎖的次數和加鎖的次數是匹配的,比如加鎖了3次,那麼相應的也要3次解鎖。

3. 關注鎖的公平性

在黃金系列文章中,我們提到了執行緒在競爭中可能被餓死,因為競爭並不是公平的。所以,我們在自定義鎖的時候,也應當考慮鎖的公平性

三、小結

以上就是關於Lock的全部內容。在本文中,我們介紹了Lock是Java中各類鎖的基礎。它是一個介面,提供了一些能力API,並有著完整的實現。並且,我們也可以根據需要自定義實現鎖的邏輯。所以,在學習Java中各種鎖的時候,最好先從Lock介面開始。同時,在替代synchronized的過程中,我們也能感受到Lock有一些synchronized所不具備的優勢:

  • synchronized用於方法體或程式碼塊,而Lock可以靈活使用,甚至可以跨越方法

  • synchronized沒有公平性,任何執行緒都可以獲取並長期持有,從而可能餓死其他執行緒。而基於Lock介面,我們可以實現公平鎖,從而避免一些執行緒活躍性問題

  • synchronized被阻塞時只有等待,而Lock則提供了tryLock方法,可以快速試錯,並可以設定時間限制,使用時更加靈活

  • synchronized不可以被中斷,而Lock提供了lockInterruptibly方法,可以實現中斷

另外,在自定義鎖的時候,要考慮鎖的公平性。而在使用鎖的時候,則需要考慮鎖的安全釋放。

夫子的試煉

  • 基於Lock介面,自定義實現一把鎖。

延伸閱讀與參考資料

關於作者

關注公眾號【庸人技術笑談】,獲取及時文章更新。記錄平凡人的技術故事,分享有品質(儘量)的技術文章,偶爾也聊聊生活和理想。不販賣焦慮,不做標題黨。

如果本文對你有幫助,歡迎點贊關注監督,我們一起從青銅到王者

相關文章