Java專家系列:CPU Cache與高性能編程

認識CPU Cache

CPU Cache概述

隨着CPU的頻率不斷提高,而內存的訪問速度卻沒有質的突破,爲了彌補訪問內存的速度慢,充分發揮CPU的計算資源,提升CPU總體吞吐量,在CPU與內存之間引入了一級Cache。隨着熱點數據體積愈來愈大,一級Cache L1已經不知足發展的要求,引入了二級Cache L2,三級Cache L3。(注:若無特別說明,本文的Cache指CPU Cache,高速緩存)CPU Cache在存儲器層次結構中的示意以下圖:html

計算機早已進入多核時代,軟件也愈來愈多的支持多核運行。一個處理器對應一個物理插槽,多處理器間經過QPI總線相連。一個處理器包含多個核,一個處理器間的多核共享L3 Cache。一個核包含寄存器、L1 Cache、L2 Cache,下圖是Intel Sandy Bridge CPU架構,一個典型的NUMA多處理器結構:java

做爲程序員,須要理解計算機存儲器層次結構,它對應用程序的性能有巨大的影響。若是須要的程序是在CPU寄存器中的,指令執行時1個週期內就能訪問到他們。若是在CPU Cache中,須要1~30個週期;若是在主存中,須要50~200個週期;在磁盤上,大概須要幾千萬個週期。充分利用它的結構和機制,能夠有效的提升程序的性能。git

以咱們常見的X86芯片爲例,Cache的結構下圖所示:整個Cache被分爲S個組,每一個組是又由E行個最小的存儲單元——Cache Line所組成,而一個Cache Line中有B(B=64)個字節用來存儲數據,即每一個Cache Line能存儲64個字節的數據,每一個Cache Line又額外包含一個有效位(valid bit)、t個標記位(tag bit),其中valid bit用來表示該緩存行是否有效;tag bit用來協助尋址,惟一標識存儲在CacheLine中的塊;而Cache Line裏的64個字節實際上是對應內存地址中的數據拷貝。根據Cache的結構題,咱們能夠推算出每一級Cache的大小爲B×E×S。程序員

那麼如何查看本身電腦CPU的Cache信息呢?github

在windows下查看方式有多種方式,其中最直觀的是,經過安裝CPU-Z軟件,直接顯示Cache信息,以下圖:shell

此外,Windows下還有兩種方法:編程

①Windows API調用GetLogicalProcessorInfo。 
②經過命令行系統內部工具CoreInfo。windows

若是是Linux系統, 可使用下面的命令查看Cache信息:後端

ls /sys/devices/system/cpu/cpu0/cache/index0


還有lscpu等命令也能夠查看相關信息,若是是Mac系統,能夠用sysctl machdep.cpu 命令查看cpu信息。數組

若是咱們用Java編程,還能夠經過CacheSize API方式來獲取Cache信息, CacheSize是一個谷歌的小項目,java語言經過它能夠進行訪問本機Cache的信息。示例代碼以下:

public static void main(String[] args) throws CacheNotFoundException {
        CacheInfo info = CacheInfo.getInstance(); 
        CacheLevelInfo l1Datainf = info.getCacheInformation(CacheLevel.L1, CacheType.DATA_CACHE);
        System.out.println("第一級數據緩存信息:"+l1Datainf.toString());

        CacheLevelInfo l1Instrinf = info.getCacheInformation(CacheLevel.L1, CacheType.INSTRUCTION_CACHE);
        System.out.println("第一級指令緩存信息:"+l1Instrinf.toString());
    }

打印輸出結果以下:

第一級數據緩存信息:CacheLevelInfo [cacheLevel=L1, cacheType=DATA_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

第一級指令緩存信息:CacheLevelInfo [cacheLevel=L1, cacheType=INSTRUCTION_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

還能夠查詢L二、L3級緩存的信息,這裏不作示例。從打印的信息和CPU-Z顯示的信息能夠看出,本機的Cache信息是一致的,L1數據/指令緩存大小都爲:C=B×E×S=64×8×64=32768字節=32KB。

Cache Line僞共享及解決方案

Cache Line僞共享分析

說僞共享前,先看看Cache Line 在java編程中使用的場景。若是CPU訪問的內存數據不在Cache中(一級、二級、三級),這就產生了Cache Line miss問題,此時CPU不得不發出新的加載指令,從內存中獲取數據。經過前面對Cache存儲層次的理解,咱們知道一旦CPU要從內存中訪問數據就會產生一個較大的時延,程序性能顯著下降,所謂遠水救不了近火。爲此咱們不得不提升Cache命中率,也就是充分發揮局部性原理。

局部性包括時間局部性、空間局部性。時間局部性:對於同一數據可能被屢次使用,自第一次加載到Cache Line後,後面的訪問就能夠屢次從Cache Line中命中,從而提升讀取速度(而不是從下層緩存讀取)。空間局部性:一個Cache Line有64字節塊,咱們能夠充分利用一次加載64字節的空間,把程序後續會訪問的數據,一次性所有加載進來,從而提升Cache Line命中率(而不是從新去尋址讀取)。

看個例子:內存地址是連續的數組(利用空間局部性),能一次被L1緩存加載完成。

以下代碼,長度爲16的row和column數組,在Cache Line 64字節數據塊上內存地址是連續的,能被一次加載到Cache Line中,因此在訪問數組時,Cache Line命中率高,性能發揮到極致。

public int run(int[] row, int[] column) {
    int sum = 0;
    for(int i = 0; i < 16; i++ ) {
        sum += row[i] * column[i];
    }
    return sum;
}

而上面例子中變量i則體現了時間局部性,i做爲計數器被頻繁操做,一直存放在寄存器中,每次從寄存器訪問,而不是從主存甚至磁盤訪問。雖然連續緊湊的內存分配帶來高性能,但並不表明它一直都能帶來高性能。若是把它放在多線程中將會發生什麼呢?如圖:

數據X、Y、Z被加載到同一Cache Line中,線程A在Core1修改X,線程B在Core2上修改Y。根據MESI大法,假設是Core1是第一個發起操做的CPU核,Core1上的L1 Cache Line由S(共享)狀態變成M(修改,髒數據)狀態,而後告知其餘的CPU核,圖例則是Core2,引用同一地址的Cache Line已經無效了;當Core2發起寫操做時,首先致使Core1將X寫回主存,Cache Line狀態由M變爲I(無效),然後纔是Core2從主存從新讀取該地址內容,Cache Line狀態由I變成E(獨佔),最後進行修改Y操做, Cache Line從E變成M。可見多個線程操做在同一Cache Line上的不一樣數據,相互競爭同一Cache Line,致使線程彼此牽制影響,變成了串行程序,下降了併發性。此時咱們則須要將共享在多線程間的數據進行隔離,使他們不在同一個Cache Line上,從而提高多線程的性能。

Cache Line僞共享處理方案

處理僞共享的兩種方式:

  1. 增大數組元素的間隔使得不一樣線程存取的元素位於不一樣的cache line上。典型的空間換時間。(Linux cache機制與之相關)
  2. 在每一個線程中建立全局數組各個元素的本地拷貝,而後結束後再寫回全局數組。

在Java類中,最優化的設計是考慮清楚哪些變量是不變的,哪些是常常變化的,哪些變化是徹底相互獨立的,哪些屬性一塊兒變化。舉個例子:

public class Data{
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
    int value;
}

假如業務場景中,上述的類知足如下幾個特色:

  1. 當value變量改變時,modifyTime確定會改變
  2. createTime變量和key變量在建立後,就不會再變化。
  3. flag也常常會變化,不過與modifyTime和value變量毫無關聯。

當上面的對象須要由多個線程同時的訪問時,從Cache角度來講,就會有一些有趣的問題。當咱們沒有加任何措施時,Data對象全部的變量極有可能被加載在L1緩存的一行Cache Line中。在高併發訪問下,會出現這種問題:

如上圖所示,每次value變動時,根據MESI協議,對象其餘CPU上相關的Cache Line所有被設置爲失效。其餘的處理器想要訪問未變化的數據(key 和 createTime)時,必須從內存中從新拉取數據,增大了數據訪問的開銷。

Padding 方式

正確的方式應該將該對象屬性分組,將一塊兒變化的放在一組,與其餘屬性無關的屬性放到一組,將不變的屬性放到一組。這樣當每次對象變化時,不會帶動全部的屬性從新加載緩存,提高了讀取效率。在JDK1.8之前,咱們通常是在屬性間增長長整型變量來分隔每一組屬性。被操做的每一組屬性佔的字節數加上先後填充屬性所佔的字節數,不小於一個cache line的字節數就能夠達到要求:

public class DataPadding{
    long a1,a2,a3,a4,a5,a6,a7,a8;//防止與前一個對象產生僞共享
    int value;
    long modifyTime;
    long b1,b2,b3,b4,b5,b6,b7,b8;//防止不相關變量僞共享;
    boolean flag;
    long c1,c2,c3,c4,c5,c6,c7,c8;//
    long createTime;
    char key;
    long d1,d2,d3,d4,d5,d6,d7,d8;//防止與下一個對象產生僞共享
}

經過填充變量,使不相關的變量分開

Contended註解方式

在JDK1.8中,新增了一種註解@sun.misc.Contended,來使各個變量在Cache line中分隔開。注意,jvm須要添加參數-XX:-RestrictContended才能開啓此功能 
用時,能夠在類前或屬性前加上此註釋:

// 類前加上表明整個類的每一個變量都會在單獨的cache line中
@sun.misc.Contended
@SuppressWarnings("restriction")
public class ContendedData {
    int value;
    long modifyTime;
    boolean flag;
    long createTime;
    char key;
}
或者這種:
// 屬性前加上時須要加上組標籤
@SuppressWarnings("restriction")
public class ContendedGroupData {
    @sun.misc.Contended("group1")
    int value;
    @sun.misc.Contended("group1")
    long modifyTime;
    @sun.misc.Contended("group2")
    boolean flag;
    @sun.misc.Contended("group3")
    long createTime;
    @sun.misc.Contended("group3")
    char key;
}

採起上述措施圖示:

JDK1.8 ConcurrentHashMap的處理

java.util.concurrent.ConcurrentHashMap在這個如雷貫耳的Map中,有一個很基本的操做問題,在併發條件下進行++操做。由於++這個操做並非原子的,並且在連續的Atomic中,很容易產生僞共享(false sharing)。因此在其內部有專門的數據結構來保存long型的數據:

(openjdk\jdk\src\share\classes\java\util\concurrent\ConcurrentHashMap.java line:2506):

    /* ---------------- Counter support -------------- */

    /**
     * A padded cell for distributing counts.  Adapted from LongAdder
     * and Striped64.  See their internal docs for explanation.
     */
    @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

咱們看到該類中,是經過@sun.misc.Contended達到防止false sharing的目的

JDK1.8 Thread 的處理

java.lang.Thread在java中,生成隨機數是和線程有着關聯。並且在不少狀況下,多線程下產生隨機數的操做是很常見的,JDK爲了確保產生隨機數的操做不會產生false sharing ,把產生隨機數的三個相關值設爲獨佔cache line。

(openjdk\jdk\src\share\classes\java\lang\Thread.java line:2023)

    // The following three initially uninitialized fields are exclusively
    // managed by class java.util.concurrent.ThreadLocalRandom. These
    // fields are used to build the high-performance PRNGs in the
    // concurrent code, and we can not risk accidental false sharing.
    // Hence, the fields are isolated with @Contended.

    /** The current seed for a ThreadLocalRandom */
    @sun.misc.Contended("tlr")
    long threadLocalRandomSeed;

    /** Probe hash value; nonzero if threadLocalRandomSeed initialized */
    @sun.misc.Contended("tlr")
    int threadLocalRandomProbe;

    /** Secondary seed isolated from public ThreadLocalRandom sequence */
    @sun.misc.Contended("tlr")
    int threadLocalRandomSecondarySeed;

Java中對Cache line經典設計

Disruptor框架

認識Disruptor

LMAX是在英國註冊並受到FCA監管的外匯黃金交易所。也是歐洲第一家也是惟一一家採用多邊交易設施Multilateral Trading Facility(MTF)擁有交易所牌照和經紀商牌照的歐洲頂級金融公司。LMAX的零售金融交易平臺,是創建在JVM平臺上,核心是一個業務邏輯處理器,它可以在一個線程裏每秒處理6百萬訂單。業務邏輯處理器的核心就是Disruptor(注,本文Disruptor基於當前最新3.3.6版本),這是一個Java實現的併發組件,可以在無鎖的狀況下實現網絡的Queue併發操做,它確保任何數據只由一個線程擁有以進行寫訪問,從而消除寫爭用的設計, 這種設計被稱做「破壞者」,也是這樣命名這個框架的。

Disruptor是一個線程內通訊框架,用於線程裏共享數據。與LinkedBlockingQueue相似,提供了一個高速的生產者消費者模型,普遍用於批量IO讀寫,在硬盤讀寫相關的程序中應用的十分普遍,Apache旗下的HBase、Hive、Storm等框架都有在使用Disruptor。LMAX 建立Disruptor做爲可靠消息架構的一部分,並將它設計成一種在不一樣組件中共享數據很是快的方法。Disruptor運行大體流程入下圖:

圖中左側(Input Disruptor部分)能夠看做多生產者單消費者模式。外部多個線程做爲多生產者併發請求業務邏輯處理器(Business Logic Processor),這些請求的信息通過Receiver存放在粉紅色的圓環中,業務處理器則做爲消費者從圓環中取得數據進行處理。右側(Output Disruptor部分)則可看做單生產者多消費者模式。業務邏輯處理器做爲單生產者,發佈數據到粉紅色圓環中,Publisher做爲多個消費者接受業務邏輯處理器的結果。這裏兩處地方的數據共享都是經過那個粉紅色的圓環,它就是Disruptor的核心設計RingBuffer。

Disruptor特色

  1. 無鎖機制。
  2. 沒有CAS操做,避免了內存屏障指令的耗時。
  3. 避開了Cache line僞共享的問題,也是Disruptor部分主要關注的主題。

Disruptor對僞共享的處理

RingBuffer類

RingBuffer類(即上節中粉紅色的圓環)的類關係圖以下:

經過源碼分析,RingBuffer的父類,RingBufferFields採用數組來實現存放線程間的共享數據。下圖,第57行,entries數組。

前面分析過數組比鏈表、樹更具備緩存友好性,此處不作細表。不使用LinkedBlockingQueue隊列,是基於無鎖機制的考慮。詳細分析可參考,併發編程網的翻譯。這裏咱們主要分析RingBuffer的繼承關係中的填充,解決緩存僞共享問題。以下圖: 

依據JVM對象繼承關係中父類屬性與子類屬性,內存地址連續排列布局,RingBufferPad的protected long p1,p2,p3,p4,p5,p6,p7;做爲緩存前置填充,RingBuffer中的protected long p1,p2,p3,p4,p5,p6,p7;做爲緩存後置填充。這樣任意線程訪問RingBuffer時,RingBuffer放在父類RingBufferFields的屬性,都是獨佔一行Cache line不會產生僞共享問題。如圖,RingBuffer的操做字段在RingBufferFields中,使用rbf標識:


按照一行緩存64字節計算,先後填充56字節(7個long),中間大於等於8字節的內容都能獨佔一行Cache line,此處rbf是大於8字節的。

Sequence類

Sequence類用來跟蹤RingBuffer和事件處理器的增加步數,支持多個併發操做包括CAS指令和寫指令。同時使用了Padding方式來實現,以下爲其類結構圖及Padding的類。

Sequence裏在volatile long value先後放置了7個long padding,來解決僞共享的問題。示意如圖,此處Value等於8字節:

也許讀者應該會認爲這裏的圖示比上面RingBuffer的圖示更好理解,這裏的操做屬性只有一個value,兩個圖相互結合就更能理解了。

Sequencer的實現

在RingBuffer構造函數裏面存在一個Sequencer接口,用來遍歷數據,在生產者和消費者之間傳遞數據。Sequencer有兩個實現類,單生產者模式的實現SingleProducerSequencer與多生產者模式的實現MultiProducerSequencer。它們的類結構如圖:

單生產者是在Cache line中使用padding方式實現,源碼以下:

多生產者則是使用 sun.misc.Unsafe來實現的。以下圖:

總結與使用示例

可見padding方式在Disruptor中是處理僞共享常見的方式,JDK1.8的@Contended很好的解決了這個問題,不知道Disruptor後面的版本是否會考慮使用它。

Disruptor使用示例代碼參考地址

參考資料:

7個示例科普CPU Cache:http://coolshell.cn/articles/10249.html 
Linux Cache 機制:http://www.cnblogs.com/liloke/archive/2011/11/20/2255737.html 
《深刻理解計算機系統》:第六章部分 
Disruptor官方文檔:https://github.com/LMAX-Exchange/disruptor/tree/master/docs 
Disruptor併發編程網文檔翻譯:http://ifeve.com/disruptor/

做者簡介:

上海-周衛理、北京-楊珍琪、北京-馮英聖、深圳-姜寄羽 傾力合做,另外感謝惠普系統架構師吳治輝策劃支持。

周衛理:本科,從事Java開發7年,熱愛研究術問題,喜歡運動。目前就任於上海一家互聯網公司,擔任Java後端小組組長,負責分佈式系統框架搭建。正往Java高性能編程,大數據中間件方向靠攏。

馮英勝:長期從事Java軟件開發工做,善於複雜業務開發,6年工做經驗,對大數據平臺和分佈式架構等有濃厚興趣。目前就任於北京噹噹網。

姜寄羽:四川大學軟件工程學士。目前在深圳亞略特擔任Java工程師一職。負責Java方面的開發和維護以及新技術預研,對軟件工程、分佈式系統和高性能編程有着深厚的理論基礎。

楊珍琪:碩士,會計學士,前HP工程師,參與中國移動BOSS系統開發,現爲創業公司CTO。

相關文章
相關標籤/搜索