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

使用樂觀鎖優化並行操作

Synchronized 和 Lock 實現的同步鎖機制,這兩種同步鎖都屬於悲觀鎖,是保護線程安全最直觀的方式。

我們知道悲觀鎖在高併發的場景下,激烈的鎖競爭會造成線程阻塞,大量阻塞線程會導致系統的上下文切換,增加系統的性能開銷。那有沒有可能實現一種非阻塞型的鎖機制來保證線程的安全呢?答案是肯定的。今天我就帶你學習下樂觀鎖的優化方法,看看怎麼使用才能發揮它最大的價值。

什麼是樂觀鎖
開始優化前,我們先來簡單回顧下樂觀鎖的定義。樂觀鎖,顧名思義,就是說在操作共享資源時,它總是抱着樂觀的態度進行,它認爲自己可以成功地完成操作。但實際上,當多個線程同時操作一個共享資源時,只有一個線程會成功,那麼失敗的線程呢?它們不會像悲觀鎖一樣在操作系統中掛起,而僅僅是返回,並且系統允許失敗的線程重試,也允許自動放棄退出操作。

所以,樂觀鎖相比悲觀鎖來說,不會帶來死鎖、飢餓等活性故障問題,線程間的相互影響也遠遠比悲觀鎖要小。更爲重要的是,樂觀鎖沒有因競爭造成的系統開銷,所以在性能上也是更勝一籌。樂觀鎖的實現原理

樂觀鎖的實現原理
相信你對上面的內容是有一定的瞭解的,下面我們來看看樂觀鎖的實現原理,有助於我們從根本上總結優化方法。

CAS 是實現樂觀鎖的核心算法,它包含了 3 個參數:V(需要更新的變量)、E(預期值)和 N(最新值)。

只有當需要更新的變量等於預期值時,需要更新的變量纔會被設置爲最新值,如果更新值和預期值不同,則說明已經有其它線程更新了需要更新的變量,此時當前線程不做操作,返回 V 的真實值。

1.CAS 如何實現原子操作
在 JDK 中的 concurrent 包中,atomic 路徑下的類都是基於 CAS 實現的。AtomicInteger 就是基於 CAS 實現的一個線程安全的整型類。下面我們通過源碼來了解下如何使用 CAS 實現原子操作。

我們可以看到 AtomicInteger 的自增方法 getAndIncrement 是用了 Unsafe 的 getAndAddInt 方法,顯然 AtomicInteger 依賴於本地方法 Unsafe 類,Unsafe 類中的操作方法會調用 CPU 底層指令實現原子操作。

//基於CAS操作更新值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
//基於CAS操作增1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

