原子類型累加器



本博客系列是學習並發編程過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。

並發編程系列博客傳送門


原子類型累加器JDK1.8引進的並發新技術,它可以看做AtomicLongAtomicDouble的部分加強類型。

原子類型累加器有如下四種:

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder

由於上面四種累加器的原理類似,下面以LongAdder為列來介紹累加器的使用。以下內存是轉載內容,原文請見此博客

LongAdder簡介

JDK1.8時,java.util.concurrent.atomic包中提供了一個新的原子類:LongAdder
根據Oracle官方文檔的介紹,LongAdder在高並發的場景下會比它的前輩————AtomicLong 具有更好的性能,代價是消耗更多的內存空間:
clipboard.png

那么,問題來了:

為什么要引入LongAdder? AtomicLong在高並發的場景下有什么問題嗎? 如果低並發環境下,LongAdder和AtomicLong性能差不多,那LongAdder是否就可以替代AtomicLong了?

為什么要引入LongAdder?

我們知道,AtomicLong是利用了底層的CAS操作來提供並發性的,比如addAndGet方法:

clipboard.png

上述方法調用了Unsafe類的getAndAddLong方法,該方法是個native方法,它的邏輯是采用自旋的方式不斷更新目標值,直到更新成功。

在並發量較低的環境下,線程沖突的概率比較小,自旋的次數不會很多。但是,高並發環境下,N個線程同時進行自旋操作,會出現大量失敗並不斷自旋的情況,此時AtomicLong的自旋會成為瓶頸。

這就是LongAdder引入的初衷——解決高並發環境下AtomicLong的自旋瓶頸問題。

LongAdder快在哪里?

既然說到LongAdder可以顯著提升高並發環境下的性能,那么它是如何做到的?這里先簡單的說下LongAdder的思路,第二部分會詳述LongAdder的原理。

我們知道,AtomicLong中有個內部變量value保存着實際的long值,所有的操作都是針對該變量進行。也就是說,高並發環境下,value變量其實是一個熱點,也就是N個線程競爭一個熱點。

LongAdder的基本思路就是分散熱點,將value值分散到一個數組中,不同線程會命中到數組的不同槽中,各個線程只對自己槽中的那個值進行CAS操作,這樣熱點就被分散了,沖突的概率就小很多。如果要獲取真正的long值,只要將各個槽中的變量值累加返回。

這種做法有沒有似曾相識的感覺?沒錯,ConcurrentHashMap中的“分段鎖”其實就是類似的思路。

LongAdder能否替代AtomicLong?

回答這個問題之前,我們先來看下LongAdder提供的API:
clipboard.png

可以看到,LongAdder提供的API和AtomicLong比較接近,兩者都能以原子的方式對long型變量進行增減。

但是AtomicLong提供的功能其實更豐富,尤其是addAndGetdecrementAndGetcompareAndSet這些方法。

addAndGetdecrementAndGet除了單純的做自增自減外,還可以立即獲取增減后的值,而LongAdder則需要做同步控制才能精確獲取增減后的值。如果業務需求需要精確的控制計數,做計數比較,AtomicLong也更合適。

另外,從空間方面考慮,LongAdder其實是一種“空間換時間”的思想,從這一點來講AtomicLong更適合。當然,如果你一定要跟我杠現代主機的內存對於這點消耗根本不算什么,那我也辦法。

總之,低並發、一般的業務場景下AtomicLong是足夠了。如果並發量很多,存在大量寫多讀少的情況,那LongAdder可能更合適。適合的才是最好的,如果真出現了需要考慮到底用AtomicLong好還是LongAdder的業務場景,那么這樣的討論是沒有意義的,因為這種情況下要么進行性能測試,以准確評估在當前業務場景下兩者的性能,要么換個思路尋求其它解決方案。

最后,給出國外一位博主對LongAdder和AtomicLong的性能評測,以供參考:http://blog.palominolabs.com/...

LongAdder原理

之前說了,AtomicLong是多個線程針對單個熱點值value進行原子操作。而LongAdder是每個線程擁有自己的槽,各個線程一般只對自己槽中的那個值進行CAS操作。

比如有三個ThreadA、ThreadB、ThreadC,每個線程對value增加10。

對於AtomicLong,最終結果的計算始終是下面這個形式:

value = 10 + 10 + 10

但是對於LongAdder來說,內部有一個base變量,一個Cell[]數組。
base變量:非競態條件下,直接累加到該變量上
Cell[]數組:競態條件下,累加個各個線程自己的槽Cell[i]
最終結果的計算是下面這個形式:

LongAdder的內部結構

LongAdder只有一個空構造器,其本身也沒有什么特殊的地方,所有復雜的邏輯都在它的父類Striped64中。
clipboard.png

來看下Striped64的內部結構,這個類實現一些核心操作,處理64位數據。
Striped64只有一個空構造器,初始化時,通過Unsafe獲取到類字段的偏移量,以便后續CAS操作:
clipboard.png

