(轉)比AtomicLong還高效的LongAdder源碼解析

接觸到AtomicLong的原因是在看guava的LoadingCache相關代碼時,關於LoadingCache,其實思路也非常簡單清晰:用模板模式解決了緩存不命中時獲取數據的邏輯,這個思路我早前也正好在項目中使用到。 言歸正傳,爲什麼說LongAdder引起了我的注意,原因有二:

  1. 作者是Doug lea ,地位實在舉足輕重。
  2. 他說這個比AtomicLong高效。

我們知道,AtomicLong已經是非常好的解決方案了,涉及併發的地方都是使用CAS操作,在硬件層次上去做 compare and set操作。效率非常高。 因此,我決定研究下,爲什麼LongAdder比AtomicLong高效。 首先,看LongAdder的繼承樹:

繼承自Striped64,這個類包裝了一些很重要的內部類和操作。稍候會看到。 正式開始前,強調下,我們知道,AtomicLong的實現方式是內部有個value 變量,當多線程併發自增,自減時,均通過cas 指令從機器指令級別操作保證併發的原子性。 再看看LongAdder的方法:

怪不得可以和AtomicLong作比較,連API都這麼像。我們隨便挑一個API入手分析,這個API通了,其他API都大同小異,因此,我選擇了add這個方法。事實上,其他API也都依賴這個方法。

LongAdder中包含了一個Cell 數組,Cell是Striped64的一個內部類,顧名思義,Cell 代表了一個最小單元,這個單元有什麼用,稍候會說道。先看定義:

Cell內部有一個非常重要的value變量,並且提供了一個cas更新其值的方法。 回到add方法:

這裏,我有個疑問,AtomicLong已經使用CAS指令,非常高效了(比起各種鎖),LongAdder如果還是用CAS指令更新值,怎麼可能比AtomicLong高效了? 何況內部還這麼多判斷!!! 這是我開始時最大的疑問,所以,我猜想,難道有比CAS指令更高效的方式出現了? 帶着這個疑問,繼續。 第一if 判斷,第一次調用的時候cells數組肯定爲null,因此,進入casBase方法:

原子更新base沒啥好說的,如果更新成功,本地調用開始返回,否則進入分支內部。 什麼時候會更新失敗? 沒錯,併發的時候,好戲開始了,AtomicLong的處理方式是死循環嘗試更新,直到成功才返回,而LongAdder則是進入這個分支。 分支內部,通過一個Threadlocal變量threadHashCode 獲取一個HashCode對象,該HashCode對象依然是Striped64類的內部類,看定義:

有個code變量,保存了一個非0的隨機數隨機值。 回到add方法:

拿到該線程相關的HashCode對象後,獲取它的code變量,as[(n-1)h] 這句話相當於對h取模,只不過比起取摸,因爲是 與 的運算所以效率更高。 計算出一個在Cells 數組中當先線程的HashCode對應的 索引位置,並將該位置的Cell 對象拿出來更新cas 更新它的value值。 當然,如果as 爲null 並且更新失敗,纔會進入retryUpdate方法。 看到這裏我想應該有很多人明白爲什麼LongAdder會比AtomicLong更高效了,沒錯,唯一會制約AtomicLong高效的原因是高併發,高併發意味着CAS的失敗機率更高, 重試次數更多,越多線程重試,CAS失敗機率又越高,變成惡性循環,AtomicLong效率降低。 那怎麼解決? LongAdder給了我們一個非常容易想到的解決方案: 減少併發,將單一value的更新壓力分擔到多個value中去,降低單個value的 “熱度”,分段更新!!! 這樣,線程數再多也會分擔到多個value上去更新,只需要增加value就可以降低 value的 “熱度” AtomicLong中的 惡性循環不就解決了嗎? cells 就是這個 “段” cell中的value 就是存放更新值的, 這樣,當我需要總數時,把cells 中的value都累加一下不就可以了麼!! 當然,聰明之處遠遠不僅僅這裏,在看看add方法中的代碼,casBase方法可不可以不要,直接分段更新,上來就計算 索引位置,然後更新value? 答案是不好,不是不行,因爲,casBase操作等價於AtomicLong中的cas操作,要知道,LongAdder這樣的處理方式是有壞處的,分段操作必然帶來空間上的浪費,可以空間換時間,但是,能不換就不換,看空間時間都節約~! 所以,casBase操作保證了在低併發時,不會立即進入分支做分段更新操作,因爲低併發時,casBase操作基本都會成功,只有併發高到一定程度了,纔會進入分支,所以,Doug Lead對該類的說明是: 低併發時LongAdder和AtomicLong性能差不多,高併發時LongAdder更高效!