//基於CAS操作減1
public final int getAndDecrement() {
    return unsafe.getAndAddInt(this, valueOffset, -1);
  1. 處理器如何實現原子操作
    CAS 是調用處理器底層指令來實現原子操作,那麼處理器底層又是如何實現原子操作的呢?

處理器和物理內存之間的通信速度要遠慢於處理器間的處理速度,所以處理器有自己的內部緩存。如下圖所示,在執行操作時,頻繁使用的內存數據會緩存在處理器的 L1、L2 和 L3 高速緩存中,以加快頻繁讀取的速度。

一般情況下,一個單核處理器能自我保證基本的內存操作是原子性的,當一個線程讀取一個字節時,所有進程和線程看到的字節都是同一個緩存裏的字節,其它線程不能訪問這個字節的內存地址。

但現在的服務器通常是多處理器,並且每個處理器都是多核的。每個處理器維護了一塊字節的內存,每個內核維護了一塊字節的緩存,這時候多線程併發就會存在緩存不一致的問題,從而導致數據不一致。

這個時候,處理器提供了總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性。

當處理器要操作一個共享變量的時候,其在總線上會發出一個 Lock 信號,這時其它處理器就不能操作共享變量了,該處理器會獨享此共享內存中的變量。但總線鎖定在阻塞其它處理器獲取該共享變量的操作請求時,也可能會導致大量阻塞,從而增加系統的性能開銷。

於是,後來的處理器都提供了緩存鎖定機制,也就說當某個處理器對緩存中的共享變量進行了操作,就會通知其它處理器放棄存儲該共享資源或者重新讀取該共享資源。目前最新的處理器都支持緩存鎖定機制。

優化 CAS 樂觀鎖
雖然樂觀鎖在併發性能上要比悲觀鎖優越,但是在寫大於讀的操作場景下,CAS 失敗的可能性會增大,如果不放棄此次 CAS 操作,就需要循環做 CAS 重試,這無疑會長時間地佔用 CPU。

在 Java7 中,通過以下代碼我們可以看到:AtomicInteger 的 getAndSet 方法中使用了 for 循環不斷重試 CAS 操作,如果長時間不成功,就會給 CPU 帶來非常大的執行開銷。到了 Java8,for 循環雖然被去掉了,但我們反編譯 Unsafe 類時就可以發現該循環其實是被封裝在了 Unsafe 類中,CPU 的執行開銷依然存在。

public final int getAndSet(int newValue) {
for (;😉 {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}

在 JDK1.8 中,Java 提供了一個新的原子類 LongAdder。LongAdder 在高併發場景下會比 AtomicInteger 和 AtomicLong 的性能更好,代價就是會消耗更多的內存空間。

LongAdder 的原理就是降低操作共享變量的併發數,也就是將對單一共享變量的操作壓力分散到多個變量值上,將競爭的每個寫線程的 value 值分散到一個數組中,不同線程會命中到數組的不同槽中,各個線程只對自己槽中的 value 值進行 CAS 操作,最後在讀取值的時候會將原子操作的共享變量與各個分散在數組的 value 值相加,返回一個近似準確的數值。

LongAdder 內部由一個 base 變量和一個 cell[]數組組成。當只有一個寫線程,沒有競爭的情況下,LongAdder 會直接使用 base 變量作爲原子操作變量,通過 CAS 操作修改變量;當有多個寫線程競爭的情況下,除了佔用 base 變量的一個寫線程之外,其它各個線程會將修改的變量寫入到自己的槽 cell[]數組中,最終結果可通過以下公式計算得出:

我們可以發現,LongAdder 在操作後的返回值只是一個近似準確的數值,但是 LongAdder 最終返回的是一個準確的數值,所以在一些對實時性要求比較高的場景下,LongAdder 並不能取代 AtomicInteger 或 AtomicLong。

總結
在日常開發中,使用樂觀鎖最常見的場景就是數據庫的更新操作了。爲了保證操作數據庫的原子性,我們常常會爲每一條數據定義一個版本號,並在更新前獲取到它,到了更新數據庫的時候,還要判斷下已經獲取的版本號是否被更新過,如果沒有,則執行該操作。

CAS 樂觀鎖在平常使用時比較受限,它只能保證單個變量操作的原子性,當涉及到多個變量時,CAS 就無能爲力了,悲觀鎖可以通過對整個代碼塊加鎖來做到這點。

CAS 樂觀鎖在高併發寫大於讀的場景下,大部分線程的原子操作會失敗,失敗後的線程將會不斷重試 CAS 原子操作,這樣就會導致大量線程長時間地佔用 CPU 資源,給系統帶來很大的性能開銷。在 JDK1.8 中,Java 新增了一個原子類 LongAdder,它使用了空間換時間的方法,解決了上述問題。

基於 JVM 實現的同步鎖 Synchronized,AQS 實現的同步鎖 Lock 以及 CAS 實現的樂觀鎖。相信你也很好奇,這三種鎖,到底哪一種的性能最好,現在我們來對比下三種不同實現方式下的鎖的性能。

鑑於脫離實際業務場景的性能對比測試沒有意義,我們可以分別在“讀多寫少”“讀少寫多”“讀寫差不多”這三種場景下進行測試。又因爲鎖的性能還與競爭的激烈程度有關,所以除此之外,我們還將做三種鎖在不同競爭級別下的性能測試。

綜合上述條件,我將對四種模式下的五個鎖 Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock 以及樂觀鎖 LongAdder 進行壓測。

這裏簡要說明一下:我是在不同競爭級別的情況下,用不同的讀寫線程數組合出了四組測試,測試代碼使用了計算併發計數器,讀線程會去讀取計數器的值,而寫線程會操作變更計數器值,運行環境是 4 核的 i7 處理器。結果已給出,具體的測試代碼可以點擊Github查看下載。

通過以上結果,我們可以發現:在讀大於寫的場景下,讀寫鎖 ReentrantReadWriteLock、StampedLock 以及樂觀鎖的讀寫性能是最好的;在寫大於讀的場景下,樂觀鎖的性能是最好的,其它 4 種鎖的性能則相差不多;在讀和寫差不多的場景下,兩種讀寫鎖以及樂觀鎖的性能要優於 Synchronized 和 ReentrantLock

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