Java中的鎖

本篇博客主要是紀錄對<<Java并发编程的艺术>>的學習心得以及一些知識點的重新整理,因此文章中會有許多引用自該書的文字。同時也借鑑其他相關的技術博客,以完善整個知識框架。

Lock接口

鎖事用來控制多個線程訪問共享資源的方式,一般來說,一個鎖能夠訪只多個線程同時訪問共享資源(但有些鎖可以允許多個線程併發的訪問共享資源,比如讀寫鎖)。Lock接口出現之前,Java程序是靠synchronized關鍵字實現鎖功能的,Java SE 5之後,併發包中新增了Lock接口用來實現鎖功能,它提供了與synchronized關鍵字類似的同步功能,只是在使用時需要顯示地獲取和釋放鎖。

使用synchronized關鍵字將會隱式地獲取鎖,但它將鎖的獲取和釋放固化了,也就是先獲取再釋放。這簡化了同步管理,可式擴展性沒有顯式的鎖獲取和釋放來的好。例如,手把手進行鎖獲取和釋放,先獲得鎖A,然後獲取鎖B,當鎖B獲得後,釋放鎖A同時獲取鎖C,當鎖C獲得後,在釋放B同時獲取鎖D,以此類推。這種場景下,synchronized就沒那麼容易實現了,使用Lock接口會容易很多。

以下是使用Lock的簡單例子:

Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
  lock.unlock();
}

注意不要將獲取鎖的過程寫在try裡,因為如果在獲取鎖時發生了異常,異常拋出的同時,也會導致鎖無故釋放。

Lock接口提供的synchronized關鍵字鎖不具備的主要特性如下表所示:

特性 描述
嘗試非阻塞地獲取鎖 當前線程嘗試獲取鎖,如果這一時刻鎖沒有被其他線程獲取到,則成功獲取並持有鎖。
能被中斷地獲取鎖 與synchronized不同,獲取到鎖的線程能夠響應中斷,當獲取到鎖的線程被中斷時,中斷異常將會被拋出,同時鎖會被釋放。
超時獲取鎖 在指定的截止時間之前獲取鎖,如果截止時間到了仍舊無法獲取鎖,則返回

隊列同步器

隊列同步器AbstractQueuedSynchronizer(以下簡稱同步器),是用來構建鎖或者其他同步組件的基礎框架,它使用了一個int成員變量表示同步狀態,通過內痔的FIFO隊列來完成資源獲取線程的排隊工作。

同步器的主要使用方式是繼承,子類通過繼承同步器並實現他的抽象方法來管理同步狀態,在抽象方法的實現過程中免不了要對同步狀態進行更改,這時就需要使用同步器提供的3個方法(getState(), setState(int newState)和compareAndSetState(int expect, int update))來進行操作,因為它們能夠保證狀態的改變是安全的。子類推薦被定義為自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅是定義了若干同步狀態獲取和釋放的方法來提供自定義同步組件使用,同步器既可以支持獨佔式地獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣就可以方便實現不同類型的同步組件(ReentrantLock, ReentrantReadWriteLock和CountDownLatch等)。

同步器式實現鎖(也可以是任意組件)的關鍵,在鎖的實現中聚合同步器,利用同步器實現鎖的語義。可以這樣理解二者之間的關係: 鎖是面向使用者的,它定義了使用者與鎖交互的接口(比如可以允許兩個現成並行訪問),隱藏了實現細節: 同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所需關注的領域。

隊列同步器的示例

只有掌握了同步器的工作原理才能更加深入地理解併發包中其他的併發組件,所以下面通過一個獨佔鎖的示例來深入了解一下同步器的工作原理。

顧名思義,獨佔鎖就是同意時刻指寧有一個線程獲取到鎖,而其他獲取鎖的線程只能處於同步隊列中等待,只有獲取鎖的線程釋放了鎖,後繼的線程才能夠獲取鎖。

class Mutex implements Lock {
  // 靜態內部類,自訂亦同步器
  private static class Sync extends AbstractQueuedSynchronizer {
    // 是否處於占用狀態
    protected boolean isHeldExclusively() {
      return getStatte() == 1;
    }
    // 當狀態為0的時候獲取鎖
    public boolean tryAcquire(int acquires) {
      if (compareAndSetState(0, 1)) {
        setExclusiveOwnerThread(Thread.currentThread());
        return true;
      }
      return false;
    }
    // 釋放鎖,將狀態設置為0
    protected boolean tryRelease(int releases) {
      if (getState() == 0) throw new IllegalMonitorStateException();
      IlsetExclusiveOwnerThread(null);
      setState(0);
      return true;
    }
    // 返回一個Condition,每個Condition都包含了一個Condition隊列
    Condition newCondition() {return new ConditionObject();}
    // 僅需要將操作代理到Sync上即可
    private final Sync sync = new Sync();
    public void lock() {sunc.acquire(1);}
    public void tryLock() {return sync.tryAcquire(1);}
    public void unlock() {sync.release(1);}
    public Condition newCondition() {return sync.newCondition();}
    public boolean isLocked() {return sync.isHeldExclusively();}
    public boolean hasQueuedThreads() {return sunc.hasQueuedThreads();}
    public void lockInterruptibly() throws InterruptedException {
      sync.acquireInterruptibly(1);
    }
    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
      return sunc.tryAcquireNanos(1, unit.toNanos(timeout));
    }
  }
}