但是,Doung Lea 還是沒這麼簡單,聰明之處還沒有結束…… 如此,retryUpdate中做了什麼事,也基本略知一二了,因爲cell中的value都更新失敗(說明該索引到這個cell的線程也很多,併發也很高時) 或者cells數組爲空時纔會調用retryUpdate, 因此,retryUpdate裏面應該會做兩件事:

  1. 擴容,將cells數組擴大,降低每個cell的併發量,同樣,這也意味着cells數組的rehash動作。
  2. 給空的cells變量賦一個新的Cell數組。

是不是這樣呢? 繼續看代碼: 代碼比較長,變成文本看看,爲了方便大家看if else 分支,對應的{ }我用相同的顏色標註出來 可以看到,這個時候Doug Lea才願意使用死循環保證更新成功~!

final void retryUpdate(long x, HashCode hc, boolean wasUncontended) {
        int h = hc.code;
        boolean collide = false;                // True if last slot nonempty
        for (;;) {
            Cell[] as; Cell a; int n; long v;
            if ((as = cells) != null && (n = as.length) > 0) {// 分支1
                if ((a = as[(n - 1) & h]) == null) {
                    if (busy == 0) {            // Try to attach new Cell
                        Cell r = new Cell(x);   // Optimistically create
                        if (busy == 0 && casBusy()) {
                            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 {
                                busy = 0;
                            }
                            if (created)
                                break;
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
                }
                else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;      // Continue after rehash
                else if (a.cas(v = a.value, fn(v, x)))
                    break;
                else if (n >= NCPU || cells != as)
                    collide = false;            // At max size or stale
                else if (!collide)
                    collide = true;
                else if (busy == 0 && casBusy()) {
                    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 {
                        busy = 0;
                    }
                    collide = false;
                    continue;                   // Retry with expanded table
                }
                h ^= h << 13;                   // Rehash                 h ^= h >>> 17;
                h ^= h << 5;
            }
            else if (busy == 0 && cells == as && casBusy()) {//分支2
                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 {
                    busy = 0;
                }
                if (init)
                    break;
            }
            else if (casBase(v = base, fn(v, x)))
                break;                          // Fall back on using base
        }
        hc.code = h;                            // Record index for next time
    }

分支2中,爲cells爲空的情況,需要new 一個Cell數組。 分支1分支中,略複雜一點點: 注意,幾個分支中都提到了busy這個方法,這個可以理解爲一個cas實現的鎖,只有在需要更新cells數組的時候纔會更新該值爲1,如果更新失敗,則說明當前有線程在更新cells數組,當前線程需要等待。重試。 回到分支1中,這裏首先判斷當前cells數組中的索引位置的cell元素是否爲空,如果爲空,則添加一個cell到數組中。 否則更新 標示衝突的標誌位wasUncontended 爲 true ,重試。 否則,再次更新cell中的value,如果失敗,重試。 。。。。。。。一系列的判斷後,如果還是失敗,下下下策,reHash,直接將cells數組擴容一倍,並更新當前線程的hash值,保證下次更新能儘可能成功。 可以看到,LongAdder確實用了很多心思減少併發量,並且,每一步都是在”沒有更好的辦法“的時候纔會選擇更大開銷的操作,從而儘可能的用最最簡單的辦法去完成操作。追求簡單,但是絕對不粗暴。


昨天和左耳朵耗子簡單討論了下,發現左耳朵耗子對讀者思維的引導還是非常不錯的,在第一次發現這個類後,對裏面的實現又提出了更多的問題,引導大家思考,值得學習。 我們發現的問題有這麼幾個: 1.jdk1.7中是不是有這個類? 我確認後,結果如下:jdk-7u51 版本上還沒有 但是jdk-8u20版本上已經有了。代碼基本一樣 ,增加了對double類型的支持和刪除了一些冗餘的代碼。有興趣的同學可以去下載下JDK 1.8看看 2.base有沒有參與彙總? base在調用intValue等方法的時候是會彙總的:

3.base的順序可不可以調換? 左耳朵耗子,提出了這麼一個問題: 在add方法中,如果cells不會爲空後,casBase方法一直都沒有用了? 因此,我想可不可以調換add方法中的判斷順序,比如,先做casBase的判斷,結果是 不調換可能更好,調換後每次都要CAS一下,在高併發時,失敗機率非常高,並且是惡性循環,比起一次判斷,後者的開銷明顯小很多,還沒有副作用。因此,不調換可能會更好。 4.AtomicLong可不可以廢掉? 我的想法是可以廢掉了,因爲,雖然LongAdder在空間上佔用略大,但是,它的性能已經足以說明一切了,無論是從節約空的角度還是執行效率上,AtomicLong基本沒有優勢了,具體看這個測試(感謝coolshell讀者Lemon的回覆):http://blog.palominolabs.com/2014/02/10/java-8-performance-improvements-longadder-vs-atomiclong/

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