Disruptor框架學習(2)--為啥這么快

Disruptor框架學習(2)--為啥這么快

上一篇中,筆者闡述了Disruptor的代碼實現和數據結構。在說到,Disruptor為什么性能那么高的時候,提及了幾個概念:CAS、緩存行、偽共享。本篇,就對此進行一個詳細的介紹。

1.1 CPU緩存

在現代計算機當中,CPU是大腦,最終都是由它來執行所有的運算。而內存(RAM)則是血液,存放著運行的數據;但是,由于CPU和內存之間的工作頻率不同,CPU如果直接去訪問內存的話,系統性能將會受到很大的影響,所以在CPU和內存之間加入了三級緩存,分別是L1、L2、L3。

當CPU執行運算時,它首先會去L1緩存中查找數據,找到則返回;如果L1中不存在,則去L2中查找,找到即返回;如果L2中不存在,則去L3中查找,查到即返回。如果三級緩存中都不存在,最終會去內存中查找。對于CPU來說,走得越遠,就越消耗時間,拖累性能。

在三級緩存中,越靠近CPU的緩存,速度越快,容量也越小,所以L1緩存是最快的,當然制作的成本也是最高的,其次是L2、L3。

CPU頻率,就是CPU運算時的工作的頻率(1秒內發生的同步脈沖數)的簡稱,單位是Hz。主頻由過去MHZ發展到了當前的GHZ(1GHZ=103MHZ=106KHZ= 10^9HZ)。

內存頻率和CPU頻率一樣,習慣上被用來表示內存的速度,內存頻率是以MHz(兆赫)為單位來計量的。目前較為主流的內存頻率1066MHz、1333MHz、1600MHz的DDR3內存,2133MHz、2400MHz、2666MHz、2800MHz、3000MHz、3200MHz的DDR4內存。

可以看得出,如果CPU直接訪問內存,是一件相當耗時的操作。

1.2 緩存行

當數據被加載到三級緩存中,它是以緩存行的形式存在的,不是一個單獨的項,也不是單獨的指針。

在CPU緩存中,數據是以緩存行(cache line)為單位進行存儲的,每個緩存行的大小一般為32--256個字節,常用CPU中緩存行的大小是64字節;CPU每次從內存中讀取數據的時候,會將相鄰的數據也一并讀取到緩存中,填充整個緩存行;

可想而知,當我們遍歷數組的時候,CPU遍歷第一個元素時,與之相鄰的元素也會被加載到了緩存中,對于后續的遍歷來說,CPU在緩存中找到了對應的數據,不需要再去內存中查找,效率得到了巨大的提升;

但是,在多線程環境中,也會出現偽共享的情況,造成程序性能的降低,堪稱無形的性能殺手;

1.2.1 緩存命中

通過具體的例子,來闡述緩存命中和未命中之間的效率:

測試代碼:

public class CacheHit {

    //二維數組:
    private static long[][] longs;

    //一維數組長度:
    private static int length = 1024*1024;

    public static void main(String [] args) throws InterruptedException {
        //創建二維數組,并賦值:
        longs = new long[length][];
        for(int x = 0 ;x < length;x++){
            longs[x] = new long[6];
            for(int y = 0 ;y<6;y++){
                longs[x][y] = 1L;
            }
        }
        cacheHit();
        cacheMiss();
    }
    //緩存命中:
    private static void cacheHit() {
        long sum = 0L;
        long start = System.nanoTime();
        for(int x=0; x < length; x++){
            for(int y=0;y<6;y++){
                sum += longs[x][y];
            }
        }
        System.out.println("命中耗時:"+(System.nanoTime() - start));
    }
    //緩存未命中:
    private static void cacheMiss() {
        long sum = 0L;
        long start = System.nanoTime();
        for(int x=0;x < 6;x++){
            for(int y=0;y < length;y++){
                sum += longs[y][x];
            }
        }
        System.out.println("未命中耗時:"+(System.nanoTime() - start));
    }
}

