Java多線程編程核心技術之同步鎖(2)

深入瞭解Lock同步鎖的優化方法
在 JDK1.5 之後,Java 還提供了 Lock 同步鎖。那麼它有什麼優勢呢?

相對於需要 JVM 隱式獲取和釋放鎖的 Synchronized 同步鎖,Lock 同步鎖(以下簡稱 Lock 鎖)需要的是顯示獲取和釋放鎖,這就爲獲取和釋放鎖提供了更多的靈活性。Lock 鎖的基本操作是通過樂觀鎖來實現的,但由於 Lock 鎖也會在阻塞時被掛起,因此它依然屬於悲觀鎖。我們可以通過一張圖來簡單對比下兩個同步鎖,瞭解下各自的特點:

從性能方面上來說,在併發量不高、競爭不激烈的情況下,Synchronized 同步鎖由於具有分級鎖的優勢,性能上與 Lock 鎖差不多;但在高負載、高併發的情況下,Synchronized 同步鎖由於競爭激烈會升級到重量級鎖,性能則沒有 Lock 鎖穩定。

我們可以通過一組簡單的性能測試,直觀地對比下兩種鎖的性能,結果見下方,代碼可以在Github上下載查看。

通過以上數據,我們可以發現:Lock 鎖的性能相對來說更加穩定。那它與上一講的 Synchronized 同步鎖相比,實現原理又是怎樣的呢?

Lock 鎖的實現原理

Lock 鎖是基於 Java 實現的鎖,Lock 是一個接口類,常用的實現類有 ReentrantLock、ReentrantReadWriteLock(RRW),它們都是依賴 AbstractQueuedSynchronizer(AQS)類實現的。

AQS 類結構中包含一個基於鏈表實現的等待隊列(CLH 隊列),用於存儲所有阻塞的線程,AQS 中還有一個 state 變量,該變量對 ReentrantLock 來說表示加鎖狀態。

該隊列的操作均通過 CAS 操作實現,我們可以通過一張圖來看下整個獲取鎖的流程。

鎖分離優化 Lock 同步鎖

雖然 Lock 鎖的性能穩定,但也並不是所有的場景下都默認使用 ReentrantLock 獨佔鎖來實現線程同步。

我們知道,對於同一份數據進行讀寫,如果一個線程在讀數據,而另一個線程在寫數據,那麼讀到的數據和最終的數據就會不一致;如果一個線程在寫數據,而另一個線程也在寫數據,那麼線程前後看到的數據也會不一致。這個時候我們可以在讀寫方法中加入互斥鎖,來保證任何時候只能有一個線程進行讀或寫操作。

在大部分業務場景中,讀業務操作要遠遠大於寫業務操作。而在多線程編程中,讀操作並不會修改共享資源的數據,如果多個線程僅僅是讀取共享資源,那麼這種情況下其實沒有必要對資源進行加鎖。如果使用互斥鎖,反倒會影響業務的併發性能,那麼在這種場景下,有沒有什麼辦法可以優化下鎖的實現方式呢?

  1. 讀寫鎖 ReentrantReadWriteLock
    針對這種讀多寫少的場景,Java 提供了另外一個實現 Lock 接口的讀寫鎖 RRW。我們已知 ReentrantLock 是一個獨佔鎖,同一時間只允許一個線程訪問,而 RRW 允許多個讀線程同時訪問,但不允許寫線程和讀線程、寫線程和寫線程同時訪問。讀寫鎖內部維護了兩個鎖,一個是用於讀操作的 ReadLock,一個是用於寫操作的 WriteLock。

那讀寫鎖又是如何實現鎖分離來保證共享資源的原子性呢?

RRW 也是基於 AQS 實現的,它的自定義同步器(繼承 AQS)需要在同步狀態 state 上維護多個讀線程和一個寫線程的狀態,該狀態的設計成爲實現讀寫鎖的關鍵。RRW 很好地使用了高低位,來實現一個整型控制兩種狀態的功能,讀寫鎖將變量切分成了兩個部分,高 16 位表示讀,低 16 位表示寫。

一個線程嘗試獲取寫鎖時,會先判斷同步狀態 state 是否爲 0。如果 state 等於 0,說明暫時沒有其它線程獲取鎖;如果 state 不等於 0,則說明有其它線程獲取了鎖。

此時再判斷同步狀態 state 的低 16 位(w)是否爲 0,如果 w 爲 0,則說明其它線程獲取了讀鎖,此時進入 CLH 隊列進行阻塞等待;如果 w 不爲 0,則說明其它線程獲取了寫鎖,此時要判斷獲取了寫鎖的是不是當前線程,若不是就進入 CLH 隊列進行阻塞等待;若是,就應該判斷當前線程獲取寫鎖是否超過了最大次數,若超過,拋異常,反之更新同步狀態。

一個線程嘗試獲取讀鎖時,同樣會先判斷同步狀態 state 是否爲 0。如果 state 等於 0,說明暫時沒有其它線程獲取鎖,此時判斷是否需要阻塞,如果需要阻塞,則進入 CLH 隊列進行阻塞等待;如果不需要阻塞,則 CAS 更新同步狀態爲讀狀態。

如果 state 不等於 0,會判斷同步狀態低 16 位,如果存在寫鎖,則獲取讀鎖失敗,進入 CLH 阻塞隊列;反之,判斷當前線程是否應該被阻塞,如果不應該阻塞則嘗試 CAS 同步狀態,獲取成功更新同步鎖爲讀狀態。

下面我們通過一個求平方的例子,來感受下 RRW 的實現,代碼如下:

public class TestRTTLock {

private double x, y;

private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 讀鎖
private Lock readLock = lock.readLock();
// 寫鎖
private Lock writeLock = lock.writeLock();

public double read() {
//獲取讀鎖
readLock.lock();
try {
return Math.sqrt(x * x + y * y);
} finally {
//釋放讀鎖
readLock.unlock();
}
}

public void move(double deltaX, double deltaY) {
//獲取寫鎖
writeLock.lock();
try {
x += deltaX;
y += deltaY;
} finally {
//釋放寫鎖
writeLock.unlock();
}
}

}

  1. 讀寫鎖再優化之 StampedLock
    RRW 被很好地應用在了讀大於寫的併發場景中,然而 RRW 在性能上還有可提升的空間。在讀取很多、寫入很少的情況下,RRW 會使寫入線程遭遇飢餓(Starvation)問題,也就是說寫入線程會因遲遲無法競爭到鎖而一直處於等待狀態。

在 JDK1.8 中,Java 提供了 StampedLock 類解決了這個問題。StampedLock 不是基於 AQS 實現的,但實現的原理和 AQS 是一樣的,都是基於隊列和鎖狀態實現的。與 RRW 不一樣的是,StampedLock 控制鎖有三種模式: 寫、悲觀讀以及樂觀讀,並且 StampedLock 在獲取鎖時會返回一個票據 stamp,獲取的 stamp 除了在釋放鎖時需要校驗,在樂觀讀模式下,stamp 還會作爲讀取共享資源後的二次校驗,後面我會講解 stamp 的工作原理。

我們先通過一個官方的例子來了解下 StampedLock 是如何使用的,代碼如下:

public class Point {
private double x, y;
private final StampedLock s1 = new StampedLock();

void move(double deltaX, double deltaY) {
    //獲取寫鎖
    long stamp = s1.writeLock();
    try {
        x += deltaX;
        y += deltaY;
    } finally {
        //釋放寫鎖
        s1.unlockWrite(stamp);
    }
}

double distanceFormOrigin() {
    //樂觀讀操作
    long stamp = s1.tryOptimisticRead();  
    //拷貝變量
    double currentX = x, currentY = y;
    //判斷讀期間是否有寫操作
    if (!s1.validate(stamp)) {
        //升級爲悲觀讀
        stamp = s1.readLock();
        try {
            currentX = x;
            currentY = y;
        } finally {
            s1.unlockRead(stamp);
        }
    }
    return Math.sqrt(currentX * currentX + currentY * currentY);
}

}

我們可以發現:一個寫線程獲取寫鎖的過程中,首先是通過 WriteLock 獲取一個票據 stamp,WriteLock 是一個獨佔鎖,同時只有一個線程可以獲取該鎖,當一個線程獲取該鎖後,其它請求的線程必須等待,當沒有線程持有讀鎖或者寫鎖的時候纔可以獲取到該鎖。請求該鎖成功後會返回一個 stamp 票據變量,用來表示該鎖的版本,當釋放該鎖的時候,需要 unlockWrite 並傳遞參數 stamp。

接下來就是一個讀線程獲取鎖的過程。首先線程會通過樂觀鎖 tryOptimisticRead 操作獲取票據 stamp ,如果當前沒有線程持有寫鎖,則返回一個非 0 的 stamp 版本信息。線程獲取該 stamp 後,將會拷貝一份共享資源到方法棧,在這之前具體的操作都是基於方法棧的拷貝數據。

之後方法還需要調用 validate,驗證之前調用 tryOptimisticRead 返回的 stamp 在當前是否有其它線程持有了寫鎖,如果是,那麼 validate 會返回 0,升級爲悲觀鎖;否則就可以使用該 stamp 版本的鎖對數據進行操作。

相比於 RRW,StampedLock 獲取讀鎖只是使用與或操作進行檢驗,不涉及 CAS 操作,即使第一次樂觀鎖獲取失敗,也會馬上升級至悲觀鎖,這樣就可以避免一直進行 CAS 操作帶來的 CPU 佔用性能的問題,因此 StampedLock 的效率更高。

總結

不管使用 Synchronized 同步鎖還是 Lock 同步鎖,只要存在鎖競爭就會產生線程阻塞,從而導致線程之間的頻繁切換,最終增加性能消耗。因此,如何降低鎖競爭,就成爲了優化鎖的關鍵。

在 Synchronized 同步鎖中,我們瞭解了可以通過減小鎖粒度、減少鎖佔用時間來降低鎖的競爭。我們知道可以利用 Lock 鎖的靈活性,通過鎖分離的方式來降低鎖競爭。Lock 鎖實現了讀寫鎖分離來優化讀大於寫的場景,從普通的 RRW 實現到讀鎖和寫鎖,到 StampedLock 實現了樂觀讀鎖、悲觀讀鎖和寫鎖,都是爲了降低鎖的競爭,促使系統的併發性能達到最佳。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章