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

這是why的第 53 篇原創文章java

荒腔走板

你們好,我是why。面試

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

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

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

第一次知道這個名詞是 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 四、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,一個被代碼耽誤的文學創做者,不是大佬,可是喜歡分享,是一個又暖又有料的四川好男人。

相關文章
相關標籤/搜索