深刨顯式鎖ReentrantLock原理及其與內建鎖的區別,以及讀寫鎖ReentrantReadWriteLock使用場景

xuxh120發表於2021-12-20

13.顯示鎖 

Java5.0之前,在協調對共享物件的訪問時可以使用的機制只有synchronized和volatile。Java5.0增加了一種新的機制:ReentrantLock。與之前提到過的機制相反,RenntrantLock並不是一種替代內建加鎖的方法,而是當內建鎖機制不適用時,作為一種可選擇的高階功能。

 

 

13.1 ReentrantLock

Lock介面提供了一組抽象的加鎖操作,與內建加鎖操作不同的是,Lock提供了一種無條件的、可輪訓的、定時的以及可中斷鎖操作,所有加鎖和解鎖的方法都是顯示的。

Lock的實現中必須提供與內部鎖相同的記憶體可見性語義,但在加鎖語義、排程演算法、順序保證以及效能特性等方面可以有所不同。

public interface Lock {

    

    void lock();

 

    void lockInterruptibly() throws InterruptedException;

 

    boolean tryLock();

 

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

 

    void unlock(); 

    

    Condition newCondition();

}

ReentrantLock實現了Lock介面,並提供了與synchronized相同的互斥性與記憶體可見性。在獲取ReentrantLock時,有著與進入同步程式碼塊相同的記憶體語義,在釋放ReentrantLock時,有著與突出同步程式碼塊相同的記憶體語義。此外,與synchronized一樣,ReentrantLock還提供了可重入的加鎖語義。

ReentrantLock支援在Lock介面中定義的所有獲取鎖模式,並且與synchronized相比,它還為處理鎖的不可用性問題提供了更高的靈活性。

 

內建鎖必須在獲取該鎖的程式碼塊中釋放,這簡化編碼,並且在異常處理操作實現了很好的互動。為什麼要建立一種與內建鎖如此相似的新加鎖機制?原因有三:

  1. 在大多數情況下,內建鎖都能很好的工作,但在一些功能上存在一些侷限性。例如,無法中斷一個正在等待獲取鎖的執行緒,或者無法在請求獲取一個鎖時無限的等待下去。
  2. 無法實現非阻塞結構的加鎖規則。
  3. 在某些情況下,一種更靈活的加鎖機制通常能提供更好的活躍性和效能。

 

程式碼清單 13-2 給出了Lock介面的標準使用形式。這種形式比使用內建鎖複雜一些:

  • 必須在finally塊中釋放鎖。否則,如果在被保護的程式碼中丟擲了異常那麼,這個鎖永遠都無法釋放。
  • 當使用加鎖時,還必須考慮在try塊中丟擲異常的情況,如果可能使物件處於某種不一致的狀態,那麼必須使用多個try-cathc或try-finally程式碼塊。(使用內建鎖也應該考慮出現異常的情況)。

程式碼清單 13-2 使用ReentrantLock來保護物件狀態

Lock lock = new ReentrantLock();

// ......

lock.lock();

try {

    // 更新狀態

    //// 捕獲異常,並在必要時恢復不變性條件

} finally {

    lock.unlock();

}

如果沒有使用finlly來釋放鎖Lock,那麼相當於啟動了一個定時炸彈。當“炸彈爆炸”時,很難追蹤到最初發生錯誤的位置,因為沒有記錄應該釋放鎖的位置和時間。這就是ReentrantLock不能完全替代synchronized的原因:它更加“危險”,因為當程式的執行控制離開被控制的程式碼塊時鎖不會自動清除。雖然在finally塊中釋放鎖並不困難,但可能忘記。

 

1.1 輪詢鎖於定時鎖

可定時和可輪詢的獲鎖模式是由tryLock 實現的,與無條件的鎖模式相比較它具有跟完善的錯誤恢復機制。

在內建鎖中,死鎖是一個嚴重的問題,恢復程式的唯一方法是重新啟動程式,而防止死鎖的唯一方法就是在構造程式時避免出現不一致的鎖順序。可定時的鎖與可輪詢的鎖提供了一種選擇:避免死鎖的發生。

 

如果一定時間內,沒有能獲得所有需要的鎖,那麼可以使用可定時的或可輪詢的鎖獲取方式,從而使你重新獲取控制權,它會釋放已經獲得的鎖,然後重新嘗試獲取所有鎖(或者至少會將這個失敗記錄到日誌並採取其他措施)。