上面有個比較特殊的字段是threadLocalRandomProbe,可以把它看成是線程的hash值。這個后面我們會講到。

定義了一個內部Cell類,這就是我們之前所說的槽,每個Cell對象存有一個value值,可以通過Unsafe來CAS操作它的值:
clipboard.png

其它的字段:
可以看到Cell[]就是之前提到的槽數組,base就是非並發條件下的基數累計值。
clipboard.png

LongAdder的核心方法

還是通過例子來看:
假設現在有一個LongAdder對象la,四個線程A、B、C、D同時對la進行累加操作。

LongAdder la = new LongAdder();
la.add(10);

ThreadA調用add方法(假設此時沒有並發):
clipboard.png

初始時Cell[]為null,base為0。所以ThreadA會調用casBase方法(定義在Striped64中),因為沒有並發,CAS操作成功將base變為10:
clipboard.png

可以看到,如果線程A、B、C、D線性執行,那casBase永遠不會失敗,也就永遠不會進入到base方法的if塊中,所有的值都會累積到base中。
那么,如果任意線程有並發沖突,導致caseBase失敗呢?

失敗就會進入if方法體:
clipboard.png

這個方法體會先再次判斷Cell[]槽數組有沒初始化過,如果初始化過了,以后所有的CAS操作都只針對槽中的Cell;否則,進入longAccumulate方法。

整個add方法的邏輯如下圖:
clipboard.png

可以看到,只有從未出現過並發沖突的時候,base基數才會使用到,一旦出現了並發沖突,之后所有的操作都只針對Cell[]數組中的單元Cell。
如果Cell[]數組未初始化,會調用父類的longAccumelate去初始化Cell[],如果Cell[]已經初始化但是沖突發生在Cell單元內,則也調用父類的longAccumelate,此時可能就需要對Cell[]擴容了。

這也是LongAdder設計的精妙之處:盡量減少熱點沖突,不到最后萬不得已,盡量將CAS操作延遲。

Striped64的核心方法

我們來看下Striped64的核心方法longAccumulate到底做了什么:
clipboard.png

上述代碼首先給當前線程分配一個hash值,然后進入一個自旋,這個自旋分為三個分支:

  • CASE1:Cell[]數組已經初始化
  • CASE2:Cell[]數組未初始化
  • CASE3:Cell[]數組正在初始化中

CASE2:Cell[]數組未初始化

我們之前討論了,初始時Cell[]數組還沒有初始化,所以會進入分支②:
clipboard.png

首先會將cellsBusy置為1-加鎖狀態
clipboard.png

然后,初始化Cell[]數組(初始大小為2),根據當前線程的hash值計算映射的索引,並創建對應的Cell對象,Cell單元中的初始值x就是本次要累加的值。

CASE3:Cell[]數組正在初始化中

如果在初始化過程中,另一個線程ThreadB也進入了longAccumulate方法,就會進入分支③:
clipboard.png

可以看到,分支③直接操作base基數,將值累加到base上。

CASE1:Cell[]數組已經初始化

如果初始化完成后,其它線程也進入了longAccumulate方法,就會進入分支①:
clipboard.png

整個longAccumulate的流程圖如下:
clipboard.png

LongAdder的sum方法

最后,我們來看下LongAddersum方法:
clipboard.png

sum求和的公式就是我們開頭說的:

需要注意的是,這個方法只能得到某個時刻的近似值,這也就是LongAdder並不能完全替代LongAtomic的原因之一。

PS: 由於計算總和時沒有對Cell數組進行加鎖,所以在累加過程中可能有其他線程對Cell中的值進行了修改,也有可能對數組進行了擴容,所以sum返回的值並不是非常精確的,其返回值並不是一個調用sum方法時的原子快照值。

LongAdder的其它兄弟

JDK1.8時,java.util.concurrent.atomic包中,除了新引入LongAdder外,還有引入了它的三個兄弟類:LongAccumulatorDoubleAdderDoubleAccumulator

clipboard.png

LongAccumulator

LongAccumulatorLongAdder的增強版。LongAdder只能針對數值的進行加減運算,而LongAccumulator提供了自定義的函數操作。其構造函數如下:
clipboard.png

通過LongBinaryOperator,可以自定義對入參的任意操作,並返回結果(LongBinaryOperator接收2個long作為參數,並返回1個long)

LongAccumulator內部原理和LongAdder幾乎完全一樣,都是利用了父類Striped64longAccumulate方法。這里就不再贅述了,讀者可以自己閱讀源碼。

DoubleAdder和DoubleAccumulator

從名字也可以看出,DoubleAdderDoubleAccumulator用於操作double原始類型。

LongAdder的唯一區別就是,其內部會通過一些方法,將原始的double類型,轉換為long類型,其余和LongAdder完全一樣:
clipboard.png


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM