從 LongAdder 中窺見併發元件的設計思路
最近在看阿里的 Sentinel 的原始碼的時候。發現使用了一個類 LongAdder 來在併發環境中計數。這個時候就提出了疑問,JDK 中已經有 AtomicLong 了,為啥還要使用 LongAdder ? AtomicLong 已經是基於 CAS 的無鎖結構,已經有很好的並發表現了,為啥還要用 LongAdder ?於是趕快找來原始碼一探究竟。
AtomicLong 的缺陷
大家可以閱讀我之前寫的 JAVA 中的 CAS 詳細瞭解 AtomicLong 的實現原理。需要注意的一點是,AtomicLong 的 Add() 是依賴自旋不斷的 CAS 去累加一個 Long 值。如果在競爭激烈的情況下,CAS 操作不斷的失敗,就會有大量的執行緒不斷的自旋嘗試 CAS 會造成 CPU 的極大的消耗。
LongAdder 解決方案
通過閱讀 LongAdder 的 Javadoc 我們瞭解到:
This class is usually preferable to {@link AtomicLong} when multiple threads update a common sum that is used for purposes such as collecting statistics, not for fine-grained synchronization control. Under low update contention, the two classes have similar characteristics. But under high contention, expected throughput of this class is significantly higher, at the expense of higher space consumption.
大概意思就是,LongAdder 功能類似 AtomicLong ,在低併發情況下二者表現差不多,在高併發情況下 LongAdder 的表現就會好很多。
LongAdder 到底用了什麼黑科技能做到高性比 AtomicLong 還要好呢呢?對於同樣的一個 add() 操作,上文說到 AtomicLong 只對一個 Long 值進行 CAS 操作。而 LongAdder 是針對 Cell 陣列的某個 Cell 進行 CAS 操作 ,把執行緒的名字的 hash 值,作為 Cell 陣列的下標,然後對 Cell[i] 的 long 進行 CAS 操作。簡單粗暴的分散了高併發下的競爭壓力。
LongAdder 的實現細節
雖然原理簡單粗暴,但是程式碼寫得卻相當細緻和精巧。
在 java.util.concurrent.atomic
包下面我們可以看到 LongAdder 的原始碼。首先看 add() 方法的原始碼。
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
看到這個 add() 方法,首先需要了解 Cell 是什麼?
Cell 是 java.util.concurrent.atomic
下 Striped64
的一個內部類。
@sun.misc.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}
// unsafe 機制
// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
首先 Cell 被 @sun.misc.Contended 修飾。意思是讓Java編譯器和JRE執行時來決定如何填充。不理解不要緊,不影響理解。
其實一個 Cell 的本質就是一個 volatile 修飾的 long 值,且這個值能夠進行 cas 操作。
回到我們的 add() 方法。
這裡涉及四個額外的方法 casBase() , getProbe() , a.cas() , longAccumulate();
我們看名字就知道 casBase() 和 a.cas() 都是對引數的 cas 操作。
getProbe() 的作用,就是根據當前執行緒 hash 出一個 int 值。
longAccumlate() 的作用比較複雜,之後我們會講解。
所以這個 add() 操作歸納以後就是:
- 如果 cells 陣列不為空,對引數進行 casBase 操作,如果 casBase 操作失敗。可能是競爭激烈,進入第二步。
- 如果 cells 為空,直接進入 longAccumulate();
- m = cells 陣列長度減一,如果陣列長度小於 1,則進入 longAccumulate()
- 如果都沒有滿足以上條件,則對當前執行緒進行某種 hash 生成一個陣列下標,對下標儲存的值進行 cas 操作。如果操作失敗,則說明競爭依然激烈,則進入 longAccumulate().
可見,操作的核心思想還是基於 cas。但是 cas 失敗後,並不是傻乎乎的自旋,而是逐漸升級。升級的 cas 都不管用了則進入 longAccumulate() 這個方法。
下面就開始揭開 longAccumulate 的神祕面紗。
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
if ((h = getProbe()) == 0) {
ThreadLocalRandom.current(); // force initialization
h = getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
for (;;) {
Cell[] as; Cell a; int n; long v;
//如果操作的cell 為空,double check 新建 cell
if ((as = cells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
Cell r = new Cell(x); // Optimistically create
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
// cas 失敗 繼續迴圈
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// 如果 cell cas 成功 break
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cell 的長度已經大於等於 cpu 的數量,擴容意義不大,就不用標記衝突,重試
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
else if (!collide)
collide = true;
// 獲取鎖,上鎖擴容,將衝突標記為否,繼續執行
else if (cellsBusy == 0 && casCellsBusy()) {
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 沒法獲取鎖,重雜湊,嘗試其他槽
h = advanceProbe(h);
}
// 獲取鎖,初始化 cell 陣列
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
boolean init = false;
try { // Initialize table
if (cells == as) {
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
// 表未被初始化,可能正在初始化,回退使用 base。
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}
longAccumulate 看上去比較複雜。我們慢慢分析。
回憶一下,什麼情況會進入到這個 longAccumulate 方法中,
- cell[] 陣列為空,
- cell[i] 資料的某個下標元素為空,
- casBase 失敗,
- a.cas 失敗,
- cell.length - 1 < 0
在 longAccumulate 中有幾個標記位,我們也先理解一下
-
cellsBusy
cells 的操作標記位,如果正在修改、新建、操作 cells 陣列中的元素會,會將其 cas 為 1,否則為0。 -
wasUncontended
表示 cas 是否失敗,如果失敗則考慮操作升級。 -
collide
是否衝突,如果衝突,則考慮擴容 cells 的長度。
整個 for(;;) 死迴圈,都是以 cas 操作成功而告終。否則則會修改上述描述的幾個標記位,重新進入迴圈。
所以整個迴圈包括如下幾種情況:
-
cells 不為空
- 如果 cell[i] 某個下標為空,則 new 一個 cell,並初始化值,然後退出
- 如果 cas 失敗,繼續迴圈
- 如果 cell 不為空,且 cell cas 成功,退出
- 如果 cell 的數量,大於等於 cpu 數量或者已經擴容了,繼續重試。(擴容沒意義)
- 設定 collide 為 true。
- 獲取 cellsBusy 成功就對 cell 進行擴容,獲取 cellBusy 失敗則重新 hash 再重試。
cells 為空且獲取到 cellsBusy ,init cells 陣列,然後賦值退出。
cellsBusy 獲取失敗,則進行 baseCas ,操作成功退出,不成功則重試。
至此 longAccumulate 就分析完了。之所以這個方法那麼複雜,我認為有兩個原因
- 是因為併發環境下要考慮各種操作的原子性,所以對於鎖都進行了 double check。
- 操作都是逐步升級,以最小的代價實現功能。
最後說說 LongAddr 的 sum() 方法,這個就很簡單了。
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
就是遍歷 cell 陣列,累加 value 就行。LongAdder 餘下的方法就比較簡單,沒有什麼可以討論的了。
LongAdder VS AtomicLong
看上去 LongAdder 效能全面超越了 AtomicLong。為什麼 jdk 1.8 中還是保留了 AtomicLong 的實現呢?
其實我們可以發現,LongAdder 使用了一個 cell 列表去承接併發的 cas,以提升效能,但是 LongAdder 在統計的時候如果有併發更新,可能導致統計的資料有誤差。
如果用於自增 id 的生成,就不適合使用 LongAdder 了。這個時候使用 AtomicLong 就是一個明智的選擇。
而在 Sentinel 中 LongAdder 承擔的只是統計任務,且允許誤差。
總結
LongAdder 使用了一個比較簡單的原理,解決了 AtomicLong 類,在極高競爭下的效能問題。但是 LongAdder 的具體實現卻非常精巧和細緻,分散競爭,逐步升級競爭的解決方案,相當漂亮,值得我們細細品味。
歡迎關注我的微信公眾號
相關文章
- 我從LongAdder中窺探到了高併發的祕籍,上面只寫了兩個字...
- 比AtomicLong更高效的併發計數類LongAdder
- 併發程式設計——多執行緒計數的更優解:LongAdder原理分析程式設計執行緒
- # iOS 一窺併發程式設計底層(一)iOS程式設計
- .NET 中的併發程式設計程式設計
- Python併發程式設計之從效能角度來初探併發程式設計(一)Python程式設計
- go 併發程式設計案例二 常見併發模型介紹Go程式設計模型
- 死磕 java併發包之LongAdder原始碼分析Java原始碼
- Java併發程式設計中的設計模式解析(一)Java程式設計設計模式
- Java併發程式設計-volatile可見性的介紹Java程式設計
- 【高併發】如何設計一個支撐高併發大流量的系統?這次我將設計思路分享給大家!
- 從執行緒到併發程式設計執行緒程式設計
- Java併發程式設計-併發程式設計的Bug源頭:可見性、原子性和有序性問題Java程式設計
- 鴻蒙程式設計江湖:併發程式設計基礎與鴻蒙中的任務併發鴻蒙程式設計
- Java 併發程式設計(十) -- ReentrantLock中的SyncJava程式設計ReentrantLock
- Java 併發程式設計(六) -- ThreadPoolExecutor中的WorkerJava程式設計thread
- Java 併發程式設計(八) -- AbstractQueuedSynchronizer中的NodeJava程式設計
- Fusion Next 之 Upload 上傳元件設計思路元件
- Java 高併發思路Java
- c++11併發程式設計歷程(15):併發設計以及併發設計資料結構的思考C++程式設計資料結構
- Java併發程式設計的藝術(五)——中斷Java程式設計
- Go 併發程式設計中的經驗教訓Go程式設計
- 併發程式設計中,你加的鎖未必安全程式設計
- 併發程式設計從零開始(十一)-Atomic類程式設計
- 併發程式設計程式設計
- hi-nginx-java併發效能一窺NginxJava
- 記一個複雜元件(Filter)的從設計到開發元件Filter
- Java併發程式設計的藝術,解讀併發程式設計的優缺點Java程式設計
- Go 併發程式設計 - 併發安全(二)Go程式設計
- 《java併發程式設計的藝術》併發工具類Java程式設計
- Java併發程式設計---java規範與模式下的併發程式設計1.1Java程式設計模式
- 【併發程式設計】Future模式及JDK中的實現程式設計模式JDK
- Java 併發程式設計(十一) -- ReentrantLock中的公平鎖FairSyncJava程式設計ReentrantLockAI
- Java併發程式設計中的鎖機制詳解Java程式設計
- 併發程式設計從零開始(九)-ConcurrentSkipListMap&Set程式設計
- 併發程式設計從零開始(十四)-Executors工具類程式設計
- java併發程式設計系列:java併發程式設計背景知識Java程式設計
- java 併發程式設計Java程式設計