上述示例中,獨佔鎖Mutex是一個自定義同步組件,它在同一時刻只允許一個線程佔有鎖。Mutex中定義了一個靜態內部類,該內部類繼承了同步器並實現了獨佔是獲取和釋放同步狀態。在tryAcquire(int acquires)方法中,如果經過CAS設置成功(同步狀態設置為1),則代表獲取了同步狀態,而在tryRelease(int releases)方法中指是將同步狀態重置為0。用戶只用Mutex時並不會直接和內部同步器的實現打交道,而是調用Mutex提供的方法,在Mutex的實現中,以獲取鎖的lock()方法為例,只需要在方法時憲中調用同步器的模板法acquire(int args)即可,當前線程調用該方法獲取同步狀態失敗後會被加入倒同步隊列中等待,這樣就大大降低了實現一個可靠自定義同步組件的門檻。

重入鎖

重入鎖ReentrantLock,顧名思義,就是支持重進入的鎖,它表示該鎖能夠支持一個線程對資源的重複加鎖。除此之外,該鎖還支持獲取鎖時的公平和非公平性選擇。

回憶上一節中的示例Mutex,同時考慮如下場景: 當一個線程調用Mutex的lock()方法獲取鎖之後,如果再次調用lock()方法,則該線程將會被自己所阻塞,原因是Mutex再實現tryAcquire(int acquires)方法時沒有考慮佔有鎖的線程再次獲取鎖的場景,而在調用tryAcquire(int acquires)方法時返回了false,導致該線程被阻塞。簡單地說,Mutex是一個不支持蟲進入的鎖。而synchronized關鍵字隱式的支持崇信入,比如一個synchronized修式的遞歸方法,在方法執行時,執行線程在獲取了鎖之後仍能連續多次地獲取該鎖,而不像Mutex由於獲取了鎖,而在下一次獲取鎖時出現阻塞自己的情況。

ReentrantLock雖然沒能像synchronized關鍵字一樣支持隱式地蟲進入,但是在調用lock()方法時,已經獲取到鎖的線程,能夠再次調用lock()方法獲取鎖而不被阻塞。

這裡提到到一個鎖獲取的公平性問題,如果再絕對時間上,先對鎖進行獲取的請求一定先被滿足,那麼這個鎖式公平的,反之,是不公平的。公平的獲取鎖,也就是等待時間最長的線程最優先獲取鎖,也可以說鎖獲取是順序的,嚴格執行FIFO的順序。ReentrantLock提供了一個構造函數,能夠控制鎖是否是公平的。事實上,公平的鎖機制往往沒有非公平的效率高,但是,並不是任何場景都是以TPS作為唯一的指標,公平鎖能夠減少”飢餓”發生的概率,等待越久的請求月是能夠得到優先滿足。

讀寫鎖

之前提到的鎖(如Mutex和ReentrantLock)基本都是排他鎖,這些鎖在同一時刻只允許一個線程進行訪問,而讀寫鎖在同意食客可以允許多個線程訪問,但是在線程訪問時,所有的讀線程和寫線程均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得併發性相比亦般的排他鎖有了很大提升。

除了保證寫操作對讀操作的可見性以及併發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。假設在程序中定義一個共享的用作緩存的數據結構,它大部分時間提供讀服務(例如查詢和搜索),而寫操作占有的時間很少,但是寫操作完成之後的更新需要對後續的讀服務可見。

為了不出現髒讀,Java 5以前使用的是等待機制,所有晚於寫操作的讀操作均會進入等待狀態。改用讀寫鎖實現後,只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可。當寫鎖背獲取到時,後續(非當前寫操作線程)的讀寫操作都會被阻塞,寫鎖釋放之後,所有操作繼續執行,編程方式相對於使用等待通知機制的實現方式而言,變得簡單明瞭。

一般情況下,讀寫鎖的性能都會比排它鎖好,因為大多數場景讀是多於寫的。在讀多於血的情況下,讀寫鎖能夠提供比排它鎖更好的併發性與吞吐量。Java並發包提供讀寫鎖的實現是ReentrantReadWriteLock。它提供如下特性:

特性 說明
公平性選擇 支持非公平(默認)和公平的鎖獲取方式,吞吐量還是非公平優先於公平
重進入 該鎖支持蟲進入,以讀寫線程為例: 讀線程在獲取了讀鎖之後,能夠再次獲取讀鎖。而寫線程在獲取了寫鎖之後能夠再次獲取寫鎖,同時也可以獲取讀鎖
鎖降級 遵循獲取寫鎖、獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成為讀鎖。