測試結果:

未命中耗時:43684518
命中耗時:19244507

在Java中,一個long類型是8字節,而一個緩存行是64字節,因此一個緩存行可以存放8個long類型。但是,在內存中的布局中,對象不僅包含了實例數據(long類型變量),還包含了對象頭。對象頭在32位系統上占用8字節,而64位系統上占用16字節。

所以,在上面的例子中,筆者向二維數組中填充了6個元素,占用了48字節。

在cacheHit()的例子中,當第一次遍歷的時候,獲取longs[0][0],而longs[0][0]--longs[0][5]也同時被加載到了緩存行中,接下來獲取longs[0][1],已存在緩存行中,直接從緩存中獲取數據,不用再去內存中查找,以此類推;

在cacheMiss()的例子中,當第一次遍歷的時候,也是獲取longs[0][0]的數據,longs[0][0]--longs[0][5]也被加載到了緩存行中,接下來獲取long[1][0],不存在緩存行中,去內存中查找,以此類推;

以上的例子可以充分說明緩存在命中和未命中的情況下,性能之間的差距。

1.2.2 偽共享

由于CPU加載機制,某個數據被加載的同時,其相鄰的數據也會被加載到CPU當中。在得到CPU免費加載的同時,也產生了不好的情況;俗話說得好,凡事都有利有弊。

在我們的java程序中,當多個線程修改兩個獨立變量的時候,如果這兩個變量存在于一個緩存行中,那么就有很大的概率產生偽共享。

這是為什么呢?

現如今,CPU都是多核處理器,一般為2核或者4核,當我們程序運行時,啟動了多個線程。例如:核心1啟動了1個線程,核心2啟動了1個線程,這2個線程分別要修改不同的變量,其中核心1的線程要修改x變量,而核心2的線程要修改y變量,但是x、y變量在內存中是相鄰的數據,他們被加載到了同一個緩存行當中,核心1的緩存行有x、y,核心2的緩存行也有x、y。

那么,只要有一個核心中的線程修改了變量,另一個核心的緩存行就會失效,導致數據需要被重新到內存中讀取,無意中影響了系統的性能,這就是偽共享。

cpu的偽共享問題本質是:幾個在內存中相鄰的數據,被CPU的不同核心加載在同一個緩存行當中,數據被修改后,由于數據存在同一個緩存行當中,進而導致緩存行失效,引起緩存命中降低。

代碼例子:

public class FalseShare implements Runnable{

    //線程數、數組大小:
    public static int NUM_THREADS = 4; // change
    
    //數組迭代的次數:
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    
    //線程需要處理的數組元素角標:
    private final int handleArrayIndex;
    
    //操作數組:
    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
    
    //對數組的元素進行賦值:
    static{
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
    }
    
    public FalseShare(final int handleArrayIndex) {
        this.handleArrayIndex = handleArrayIndex;
    }

    //啟動線程,每一個線程操作一個數組的元素,一一對應:
    public static void main(final String[] args) throws Exception {
        //程序睡眠必須加上:
        Thread.sleep(10000);
        
        final long start = System.nanoTime();
        
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseShare(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
        System.out.println(System.nanoTime() - start);
    }

    //對數組的元素進行操作:
    public void run() {
        long i = ITERATIONS;
        while (0 != --i) {
            longs[handleArrayIndex].value = i;
        }
    }

    //數組元素:
    public final static class VolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5; //代碼1
        public int p6;//代碼1
    }
}

測試結果:(納秒)

未注釋代碼1:19830512      18472356     19993249    19841462
注釋代碼1:  21141471      25611265     19939633    29976847

通過測試結果,可以看出,在注釋掉代碼后,性能明顯下降。讓我們來闡述下原因:

通過代碼,我們可以看出來,程序模擬的情況就是每一個線程操作數組中的一個元素,例如:線程1操作longs[0],線程2操作longs[1],線程3操作longs[2]...以此類推;之前說過,CPU緩存中是以緩存行為單位來進行存儲的,一個緩存行大小為64字節。在程序中VolatileLong對象,正好滿足64字節,為什么這么說?

在Java程序中,對象在內存中的分布:對象頭(Header),實例數據(Instance Data),對齊填充(Padding);

其中,對象頭在32位系統上占用8字節,64位系統上占用16字節;實例數據也就是我們平常是所用到的基本類型和引用類型;對齊填充是對象在內存區域內的補充,jvm要求對象在內存區域的大小必須是8的整數倍,所以當對象頭+實例數據的和不是8的整數倍時,就需要用到對齊填充,少多少就填充多少無效數據;

綜上所述,VolatileLong=對象頭(12字節)+value(8字節)+p1-p5(40字節)+p6(4字節) = 64字節,正好填充滿整個緩存行;

當我們沒有注釋掉代碼的時候,數組的各個元素將分布在不同的緩存行當中;而當注釋掉代碼的時候,數組的元素有很大的幾率分布在同一個緩存行當中;當不同線程操作元素的時候,就會產生沖突,產生偽共享,影響系統性能;

經過上面的敘述,你大概對偽共享有了一定的了解,但是你會不會有這樣的疑問?為什么其中1個核心緩存行的數據被修改了,其余核心中的緩存行就失效了?是什么機制產生了這樣的情況?

以下,我們就來簡單的介紹CPU的一致性協議MESI,就是這個協議保證了Cache的一致性;

1.2.3 MESI協議

多核理器中,每個核心都有自己的cache,內存中的數據可以同時處于不同的cache中,若各個核心獨立修改自己的cache,就會出現不一致問題。為了解決一致性問題,MESI協議被引入。

MESI(Modified Exclusive Shared Or Invalid)是一種廣泛使用的支持寫回策略的緩存一致性協議,該協議最早被應用在Intel奔騰系列的CPU中。

其實,MESI協議就是規定了緩存行的4種狀態,以及這4種狀態之間的流轉,以來保證不同核心中緩存的一致;每種狀態在緩存行中用2個bit位來進行描述,分別是修改態(M)、獨享態(E)、共享態(S)、無效態(I);

  • E(Exclusive):x變量只存在于core1中;
  • S(Shared):x變量存在于core1 core2 core3中
  • M(Modified):core1修改了x變量,core2 core3的緩存行被置為無效狀態

在CPU中,每個核心不但控制著自己緩存行的讀寫操作,而且還監聽這其他核心中緩存行的讀寫操作;每個緩存行的狀態受到本核心和其他核心的雙重影響;

下面,我們就闡述下這4中狀態的流轉:


(1)I--本地讀請求:CPU讀取變量x,如果其他核中的緩存有變量x,且緩存行的狀態為M,則將該核心的變量x更新到內存,本核心的再從內存中讀取取數據,加載到緩存行中,兩個核心的緩存行狀態都變成S;如果其他核心的緩存行狀態為S或者E,本核心從內存中杜取數據,之后所有核心中的包含變量x的緩存行狀態都變成S。

(2)I--本地讀請求:CPU讀取變量x,如果其他核中的緩存沒有變量x,則本核心從內存中讀取變量x,存入本核心的緩存行當中,該緩存行狀態變成E;

(3)I--本地寫請求:CPU讀取寫入變量x,如果其他核中沒有此變量,則從內存中讀取,在本核心中修改,此緩存行狀態變為M;如果其他緩存行中有變量x,并且狀態為M,則需要先將其他核心中的變量x寫回內存,本核心再從內存中讀取;如果其他緩存行中有變量x,并且狀態為E/S,則將其他核心中的緩存行狀態置為I,本核心在從內存中讀取變量x,之后將本核心的緩存行置為M;

注意,一個緩存除在Invalid狀態外都可以滿足CPU的讀請求,一個invalid的緩存行必須從主存中讀取(變成S或者 E狀態)來滿足該CPU的讀請求。

(4)S--遠程寫請求:多個核心共享變量X,其他核心將變量x修改,本核心中的緩存行不能使用,狀態變為I;

(5)S--本地讀請求:多個核心共享變量X,本核心讀取本緩存中的變量x,狀態不變;

(6)S--遠程讀請求:多個核心共享變量X,其他核心要讀取變量X,從主內存中讀取變量x,狀態置為S,本核心狀態S不變;

(7)S--本地寫請求:多個核心共享變量X,本核心修改本緩存行中的變量x,必須先將其他核心中所擁有變量x的緩存行狀態變成I,本核心緩存行狀態置為M;該操作通常使用RequestFor Ownership (RFO)廣播的方式來完成;

(8)E--遠程讀請求:只有本核心擁有變量x,其他核心也要讀取變量x,從內存中讀取變量x,并將所有擁有變量x的緩存行置為S狀態;

(9)E--本地讀請求:只有本核心擁有變量x,本核心需要讀取變量x,讀取本地緩存行中的變量x即可,狀態不變依舊為E;

(10)E--遠程寫請求:只有本核心擁有變量x,其他核心需要修改變量x,其他核心從內存中讀取變量x,進行修改,狀態變成M,而本核心中緩存行變為狀態I;

(11)E--本地寫請求:只有本核心擁有變量x,本核心修改本緩存行中的變量x,狀態置為M;

(12)M--本地寫請求:只有本核心中擁有變量x,本核心進行修改x操作,緩存行狀態不變;

(13)M--本地讀請求:只有本核心中擁有變量x,本核心進行讀取x操作,緩存行狀態不變;

(14)M--遠程讀請求:只有本核心中擁有變量x,其他核心需要讀取變量x,先將本核心中的變量x寫回到內存中,在將本緩存行狀態置為S,其他核心擁有變量x的緩存行狀態也變為S;

(15)M--遠程寫請求:只有本核心中擁有變量x,其他和核心需要修改變量x,先將本核心中的變量x寫回內存,再將本核心中緩存行置為I。其他核心的在從緩存行中讀取變量x,修改后置為M;

以上就是MESI協議的狀態流轉;如果對狀態流轉還有疑問的話,還可以結合以下圖例進行學習:

1.3 CAS

前2節,我們已經講了緩存行、偽共享的知識,本節來闡述Disruptor中另一個知識點---CAS;那么,CAS是什么呢?

在Java中,多線程之間如何保證數據的一致性?想必大部分都會異口同聲地說出鎖---synchronized鎖。在JDK1.5之前,的確是使用synchronized鎖來保證數據的一致性。但是,synchronized鎖是一種比較重的鎖,俗稱悲觀鎖。在較多線程的競爭下,加鎖、釋放鎖會對系統性能產生很大的影響,而且一個線程持有鎖,會導致其他線程的掛起,直至鎖的釋放。

那么,有沒有比較輕的鎖呢,答案是有的!與之相對應的是樂觀鎖!樂觀鎖雖然名稱中帶有鎖,但實際在代碼中是不加鎖的,樂觀鎖大多實現體現在數據庫sql層面,通常是的做法是:為數據增加一個版本標識,在表中增加一個 “version” 字段來實現。讀取出數據時,將此版本號一同讀出,之后更新時,對此版本號加一。此時,將提交數據的版本數據與數據庫表對應記錄的當前版本信息進行比對,如果提交的數據版本號大于數據庫表當前版本號,則予以更新,否則認為是過期數據。

update XXX_TABLE SET MONEY = 100 AND VERSION = 11 WHERE ID = 1 AND VERSION = 10;

這就是樂觀鎖!

上面說到了數據庫層面的樂觀鎖,那么代碼層面有沒有類似的實現?答案是,有的!那就是我們本小節的主角--CAS;

CAS是一個CPU級別的指令,翻譯為Compare And Swap比較并交換;

CAS是對內存中共享數據操作的一種指令,該指令就是用樂觀鎖實現的方式,對共享數據做原子的讀寫操作。原子本意是“不能被進一步分割的最小粒子”,而原子操作意為”不可被中斷的一個或一系列操作”。原子變量能夠保證原子性的操作,意思是某個任務在執行過程中,要么全部成功,要么全部失敗回滾,恢復到執行之前的初態,不存在初態和成功之間的中間狀態。

CAS有3個操作數,內存中的值V,預期內存中的值A,要修改成的值B。當內存值V和預期值相同時,就將內存值V修改為B,否則什么都不做。

例如:

public class CasTest implements Runnable{

    private int memoryValue = 1;

    private int expectValue;

    private int updateValue;

    public CasTest(int expectValue,int updateValue){
        this.expectValue = expectValue;
        this.updateValue = updateValue;
    }

    public void run() {
        if(memoryValue==expectValue){
            this.memoryValue = updateValue;
            System.out.println("修改成功");
        }else {
            System.out.println("修改失敗");
        }
    }

    public static void main(String[] agrs) throws InterruptedException {
        CasTest casTest1 = new CasTest(1,2);
        Thread t1 = new Thread(casTest1);
        t1.start();
        
        Thread t2= new Thread(casTest1);
        t2.start();

        t1.join();
        t2.join();
    }
}

在Java中,主要使用了Unsafe類來實現CAS操作,利用JNI來完成CPU指令的調用。JNI:java native interface為java本地調用,也就是說允許java調用其他計算機語言(例如:C、C++等);

在java.util.concurrent.atomic包下(AtomicInteger為例):

public class AtomicInteger extends Number implements java.io.Serializable {

    private static final long serialVersionUID = 6214790243416807050L;

    private static final Unsafe unsafe = Unsafe.getUnsafe();

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
}

實際最終調用了sun.misc.Unsafe類:

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

可以看到Unsafe的compareAndSwapInt方法,使用了native修飾符,是一個本地方法調用,最終由C++代碼來操作CPU。至于具體實現,有興趣的朋友可以去參考openJDK中Unsafe類;

與synchronized鎖相比較而言,CAS最大的優勢就是非阻塞,在代碼層面,多線程情況下不阻塞其他線程的執行,從而達到既保證數據的安全,又提高了系統的性能。

1.4 Disruptor中的運用

上面,說了分別說了CAS、緩存行、偽共享。接下來,就來看看再Disruptor中是如何使用的!

在多生產者的環境下,更新下一個可用的序列號地方,我們使用CAS(Compare And Swap)操作。

Disruptor中多生產者情況下,獲取下一個可用序列號的實現:

public final class MultiProducerSequencer extends AbstractSequencer{
    @Override
    public long next(int n){
        if (n < 1){
            throw new IllegalArgumentException("n must be > 0");
        }
        long current;
        long next;
        do{
            current = cursor.get();
            next = current + n;
            long wrapPoint = next - bufferSize;
            long cachedGatingSequence = gatingSequenceCache.get();
            if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current){
                long gatingSequence = Util.getMinimumSequence(gatingSequences, current);

                if (wrapPoint > gatingSequence){
                    waitStrategy.signalAllWhenBlocking();
                    LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
                    continue;
                }
                gatingSequenceCache.set(gatingSequence);

            //對current,next進行compareAndSet,cursor就是序列號對象:
            } else if (cursor.compareAndSet(current, next)){
                break;
            }
        }while (true);
        return next;
    }
}

Disruptor通過緩存行填充的方式來解決偽共享:

class LhsPadding{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding{
    protected volatile long value;
}

class RhsPadding extends Value{
    protected long p9, p10, p11, p12, p13, p14, p15;
}

public class Sequence extends RhsPadding{}

Sequence是Disruptor中序列號對象,value是對象具體的序列值,通過上面的方式,value不會與其他需要操作的變量存在同一個緩存行中;

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,996評論 2 374

推薦閱讀更多精彩內容