【Java併發工具類】Lock和Condition

前言

Java SDK併發包經過LockCondition兩個接口來實現管程,其中Lock用於解決互斥問題,Condition用於解決同步問題。咱們須要知道,Java語言自己使用synchronized實現了管程的,那麼爲何還在SDK中提供另一種實現呢?欲知爲什麼請看下文。html

下面將先闡述再造管程的理由,而後詳細介紹Lock和Condition,最後再看實現同步機制時是選擇synchronized仍是SDK中的管程。java

再造管程的理由

Java本就從語言層面實現了管程,然然後面又在SDK中再次現實,這隻能說明語言層面的實現的管程有所不足。要說談synchronized的不足,咱們就要要回顧一下破壞死鎖的不可搶佔問題算法

破壞不可搶佔條件,須要線程在獲取不到鎖的狀況下主動釋放它擁有的資源。當咱們使用synchronized的時候,線程是沒有辦法主動釋放它佔有的資源的。由於,synchronized在申請不到資源時,會使線程直接進入阻塞狀態,而線程進入了阻塞狀態就不能主動釋放佔有的資源。編程

因此,有沒有一種辦法可使得線程處於阻塞狀態時也可以響應中斷主動釋放資源或者獲取不到資源的時候不阻塞呢?答案是有的,使用SDK中的管程。緩存

SDK中管程的實現java.util.concurrent中的Lock接口,提供了以下三種設計思想均可以解決死鎖的不可搶佔條件:安全

  1. 可以響應中斷多線程

    線程處於阻塞狀態時能夠接收中斷信號。咱們即可以給阻塞的線程發送中斷信號,喚醒線程,線程便有機會釋放它曾經擁有的鎖。這樣即可破壞不可搶佔條件。併發

  2. 支持超時dom

    若是線程在一段時間以內沒有獲取到鎖,不是進入阻塞狀態,而是返回一個錯誤,那這個線程也有機會釋放曾經持有的鎖。這樣也能破壞不可搶佔條件。函數

  3. 非阻塞地獲取鎖

    若是嘗試獲取鎖失敗,並不進入阻塞狀態,而是直接返回,那這個線程也有機會釋放曾經持有的鎖。這樣也能夠破壞不可搶佔條件。

這三種方案就可全面彌補synchronized的問題。也就是再造管程的緣由。這三種思想體如今Lock接口的API上,即是以下三個方法:

// 支持中斷的 API
void lockInterruptibly() throws InterruptedException;

// 支持超時的 API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

// 支持非阻塞獲取鎖的 API
boolean tryLock();

下面咱們便繼續介紹Lock。

Lock和ReentrantLock

Lock接口中定義了一組抽象的加鎖操做:

public interface Lock{
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition(); // 關聯Condition對象使用
}

與synchronized內置加鎖不一樣,Lock提供的是無條件的、可輪詢的、定時的以及可中斷的鎖獲取操做,全部加鎖和解鎖都是顯式的。在Lock的實現中必需要提供與內置鎖相同的內存可見性語義,可是加鎖語義、調度算法、順序保證以及性能等方面能夠不一樣。

ReentrantLock實現了Lock接口,並提供了與synchronized相同的互斥性和內存可見性。在獲取ReentrantLock時,有着進入同步代碼塊相同的內存語義,在釋放ReentrantLock時,一樣有着與退出同步代碼塊相同的內存語義。見名知義,ReentrantLock還提供了同synchronized同樣的可重入加鎖的語義。

👉 擴展:可重入函數
可重入函數怎麼理解呢?指的是線程能夠重複調用?顯然不是,所謂可重入函數,指的是多個線程能夠同時調用該函數,每一個線程都能獲得正確結果;同時在一個線程內支持線程切換,不管被切換多少次,結果都是正確的。多線程能夠同時執行,還支持線程切換,這意味着什麼呢?線程安全。因此,可重入函數是線程安全的。

Lock的標準使用形式

Lock l = ...; //使用ReentrantLock實現類 Lock l = new ReentrantLock();
l.lock();
try {
    // access the resource protected by this lock
} finally {
    l.unlock();
}

Lock的使用形式比synchronized要複雜一些,全部的加鎖和解鎖的操做都是顯式的。解鎖操做必須在finally塊中,不然,若是在被保護的代碼塊中拋出了異常,那麼這個鎖將永遠沒法釋放。當使用加鎖時,還必須考慮在try塊中拋出異常的狀況,若是可能使對象處於不一致狀態,那麼就須要try-catch或者try-finally塊。

輪詢鎖與定時鎖

可定時與可輪詢的鎖獲取方式是由tryLock方法實現的。與無條件獲取鎖的模式比較,它具備更完善的錯誤恢復機制。

使用輪詢鎖解決動態順序死鎖問題

若是不能得到全部須要的鎖,那麼可使用可定時的或者可輪詢的鎖獲取方式,它會釋放已經得到的鎖,而後嘗試從新獲取全部鎖。下面一個例子(來自參考[2]),給出了使用輪詢鎖來解決轉帳時動態順序死鎖問題:使用tryLock來獲取兩個帳戶的鎖,若是不能同時得到,那麼就回退並從新嘗試。程序中還在休眠時間中作了隨機處理,從而下降發生活鎖的可能性。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
import static java.util.concurrent.TimeUnit.NANOSECONDS;

