我從LongAdder中窺探到了高併發的祕籍,上面只寫了兩個字...

why技術發表於2020-07-07

這是why的第 53 篇原創文章

荒腔走板

大家好,我是why。

時間過的真是快,一週又要結束了。那麼,你比上週更博學了嗎?先來一個簡短的荒腔走板,給冰冷的技術文注入一絲色彩。

上面這圖是我之前拼的一副拼圖,一共劃分了800塊,背面無提示,難度極高,我花了兩週的時間才拼完。

拼的是壇城,傳說中佛祖居住生活的地方。

第一次知道這個名詞是 2015 年,窩在寢室看紀錄片《第三極》。

其中有一個片段講的就是僧人為了某個節日用沙繪畫壇城,他們的那種專注,虔誠,真摯深深的打動了我,當巨集偉的壇城畫完之後,他靜靜的等待節日的到來。

本以為節日當天眾人會對壇城頂禮膜拜,而實際情況是大家手握一炷香,看著眾僧人快速的摧毀壇城。

還沒來得及仔細欣賞那複雜的美麗的圖案,卻又用掃把掃的乾乾淨淨。

掃把掃下去的那一瞬間,我的心受到了一種強烈的撞擊:可以辛苦地拿起,也可以輕鬆地放下。

看到摧毀壇城的片段的時候,有一個彈幕是這樣說的:

一切有為法,如夢幻泡影,如露亦如電,應作如是觀。

這句話出自《金剛般若波羅蜜經》第三十二品,應化非真分。

因為之前翻閱過幾次《金剛經》,看到這句話的時候我一下就想起了它。

因為讀的時候我就覺得這句話很有哲理,但是也似懂非懂。所以印象比較深刻。

當他再次在壇城這個畫面上以彈幕的形式展現在我的眼前的時候,我一下就懂了其中的哲理,不敢說大徹大悟,至少領悟一二。

觀看摧毀壇城,這個色彩斑斕的世界變幻消失的過程,正常人的感受都是震撼,轉而覺得可惜,心裡久久不能平靜。

但是僧人卻風輕雲淡的說:一切有為法,如夢幻泡影,如露亦如電,應作如是觀。

好了,說迴文章。

先說AtomicLong

關於 AtomicLong 我就不進行詳細的介紹了。

先寫這一小節的目的是預熱一下,丟擲一個問題,而這個問題是關於 CAS 操作和 volatile 關鍵字的。

我不知道原始碼為什麼這樣寫,希望知道答案的朋友指點一二。

抱拳了,老鐵。

為了順利的丟擲這個問題,我就得先用《Java併發程式設計的藝術》一書做引子,引出這個問題。

首先在書的第 2.3 章節《原子操作的實現原理》中介紹處理器是如何實現原子操作時提到了兩點:

  • 使用匯流排鎖保證原子性。
  • 使用快取鎖保證原子性。

所謂匯流排鎖就是使用處理器提供一個提供的一個 LOCK # 訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享記憶體。

匯流排鎖保證原子性的操作有點簡單粗暴直接了,導致匯流排鎖定的開銷比較大。

所以,目前處理器在某些場合下使用快取鎖來進行優化。

快取鎖的概念可以看一下書裡面怎麼寫的:

其中提到的圖 2-3 是這樣的:

其實關鍵 Lock 字首指令。

被 Lock 字首指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問。

而根據 IA-32 架構軟體開發者手冊可以知道,Lock 字首的指令在多核處理器下會引發兩件事情:

  • 將當前處理器快取行的資料寫回系統記憶體。
  • 這個寫回記憶體的操作會使在其他 CPU 裡快取了該記憶體地址的資料無效。

對於 volatile 關鍵字,毫無疑問,我們是知道它是使用了 Lock 字首指令的。

那麼問題來了,JVM 的 CAS 操作使用了 Lock 字首指令嗎?

是的,使用了。

JVM 中的 CAS 操作使用的是處理器通過的 CMPXCHG 指令實現的。這也是一個 Lock 字首指令。

好,接下來我們看一個方法:

java.util.concurrent.locks.AbstractQueuedLongSynchronizer#compareAndSetState

這個方法位於 AQS 包裡面,就是一個 CAS 的操作。現在只需要關心我框起來的部分。

英文部分翻譯過來是:這個操作具有 volatile 讀和寫的記憶體語言。

而這個操作是什麼操作?