程式碼清單 13-3給出了一個在不同賬戶間轉賬的例子:使用trylock來獲取兩個鎖,如果不同時獲得就退回並重試;如果在制定時間不能獲的所需要的鎖,那麼transferMoney將返回一個失敗狀態,從而使該操作平緩的實效。

 程式碼清單 13-3 用 tryLock 避免順序死鎖的發生

     public boolean transferMoney(Account fromAcc, 

                                 Account toAcc, 

                                 DollarAmount amount, 

                                 long timeout, 

                                 TimeUnit unit)

            throws InsufficientFundsException, InterruptedException {

        long fixedDelay = getFixedDelayComponentNanos(timeout, unit);

        long randMod = getRandomDelayModuleNanos(timeout, unit);

        long stopTime = System.nanoTime() + unit.toNanos(timeout);

 

        while (true) {

            if (fromAcc.lock.tryLock()) {

                try {

                    if (toAcc.lock.tryLock()) {

                        try {

                            if (fromAcc.getBalance().compareTo(amount) < 0) {

                            throws new InsufficientFundsException();

                            } else {

                                fromAcc.debit(amount);

                                toAcc.credit(amount);

                                return true;

                            }

                        } finally {

                            toAcc.lock.unlock();

                        }

                    }

                } finally {

                    fromAcc.lock.unlock();

                }

            }

            if (System.nanoTime() < stopTime)

                return false;

            NANOSECONDS.sleep(fixedDelay + randMod.nextLong() % randMod);

        }

    }

那些具有時間限制的活動,定時鎖同樣非常有用。當在帶有時間限制的操作中呼叫了一個阻塞方法時,他能根據剩餘時間來提供一個時限。如果操作不能在指定的時間內給出結果,那麼就會使程式提前結束。當使用內建鎖時,在開始請求鎖後,這個操作將無法取消,因此內建鎖很難實現帶有時間限制的操作。程式清單 13-4 實現了這樣一個例子:在共享通訊線路上傳送一個訊息,如果不能在指定時間內完成,程式碼就會失敗。

 程式清單 13-4 帶有時間限制

    public boolean trySendOnSharedLine(String message,

                                       long timeout, TimeUnit unit)

            throws InterruptedException {

        if (!lock.tryLock(nanosToLock, NANOSECONDS)) {

            return false;

        }

        try {

            return sendOnSharedLine(message);

        } finally {

            lock.unlock();

        }

    }

 

1.2 可中斷的鎖獲取操作

可中斷的鎖獲取操作的標準結構比普通的鎖獲取操作稍微複雜一些,因為需要兩個 try 塊。如果可中斷的鎖獲取操作丟擲了 InterruptedException, 那麼可以使用標準的 try-finally加鎖模式。

 

程式清單 13-5 使用 lockInterruptibly來實現程式清單13-4的sendOnSharedLine,以便在一個課取消的任務中呼叫它。
定時的tryLock同樣響應中斷,因此當需要實現一個定時的和可中斷的鎖獲取操作時,可以使用tryLock方法。

程式清單 13-5 可中斷的鎖獲取操作

 

    public boolean sendOnSharedLine(String messages) throws InterruptedException {

        lock.lockInterruptibly();

        try {

            return cancellableSendOnSharedLine(messages);

        } finally {

            lock.unlock();

        }

    }

 

    private boolean cancellableSendOnSharedLine(String messages) throws InterruptedException {...}

    

13.2 效能考慮

當把ReentrantLock新增到Java 5.0時,他能比內建鎖提供更好的競爭效能。對於同步原語來說,競爭效能是可伸縮性的關鍵因素:如果有越多的資源被耗費在鎖的管理和排程上,那麼應用程式的到的資源就越少。鎖的實現方式越好,將需要越少的系統呼叫和上下文切換,並且在共享記憶體匯流排上的記憶體同步通訊量也越少,而一些耗時的操作將佔用應用程式的資源。

 

Java 6.0使用了改進後的演算法來管理內建鎖,與在ReentrantLock中使用的演算法類似,該演算法有效的提高了可伸縮性。

圖13-1給出了Java 5.0與Java 6.0版本中,內建鎖與ReentrantLock之間的效能差異:在執行環境為4路的Opteron系統,作業系統是Solaris,測試並比較一個HashMap在由內建鎖保護以及由ReentrantLock保護的情況下的吞吐量的例子。在Java 5.0中ReentrantLock能提供更好的吞吐量,但是在Java 6.0中二者的吞吐量非常接近。 

 

 

 