public class DeadlockAvoidance {
    private static Random rnd = new Random();
    public boolean transferMoney(Account fromAcct,
                                 Account toAcct,
                                 DollarAmount amount,
                                 long timeout,
                                 TimeUnit unit)
        throws InsufficientFundsException, InterruptedException {
        long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
        long randMod = getRandomDelayModulusNanos(timeout, unit);
        long stopTime = System.nanoTime() + unit.toNanos(timeout);

        while (true) {
            // 使用tryLock()獲取鎖,若是獲取了鎖,則返回 true;不然返回 false.
            if (fromAcct.lock.tryLock()) { 
                try {
                    if (toAcct.lock.tryLock()) { // 使用tryLock()獲取鎖
                        try {
                            if (fromAcct.getBalance().compareTo(amount) < 0)
                                throw new InsufficientFundsException();
                            else {
                                fromAcct.debit(amount);
                                toAcct.credit(amount);
                                return true;
                            }
                        } finally {
                            toAcct.lock.unlock();
                        }
                    }
                } finally {
                    fromAcct.lock.unlock();
                }
            }
            if (System.nanoTime() < stopTime) // 獲取鎖的時間大於給定時間,則返回失敗
                return false;
            NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod); // 休眠時間加入隨機成分
        }
    }

    private static final int DELAY_FIXED = 1;
    private static final int DELAY_RANDOM = 2;

    static long getFixedDelayComponentNanos(long timeout, TimeUnit unit) {
        return DELAY_FIXED;
    }

    static long getRandomDelayModulusNanos(long timeout, TimeUnit unit) {
        return DELAY_RANDOM;
    }

    static class DollarAmount implements Comparable<DollarAmount> {
        public int compareTo(DollarAmount other) {
            return 0;
        }
        DollarAmount(int dollars) {
        }
    }

    class Account {
        public Lock lock;
        void debit(DollarAmount d) {
        }
        void credit(DollarAmount d) {
        }
        DollarAmount getBalance() {
            return null;
        }
    }
    class InsufficientFundsException extends Exception {
    }
}

定時鎖

在實現具備時間限制操做時,定時鎖將很是有用。當在帶有時間限制的操做中調用了一個阻塞方法時,它能根據剩餘時間來提供一個時限。若是操做不能在指定的時間內給出結果,那麼它就會使程序提早結束。當使用內置鎖時,在開始請求鎖後,這個操做將沒法取消,所以內置鎖很難實現帶有時間限制的操做。

可中斷的鎖獲取操做

可中斷的鎖獲取操做適用在可取消的操做中獲取鎖。內置鎖是不能響應中斷的。lockInterruptibly()方法可以在獲取鎖的同時保持對中斷的響應,由於它包含在Lock中,所以無須建立其餘類型的不可中斷阻塞機制。

非塊結構的加鎖

在內置鎖中,鎖的獲取和釋放都是基於代碼塊的,且是自動獲取和釋放鎖。雖然這樣避免了編碼的複雜性,可是卻不太靈活。例如,某些遍歷併發訪問的數據結果的算法要求使用連鎖式加鎖"hand-over-hand"鎖耦合 "chain locking":獲取節點 A 的鎖,而後再獲取節點 B 的鎖,而後釋放 A 並獲取 C,而後釋放 B 並獲取 D,依此類推。Lock 接口的實現容許鎖在不一樣的做用範圍內獲取和釋放,並容許以任何順序獲取和釋放多個鎖,從而支持使用這種技術。

公平性

ReentrantLock的構造函數中提供了兩種公平性選擇:建立一個非公平的鎖(默認)或者一個公平的鎖。

public ReentrantLock(){}
// fair參數表明的是鎖的公平策略,若是傳入true就表示須要構造一個公平鎖,不然就是構造一個非公平鎖。
public ReentrantLock(boolean fair) {}

在公平的鎖上,線程將按照它們發出請求的順序來得到鎖;在非公平的鎖上,則容許「插隊」:當一個線程請求非公平鎖時,若是在發出請求的同時該鎖的狀態變爲可用,那麼這個線程將跳過隊列中全部的等待線程並得到這個鎖(來得早不如來得巧)。

關於請求線程是否進入隊列排隊等待鎖:在公平的鎖中,若是有一個線程持有這個鎖或者有其餘線程正在隊列中等待這個鎖,那麼新發出請求的線程將被放入隊列中等待(FIFO原則);在非公平鎖中,只有當鎖被某個線程持有時,新發出請求的線程纔會被放入隊列中(如上面所述的來得巧就會直接得到鎖)。

在現實生活中咱們每每指望事事公平,可是爲何在併發的鎖上卻存在不公平鎖?其實想一想也簡單,恢復掛起的一個線程到這個線程到這個線程真正開始執行以前是存在延遲的,若是在此期間有一個線程恰好達到而且在被喚醒的線程真正執行以前又恰好能夠利用完資源,那麼這種充分利用資源的精神偏偏是可取的。
公平性將因爲在掛起線程和恢復線程時產生開銷而極大地下降性能,因而,大多數狀況下,非公平鎖的性能要高於公平鎖的性能。