就是 344 行 unsafe 的 compareAndSwapLong 操作,這個方法是一個 native 方法。

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

為什麼這個操作具有 volatile 讀和寫的記憶體語言呢?

書裡面是這樣寫的:

這個本地方法的最終實現在 openjdk 的如下位置:
openjdk-7-fcs-src-b147- 27_jun_2011\openjdk\hotspot\src\os_cpu\windows_x86\vm\atomic_windows_x86.inline.hpp(對應於Windows作業系統,X86處理器)

intel 的手冊對 Lock 字首的說明如下。

  • 確保對記憶體的讀-改-寫操作原子執行。在 Pentium 及 Pentium 之前的處理器中,帶有 Lock 字首的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從Pentium 4、Intel Xeon及P6處理器開始,Intel使用快取鎖定(Cache Locking) 來保證指令執行的原子性。快取鎖定將大大降低lock字首指令的執行開銷。
  • 禁止該指令,與之前和之後的讀和寫指令重排序。
  • 把寫緩衝區中的所有資料重新整理到記憶體中。

上面的第2點和第3點所具有的記憶體屏障效果,足以同時實現 volatile 讀和volatile 寫的記憶體語義。

好,如果你說你對書上的內容存疑。那麼我帶大家再看看官方文件:

https://docs.oracle.com/javase/8/docs/api/

我框起來的部分:

compareAndSet 和所有其他的諸如 getAndIncrement 這種讀然後更新的操作擁有和 volatile 讀、寫一樣的記憶體語義。

原因就是用的到了 Lock 指令。

好,到這裡我們可以得出結論了:

compareAndSet 同時具有volatile讀和volatile寫的記憶體語義。

那麼問題就來了!

這個操作,在 AtomicLong 裡面也有呼叫:

而 AtomicLong 裡面的 value 又是被 volatile 修飾了的:

請問:為什麼 compareAndSwapLong 操作已經同時具有 volatile 讀和 volatile 寫的記憶體語義了,其操作的 value 還需要被 volatile 修飾呢?

這個問題也是一個朋友丟擲來探討的,探討的結果是,我們都不知道為什麼:

我猜測會不會是由於作業系統不同而不同。在 x86 上面執行是這樣,其他的作業系統就不一定了,但是沒有證據。

希望知道為什麼這樣做的朋友能指點一下。

好,那麼前面說到 CAS ,那麼一個經典的面試題就來了:

請問,CAS 實現原子操作有哪些問題呢?

  • ABA問題。
  • 迴圈時間開銷大。
  • 只能保證一個共享變數的原子操作。

如果上面這三點你不知道,或者你說不明白,那我建議你看完本文後一定去了解一下,屬於面試常問系列。

我主要說說這個迴圈時間開銷大的問題。自旋 CAS 如果長時間不成功,就會對 CPU 帶來比較大的執行開銷。

而回答這個問題的朋友,大多數舉例的時候都會說: “AtomicLong 就是基於自旋 CAS 做的,會帶來一定的效能問題。巴拉巴拉......”

而我作為面試官的時候只是微笑著看著你,讓你錯以為自己答的很完美。

我知道你為什麼這樣答,因為你看了幾篇部落格,刷了刷常見面試題,那裡面都是這樣寫的 :AtomicLong 就是基於自旋 CAS 做的。

但是,朋友,你可以這樣說,但是回答不完美。這題得分別從 JDK 7 和 JDK 8 去答:

JDK 7 的 AtomicLong 是基於自旋 CAS 做的,比如下面這個方法:

while(true) 就是自旋,自旋里面純粹依賴於 compareAndSet 方法:

這個方法裡面呼叫的 native 的 comareAndSwapLong 方法,對應的 Lock 字首指令就是我們前面說到的 cmpxchg。

而在 JDK 8 裡面 AtomicLong 裡面的一些方法也是自旋,但是就不僅僅依賴於 cmpxchg 指令做了,比如還是上面這個方法:

可以看到這裡面還是有一個 do-while 的迴圈,還是呼叫的 compareAndSwapLong 方法:

這個方法對應的 Lock 字首指令是我們前面提到過的 xadd 指令。

從 Java 程式碼的角度來看,都是自旋,都是 compareAndSwapLong 方法。沒有什麼差異。

但是從這篇 oracle 官網的文章,我們可以窺見 JDK 8 在 x86 平臺上對 compareAndSwapLong 方法做了一些操作,使用了 xadd 彙編指令代替 CAS 操作。

xadd 指令是 fetch and add。

cmpxchg 指令是 compare and swap。

xadd 指令的效能是優於 cmpxchg 指令的。

具體可以看看這篇 oracle 官網的文章:

https://blogs.oracle.com/dave/atomic-fetch-and-add-vs-compare-and-swap

文章下面的評論,可以多注意一下,我擷取其中兩個,大家品一品:

然後是這個:

總之就是:這篇文章說的有道理,我們(Dave and Doug)也在思考這個問題。所以我們會在 JIT 上面搞事情,在 x86 平臺上把 CAS 操作替換為 LOCK:XADD 指令。

(這個地方我之前理解的有問題,經過朋友的指正後才修改過來。)

所以,JDK 8 之後的 AtomicLong 裡面的方法都是經過改良後, xadd+cmpxchg 雙重加持的方法。

另外需要注意的是,我怕有的朋友懵逼,專門多提一嘴:CAS 是指一次比較並交換的過程,成功了就返回 true,失敗了則返回 false,強調的是一次。而自旋 CAS 是在死迴圈裡面進行比較並交換,只要不返回 true 就一直迴圈。

所以,不要一提到 CAS 就說迴圈時間開銷大。前面記得加上“自旋”和“競爭大”兩個條件。

至於 JDK 8 使用 xadd 彙編指令代替 CAS 操作的是否真的是效能更好了,可以看看這篇 oracle 官網的文章:

https://blogs.oracle.com/dave/atomic-fetch-and-add-vs-compare-and-swap

文章下面的評論,可以多注意一下,我擷取其中一個,大家品一品:

經過我們前面的分析,AtomicLong 從 JDK 7 到 JDK 8 是有一定程度上的效能優化的,但是改動並不大。

還是存在一個問題:雖然它可以實現原子性的增減操作,但是當競爭非常大的時候,被操作的這個 value 就是一個熱點資料,所有執行緒都要去對其進行爭搶,導致併發修改時衝突很大。

所以,歸根到底它的主要問題還是出在共享熱點資料上。

為了解決這個問題,Doug Lea 在 JDK 8 裡面引入了 LongAdder 類。

更加牛逼的LongAdder

大家先看一下官網上的介紹:

上面的截圖一共兩段話,是對 LongAdder 的簡介,我給大家翻譯並解讀一下。

首先第一段:當有多執行緒競爭的情況下,有個叫做變數集合(set of variables)的東西會動態的增加,以減少競爭。sum() 方法返回的是某個時刻的這些變數的總和。

所以,我們知道了它的返回值,不論是 sum() 方法還是 longValue() 方法,都是那個時刻的,不是一個準確的值。

意思就是你拿到這個值的那一刻,這個值其實已經變了。

這點是非常重要的,為什麼會是這樣呢?

我們對比一下 AtomicLong 和 LongAdder 的自增方法就可以知道了:

AtomicLong 的自增是有返回值的,就是一個這次呼叫之後的準確的值,這是一個原子性的操作。

LongAdder 的自增是沒有返回值的,你要獲取當前值的時候,只能呼叫 sum 方法。

你想這個操作:先自增,再獲取值,這就不是原子操作了。

所以,當多執行緒併發呼叫的時候,sum 方法返回的值必定不是一個準確的值。除非你加鎖。

該方法上的說明也是這樣的:

至於為什麼不能返回一個準確的值,這就是和它的設計相關了,這點放在後面去說。

然後第二段:當在多執行緒的情況下對一個共享資料進行更新(寫)操作,比如實現一些統計資訊類的需求,LongAdder 的表現比它的老大哥 AtomicLong 表現的更好。在併發不高的時候,兩個類都差不多。但是高併發時 LongAdder 的吞吐量明顯高一點,它也佔用更多的空間。這是一種空間換時間的思想。

這段話其實是接著第一段話在進行描述的。

因為它在多執行緒併發情況下,沒有一個準確的返回值,所以當你需要根據返回值去搞事情的時候,你就要仔細思考思考,這個返回值你是要精準的,還是大概的統計類的資料就行。

比如說,如果你是用來做序號生成器,所以你需要一個準確的返回值,那麼還是用 AtomicLong 更加合適

如果你是用來做計數器,這種寫多讀少的場景。比如介面訪問次數的統計類需求,不需要時時刻刻的返回一個準確的值,那就上 LongAdder 吧

總之,AtomicLong 是可以保證每次都有準確值,而 LongAdder 是可以保證最終資料是準確的。高併發的場景下 LongAdder 的寫效能比 AtomicLong 高。

接下來探討三個問題:

  • LongAdder 是怎麼解決多執行緒操作熱點 value 導致併發修改衝突很大這個問題的?
  • 為什麼高併發場景下 LongAdder 的 sum 方法不能返回一個準確的值?
  • 為什麼高併發場景下 LongAdder 的寫效能比 AtomicLong 高?

先帶大家看個圖片,看不懂沒有關係,先有個大概的印象:

接下來我們就去探索原始碼,原始碼之下無祕密。

從原始碼我們可以看到 add 方法是關鍵:

裡面有 cells 、base 這樣的變數,所以在解釋 add 方法之前,我們先看一下 這幾個成員變數。

這幾個變數是 Striped64 裡面的。

LongAdder 是 Striped64 的子類:

其中的四個變數如下:

  • NCPU:cpu 的個數,用來決定 cells 陣列的大小。
  • cells:一個陣列,當不為 null 的時候大小是 2 的次冪。裡面放的是 cell 物件。
  • base : 基數值,當沒有競爭的時候直接把值累加到 base 裡面。還有一個作用就是在 cells 初始化時,由於 cells 只能初始化一次,所以其他競爭初始化操作失敗執行緒會把值累加到 base 裡面。
  • cellsBusy:當 cells 在擴容或者初始化的時候的鎖標識。

之前,文件裡面說的 set of variables 就是這裡的 cells。

好了,我們再回到 add 方法裡面:

cells 沒有被初始化過,說明是第一次呼叫或者競爭不大,導致 CAS 操作每次都是成功的。

casBase 方法就是進行 CAS 操作。

當由於競爭激烈導致 casBase 方法返回了 false 後,進入 if 分支判斷。

這個 if 分子判斷有 4 個條件,做了 3 種情況的判斷

  • 標號為 ① 的地方是再次判斷 cells 陣列是否為 null 或者 size 為 0 。as 就是 cells 陣列。
  • 標號為 ② 的地方是判斷當前執行緒對 cells 陣列大小取模後的值,在 cells 陣列裡面是否能取到 cell 物件。
  • 標號為 ③ 的地方是對取到的 cell 物件進行 CAS 操作是否能成功。

這三個操作的含義為:當 cells 陣列裡面有東西,並且通過 getProbe() & m算出來的值,在 cells 陣列裡面能取到東西(cell)時,就再次對取到的 cell 物件進行 CAS 操作。

如果不滿足上面的條件,則進入 longAccumulate 函式。

這個方法主要是對 cells 陣列進行操作,你想一個陣列它可以有三個狀態:未初始化、初始化中、已初始化,所以下面就是對這三種狀態的分別處理:

  • 標號為 ① 的地方是 cells 已經初始化過了,那麼這個裡面可以進行在 cell 裡面累加的操作,或者擴容的操作。
  • 標號為 ② 的地方是 cells 沒有初始化,也還沒有被加鎖,那就對 cellsBusy 標識進行 CAS 操作,嘗試加鎖。加鎖成功了就可以在這裡面進行一些初始化的事情。
  • 標號為 ③ 的地方是 cells 正在進行初始化,這個時候就在 base 基數上進行 CAS 的累加操作。

上面三步是在一個死迴圈裡面的。

所以如果 cells 還沒有進行初始化,由於有鎖的標誌位,所以就算併發非常大的時候一定只有一個執行緒去做初始化 cells 的操作,然後對 cells 進行初始化或者擴容的時候,其他執行緒的值就在 base 上進行累加操作。

上面就是 sum 方法的工作過程。

感受到了嗎,其實這就是一個分段操作的思想,不知道你有沒有想到 ConcurrentHashMap,也不奇怪,畢竟這兩個東西都是 Doug Lea 寫的。

然後再補充說明一下,cells 的初始化大小為 2:

cells 的最大值為 CPU 核數:

cell 是被 Contended 註解修飾了,為了解決偽共享的問題:

說起偽共享,我想起了之前的《一個困擾我122天的技術問題,我好像知道答案了》這篇文章中提到的一個猜想:

後來,我也用這個註解去解決偽共享的問題了,可惜最終的實驗結果表明不是這個原因。

那篇文章釋出後有很多朋友給我反饋他們的看法,而更多的是在這條路上發現了更多更多的玄學問題,但是最終這些問題的背後都指向了同一個東西:JIT。

扯遠了,說回本文的這個 LongAdder。

總的來說,就是當沒有衝突的時候 LongAdder 表現的和 AtomicLong 一樣。當有衝突的時候,才是 LongAdder 表現的時候,然後我們再回去看這個圖,就能明白怎麼回事了:

好了,現在我們回到前面提出的三個問題:

  • LongAdder 是怎麼解決多執行緒操作熱點 value 導致併發修改衝突很大這個問題的?
  • 為什麼高併發場景下 LongAdder 的 sum 方法不能返回一個準確的值?
  • 為什麼高併發場景下 LongAdder 的寫效能比 AtomicLong 高?

它們其實是一個問題。

因為 LongAdder 把熱點 value 拆分了,放到了各個 cell 裡面去操作。這樣就相當於把衝突分散到了 cell 裡面。所以解決了併發修改衝突很大這個問題。

當發生衝突時 sum= base+cells。高併發的情況下當你獲取 sum 的時候,cells 極有可能正在被其他的執行緒改變。一個在高併發場景下實時變化的值,你要它怎麼給你個準確值?當然,你也可以通過加鎖操作拿到當前的一個準確值,但是這種場景你還用啥 LongAdder,是 AtomicLong 不香了嗎?

為什麼高併發場景下 LongAdder 的寫效能比 AtomicLong 高?

你發動你的小腦殼想一想,朋友。

AtomicLong 不管有沒有衝突,它寫的都是一個共享的 value,有衝突的時候它就在自旋。

LongAdder 沒有衝突的時候表現的和 AtomicLong 一樣,有衝突的時候就把衝突分散到各個 cell 裡面了,衝突分散了,寫的當然更快了。

一點思考

本文的題目是《我從LongAdder中窺探到了高併發的祕籍,上面就寫了兩個字......》。

那麼這兩個字是什麼呢?

就是拆分。我淺顯的覺得分散式、高併發都是基於拆分思想的。

本文的 LongAdder 就不說了。

微服務化、分庫分表、讀寫分離......這些東西都是在拆分,把集中的壓力分散開來。

我們常常說效能不行了,那就堆機器解決,這就是在做拆分。

當然,拆分了帶來好處的同時也是有一定的問題的。

比如老大難的分散式事務、資料聚合查詢等需求。

舉一個我遇到過的例子吧。

在寫這篇文章之前,我看了 LongAdder 原始碼,瞭解到它這樣的結構後,知道了它和 AtomicLong 之間的差異後,我想起了之前做過的一個需求。

就是賬戶服務,有個大商戶的賬戶是一個熱點賬戶,交易非常的頻繁。

這個賬戶上的金額就相當於是一個共享的熱點資料。

我們當時的做法是把這個賬戶拆分為多個影子賬戶,這樣就把熱點賬戶拆分成了多個影子賬戶,壓力就分攤了。

其實這個思想和 LongAdder 是一脈相承的。

這個場景下拆分帶來的問題是什麼呢?

其中一個問題就是這個賬戶的總餘額是多個影子賬戶之和,而每個影子賬戶上的餘額是時刻在變化的,所以我們不能保證餘額是一個實時準確的值。

但是商戶不關心這個呀。他只關心上日餘額是準確的,每日對賬都能對上就行了。

我們在滿足需求的同時,效能還上去了。

還有一個簡單的思考是如果我們把“實現原子操作進行加減”這句話當做一個需求。

我個人拙見是這樣的,AtomicLong 類就是實現了這個需求,交付出去後,它能用,能正常工作,而且還附送了一個功能是每次都給你返回一個準確的值。

而 LongAdder 就是更加優雅的實現了這個需求,它是在原有的基礎上進行了迭代開發,功能還是能一樣的實現,沒有附加功能,但是針對某些場景來說,更好用了。

它們傳遞給我的思想不是我們常說的:先上,能跑就行,後期再迭代。

而是:它確實能跑,但是還有更加快,更加優雅的實現方式,我們可以實現它。

這是我們需要學習的地方。

最後說兩句(求關注)

才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,由於本號沒有留言功能,還請你在後臺留言指出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

相關文章