13.3 公平性

 ReentrantLock中提供了兩種公平性選擇:非公平的鎖(預設)和公平的鎖。在公平的鎖上,執行緒將按照他們發出請求的順序來獲得鎖,而非公平的鎖則允許“插隊”: 當一個執行緒請求非公平的鎖時,如果在發出請求的同時該鎖的狀態變為可用,那麼這個執行緒將跳過佇列中所有的等待執行緒並獲得這個鎖(Semaphore與此相似)。在公平的鎖中,如果一個執行緒持有這個鎖或者有其他執行緒在佇列中等待這個鎖,那麼新發出請求的執行緒將被放入佇列中。在非公平的鎖中,只有當鎖被某個執行緒持有時,新發出請求的執行緒才會被放入佇列中。(注意:即使是公平鎖,可輪詢的tryLock仍然會“插隊”)。

 

我們為什麼不希望所有的鎖都是公平的?畢竟,公平是一種好的行為,對不對?當執行加鎖操作時,公平性將由於在掛起執行緒和恢復執行緒時存在的開銷而極大的降低了效能。有些演算法依賴於公平的排隊演算法以確保他們的正確性,但這些演算法並不常見。在大多數情況下,非公平鎖的效能要高於公平鎖的效能。

 

13-2給出了Map的效能測試,採用了對數縮放比例的方式,比較由公平的以及非公平的ReentrantLock包裝的HashMap的效能,執行環境為4路的Opteron系統,作業系統是Solaris。從圖可見公平性把效能降低了約兩個數量級,不必要的話不要為了公平性而付出代價。 

 

 

在激烈競爭的情況下,非公平鎖的效能高於公平鎖的效能的一個原因是:在恢復一個被掛起的執行緒與該執行緒真正開始執行之間存在著嚴重的延遲。假設執行緒A持有一個鎖,並且執行緒B在請求這個鎖。由於這個鎖已經被執行緒A持有,因此B將被掛起。當A釋放鎖時,B將被喚醒,因此會再次嘗試獲取鎖。與此同時,如果C也請求這個鎖,那麼C很可能會在B被完全喚醒之前獲得、使用以及釋放這個鎖。這樣的情況時一種“雙贏”的局面:在B獲得鎖的時刻並沒有推遲,C更早的獲得了鎖,並且吞吐量也獲得了提高。

 

當持有鎖的時間相對較長,或者請求鎖的平均時間間隔較長,那麼應該使用公平鎖。在這些情況下,“插隊”帶來的吞吐量提升(當鎖處於可用狀態時,執行緒卻還處於被喚醒的過程中)則可能不會出現。

與預設的ReentrantLock一樣,內建加鎖並不會提供確定的公平性保證,Java語言規範對此也並沒有要求。

  

13.4 synchronized vs. ReentrantLock

ReentrantLock在加鎖和記憶體上提供的語義與內建鎖相同,此外還提供了一些其他功能,包括定時的鎖等待、可中斷的鎖等待、公平性以及實現非塊結構的加鎖。ReentrantLock在效能上似乎優於內建鎖,其中在Java 6.0中略有勝出,而在Java 5.0中則是遠遠勝出。那麼為什麼不放棄synchronized,在所有新的併發程式碼中都適用ReentrantLock呢?

 

原因有四:

  1. 與顯式鎖相比,內建鎖仍然具有很大優勢:內建鎖為許多開發人員所熟知,並且簡潔緊湊。
  2. 許多現有程式中都已經使用了內建鎖,如果將兩種機制混合使用,那麼不僅容易令人困惑,也容易發生錯誤。
  3. ReentrantLock的危險性比較高,如果忘記在finally塊中呼叫unlock,那麼雖然程式碼表面上能正常執行,但實際上已經買下了一顆“定時炸彈”。
  4. 未來更可能會提升效能的時synchronized而不是ReentrantLock。因為synchronized時JVM的內建屬性,他能執行一些優化,例如對執行緒封閉的鎖物件的鎖消除優化,通過增加鎖的顆粒度來消除內建鎖的同步。

故,只有當內建鎖不能滿足需求的情況下,ReentrantLock可以作為一種高階的工具,否則還是應該優先使用synchronized。

 

13.5 -寫鎖

ReentrantLock實現了一種標準的互斥鎖,每次最多隻有一個執行緒能持有ReentrantLock。但對於維護資料的完整性來說,互斥通常是一種過於強硬的加鎖規則,因此也就不必要的限制了併發行。

互斥是一種保守的加鎖策略,雖然可以避免“寫/寫”衝突和“寫/讀”衝突,但同樣避免了“讀/讀”衝突。在許多情況下,資料結構上的訪問操作都是“讀操作”,此時如果能方塊加鎖需求,允許多個執行讀操作的執行緒同時訪問資料結構,那麼將提升程式的效能。

在這種情況下,可以使用讀/寫鎖:一個資源可以被多個讀操作訪問,或者被一個寫操作訪問,但是兩者不能同時進行。

 