只有當持有鎖的時間相對較長,或者請求鎖的平均時間間隔較長,那麼就應該使用公平鎖。與默認的ReentrantLock同樣,內置加鎖並不會提供肯定的公平性保證。

synchronized和ReentrantLock之間的抉擇

ReentrantLock在加鎖和內存上提供的語義都與內置鎖相同,除此以外,它還提供咱們上述的定時的鎖等待、可中斷的鎖等待、公平性以及實現非塊結構的加鎖。ReentrantLock在性能上也優於內置鎖(在Java 5.0中遠遠勝出,在Java 6.0中略有勝出,synchronized在Java 6.0中作了優化),可是是否就意味着開發都使用ReentrantLock替代synchronized呢?

synchronized與ReentrantLock相比,仍是具備很大優點。例如爲開發人員所熟悉、簡潔緊湊。加鎖和釋放鎖都是自動進行的,而顯式鎖須要手動在finally中進行,若是忘記將引起嚴重後果。在現有不少程序中使用的時內置鎖,貿然混合入顯式鎖也會讓人困惑,也容易引發錯誤。

因此,在通常狀況下使用內置鎖,僅當內置鎖不能知足需求時,才能夠考慮使用ReentrantLock。將來synchronized被提高優化的可能也會很大,由於synchronized做爲JVM的內置屬性,能夠便於一些代碼優化,如對線程封閉的鎖對象的鎖消除優化,經過增長鎖的粒度來消除內置鎖的同步。

Condition對象

Lock能夠看做是一種廣義的內置鎖,Condition則能夠看做是一種廣義的內置條件隊列。咱們前面介紹管程時說過,每一個內置鎖只能有一個相關聯的條件隊列(條件變量等待隊列)。 一個Condition和一個Lock關聯在一塊兒,就像一個條件隊列和一個內置鎖關聯同樣。建立一個Condition,能夠在關聯的Lock上調用Lock.newCondition()方法 。Condition比內置條件隊列提供了更豐富的功能:在每一個鎖上加鎖存在多個等待、條件等待是可中斷的或不可中斷的、基於限時的等待,以及公平的或非公平的隊列操做。

每一個Lock能夠擁有任意數量的Condition對象。Condition對象繼承了相關的Lock對象的公平性,對於公平的鎖,線程會依照FIFO順序從Condition.await中釋放。

Condition的接口以下:

public interface Condition{
    void await() throws InterruptedException;
    boolean await(long time, TimeUnit unit) throws InterruptedException;
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    void awaitUninterruptibly();
    boolean awaitUntil(Date deadline) throws InterruptedException;
    void signal();
    void signalAll();
}

注意,在Condition對象中,與(等待—喚醒機制中介紹的)內置鎖中waitnotifynotifyAll方法相對應的是awaitsignalsignaAll方法。由於Condition也繼承了Object,因此它也包含了wait、notify和notifyAll方法,在使用時必定要使用正確的版本。

使用Lock和Condition實現有界緩存(代碼來自參考[2])。使用兩個Condition,分別爲notFull和notEmpty,用於表示「非滿」與「非空」兩個條件謂詞(使某個操做成爲狀態依賴操做的前提,對下面的take方法來講,它的條件謂詞就是「緩存不空」,take方法在執行前必須首先測試該條件謂詞)。當緩存爲空時,take將阻塞並等待notEmpty,此時put想notEmpty發送信號,能夠解除任何在take中阻塞的線程。

public class ConditionBoundedBuffer <T> {
    protected final Lock lock = new ReentrantLock();
    // 條件謂詞: notFull (count < items.length)
    private final Condition notFull = lock.newCondition();
    // 條件謂詞: notEmpty (count > 0)
    private final Condition notEmpty = lock.newCondition();
    private static final int BUFFER_SIZE = 100;
    @GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
    @GuardedBy("lock") private int tail, head, count;

    // 阻塞並直到: notFull
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await(); // 阻塞當前線程在notFull的條件隊列上
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal(); // 喚醒notEmpty條件隊列上的一個線程
        } finally {
            lock.unlock();
        }
    }

    // 阻塞並直到: notEmpty
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await(); // 阻塞當前線程在notEmpty的條件隊列上
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal(); // 喚醒notFull條件隊列上的一個線程
            return x;
        } finally {
            lock.unlock();
        }
    }
}

經過將兩個條件謂詞分開並放到兩個等待線程集中,Condition使其更容易知足單次通知的需求。signal將比signalAll更高效,它能極大地減小在每次緩存操做中發生的上下文切換與鎖請求的次數。由於若是使用內置鎖來實現,全部被阻塞的線程都將在一個隊列上等待。

小結

在開發併發程序時,是使用原生的synchronized仍是java.util.concurrent.*下的顯式鎖Lock工具類,它們各有優劣還須要根據具體要求進行選擇。以上僅是學習筆記的整合,如有不明不白之處,還望各位看官指出,先在此謝過。

參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016

相關文章
相關標籤/搜索