在程式清單 13-6 的ReadWriteLock中暴露了兩個Lock物件,其中一個用於讀操作,而一個用於寫操作。要讀取由ReadWriteLock保護的資料必須首先獲得讀取鎖,當需要修改ReadWriteLock保護的資料時必須首先獲得寫入鎖。儘管這兩個鎖看上去是彼此獨立的,但讀取鎖和寫入鎖只是讀-寫鎖物件的不同檢視。

 程式清單 13-6 ReadWriteLock介面

public interface ReadWriteLock {

    /**

     * Returns the lock used for reading.

     *

     * @return the lock used for reading

     */

    Lock readLock();

 

    /**

     * Returns the lock used for writing.

     *

     * @return the lock used for writing

     */

    Lock writeLock();

}

-寫鎖是一種效能優化措施,在一些特定的情況下能實現更高的併發性。在實際情況中,對於在多處理器系統上被頻繁讀取的資料結構,讀-寫鎖能夠提高效能。而在其他情況下,讀-寫鎖的效能要比獨佔鎖的效能要略差一些,這是因為他們的複雜性更高。如果要判斷在某些情況下使用讀-寫鎖是否會帶來效能提升,最好對程式進行分析。

由於ReadWriteLock使用Lock來實現鎖的讀-寫部分,因此在需要的時候,可以容易的將讀-寫鎖換位獨佔鎖。

 

ReentrantReadWriteLock為這兩種鎖都提供了可重入的加鎖語義。與ReentrantLock類似,ReentrantReadWriteLock也只非公平鎖(預設)和公平鎖。在公平鎖中,等待時間最長的執行緒將優先獲得鎖。如果這個鎖由讀執行緒持有,而另一個執行緒請求寫入鎖,那麼其他讀執行緒都不能獲得讀取鎖,知道寫執行緒使用完並且釋放了寫入鎖。在非公平鎖中,執行緒獲得訪問許可的順序是不確定的。寫執行緒降級為讀執行緒是可以的,但從讀執行緒升級為寫執行緒則是不可以的(可能兩個讀執行緒是圖同時升級為寫入鎖,從而導致死鎖)。

 

ReentrantLock類似的是,ReentrantReadWriteLock中的寫入鎖智慧有唯一的所有者,並且只能由獲得該鎖的執行緒來釋放。ps:在Java 5中讀取鎖只維護活躍的讀執行緒的數量,而在Java 6中則記錄了哪些執行緒獲得了讀取鎖。

 

當鎖的持有時間較長並且大部分操作都不會修改被守護的資源時,那麼讀-寫鎖能提高併發性,在程式清單13-7的ReadWriteMap中使用了ReentrantReadWriteLock來包裝Map,從而使他能在多個讀寫成之間被安全的共享,並且仍然能避免“讀-寫”或“寫-寫”衝突。在現實中,ConcurrentHashMap的效能已經很好了,一次如果只需要一個併發的基於雜湊的對映時,直接使用ConcurrentHashMap就可以了,但如果需要對另一宗Map實現(如LinkedHashMap)提供併發性更高的訪問,那麼就可以使用該技術了。

 程式清單 13-7 用讀-寫鎖來包裝Map

public class ReadWriteMap<K, V> {

    

    private final Map<K, V> map;

    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    private final Lock r = lock.readLock();

    private final Lock w = lock.writeLock();

 

    public ReadWriteMap(Map<K, V> map) {

        this.map = map;

    }

    

    public V put(K key, V value) {

        w.lock();

        try {

            return map.put(key, value);

        } finally {

            w.unlock();

        }

    }

    // 對remove, putAll,clear等方法執行相同的操作

    

    public V get(Object key) {

        r.lock();

        try {

            return map.get(key);

        } finally {

            r.unlock();

        }

    }

    // 對其他只讀的Map方執行相同的操作

    

}

13-3給出了分別中ReentrantLock和ReadWriteLock來封裝ArrayList的吞吐量比較。測試成為為4路Opteron系統,作業系統為Solaris。這裡是使用的測試程式:每個操作隨機的選擇一個值並在容器中查詢這個值,並且只有少量操作會修改這個容器中的內容。

 

小結

與內建鎖相比,顯示的Lock提供了一些擴充套件功能,在處理鎖的不可用性方面有著更高靈活性,並且對佇列行有著更好的控制。但ReentrantLocak不能完全替代snchronized,只有在snchronized無法滿足需求時才應該使用它。

-寫鎖允許多個讀執行緒併發地訪問被保護物件,當訪問以讀取操作為主的資料結構時,它能提高程式的可伸縮性。

相關文章