CPU Cache與緩存行


編譯環境:windows10+Idea+x86 CPU。

1、CPU Cache

CPU 訪問內存時,首先查詢 cache 是否已緩存該數據。如果有,則返回數據,無需訪問內存;如果不存在,則需把數據從內存中載入 cache,最后返回給理器。在處理器看來,緩存是一個透明部件,旨在提高處理器訪問內存的速率,所以從邏輯的角度而言,編程時無需關注它,但是從性能的角度而言,理解其原理和機制有助於寫出性能更好的程序。Cache 之所以有效,是因為程序對內存的訪問存在一種概率上的局部特征:

  • Spatial Locality:對於剛被訪問的數據,其相鄰的數據在將來被訪問的概率高。
  • Temporal Locality:對於剛被訪問的數據,其本身在將來被訪問的概率高。

下圖是計算機存儲的基本結構。L1、L2、L3分別表示一級緩存、二級緩存、三級緩存。越靠近CPU的緩存,速度越快,容量也越小。L1緩存小但很快,並且緊靠着在使用它的CPU內核。分為指令緩存和數據緩存;L2大一些,也慢一些,並仍然只能被一個單獨的CPU核使用;L3更大、更慢,並且被單個插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。

當CPU執行運算的時候,它先去L1查找所需的數據、再去L2、然后是L3,如果最后這些緩存中都沒有,所需的數據就要去主內存拿。走得越遠,運算耗費的時間就越長。所以要盡量確保數據在L1緩存中。

Martin和Mike的 QCon presentation 演講中給出了一些緩存未命中的消耗數據,也就是從CPU訪問不同層級數據的時間概念:

可見CPU讀取主存中的數據會比從L1中讀取慢了近60-80倍。

2、Cache Line

Cache是由很多個 Cache line 組成的。Cache line 是 cache 和 RAM 交換數據的最小單位,通常為 64 Byte。當 CPU 把內存的數據載入 cache 時,會把臨近的共 64 Byte 的數據一同放入同一個Cache line,因為空間局部性:臨近的數據在將來被訪問的可能性大。

由於緩存行的特性,當多線程修改互相獨立的變量時,如果這些變量共享同一個緩存行,就會無意中影響彼此的性能,這就是偽共享(下面會介紹到)。有人將偽共享描述成無聲的性能殺手,因為從代碼中很難看清楚是否會出現偽共享問題。

需要注意,數據在緩存中不是以獨立的項來存儲的,它不是我們認為的一個獨立的變量,也不是一個單獨的指針,它是有效引用主存中的一塊地址。一個Java的long類型是8字節,因此在一個緩存行中可以存8個long類型的變量。

以大小為 32 KB,cache line 的大小為 64 Byte 的L1級緩存為例,對於不同存放規則,其硬件設計也不同,下圖簡單表示一種設計:

緩存行的這種特性也決定了在訪問同一緩存行中的數據時效率是比較高的。比如當你訪問java中的一個long類型的數組,當數組中的一個值被加載到緩存中,它會額外加載另外7個,因此可以非常快速的遍歷這個數組。實際上,你可以非常快速的遍歷在連續的內存塊中分配的任意數據結構。

3、False Sharing(偽共享)

處理器為了提高處理速度,不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存(L1,L2,L3)后再進行操作,但操作完之后不知道何時會寫到內存;如果對聲明了volatile 變量進行寫操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在的緩存行的數據寫回到系統內存。但就算寫回到內存,如果其他處理器緩存的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操作的時候,會強制重新從系統內存里把數據讀取到處理器緩存里。

為了說明偽共享問題,下面舉一個例子進行說明:兩個線程分別對兩個變量(剛好在同一個緩存行)分別進行讀寫的情況分析。

在core1上線程需要更新變量X,同時core2上線程需要更新變量Y。這種情況下,兩個變量就在同一個緩存行中。每個線程都要去競爭緩存行的所有權來更新對應的變量。如果core1獲得了緩存行的所有權,那么緩存子系統將會使core2中對應的緩存失效。相反,如果core2獲得了所有權然后執行更新操作,core1就要使自己對應的緩存行失效。這里需要注意:整個操作過程是以緩存行為單位進行處理的,這會來來回回的經過L3緩存,大大影響了性能,每次當前線程對緩存行進行寫操作時,內核都要把另一個內核上的緩存塊無效掉,並重新讀取里面的數據。如果相互競爭的核心位於不同的插槽,就要額外橫跨插槽連接,效率可能會更低。

3、緩存對齊

基於以上問題的分析,在一些情況下,比如會頻繁進行操作的數據,可以根據緩存行的特性進行緩存行對齊(即將要操作的數據湊一個緩存行進行操作)下面使用一個示例進行說明:

public final class T  {

    public static class X{
        //8字節
        private volatile long x = 0L;
    }
    private static X[] arr = new X[2];

    static {
        arr[0] = new X();
        arr[1] = new X();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(()->{
            for(long i = 0;i < 1000_0000L;i++){
                //volatile的緩存一致性協議MESI或者鎖總線,會消耗時間
                arr[0].x = i;
            }
        });

        Thread thread2 = new Thread(()->{
            for(long i = 0;i< 1000_0000L;i++){
                arr[1].x = i;
            }
        });
        long startTime = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("總計消耗時間:"+(System.nanoTime()-startTime)/100_000);
    }
}

運行結果如下:

總計消耗時間:4645

升級改造運行,實現緩存行對齊,重點代碼如下:

private static class Padding{
    //7*8字節
    public volatile long p1,p2,p3,p4,p5,p6,p7;
}
public static class T extends Padding{
    //8字節
    private volatile long x = 0L;
}

通過上述代碼做緩存對齊,每次都會有初始的7*8個占位,加上最后一個就是獨立的一塊緩存行,整理后代碼如下:

public final class T  {
    private static class Padding{
        //7*8字節
        public volatile long p1,p2,p3,p4,p5,p6,p7;
    }
    public static class X extends Padding{
        //8字節
        private volatile long x = 0L;
    }
    private static X[] arr = new X[2];

    static {
        arr[0] = new X();
        arr[1] = new X();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(()->{
            for(long i = 0;i < 1000_0000L;i++){
                //volatile的緩存一致性協議MESI或者鎖總線,會消耗時間
                arr[0].x = i;
            }
        });

        Thread thread2 = new Thread(()->{
            for(long i = 0;i< 1000_0000L;i++){
                arr[1].x = i;
            }
        });
        long startTime = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("總計消耗時間:"+(System.nanoTime()-startTime)/100_000);
    }
}

運行結果如下:

總計消耗時間:868

從上面可以看到,使用緩存對齊,相同操作情況下對齊后的時間比沒對齊的時間有飛躍的提速。

這種緩存行填充的方法在早期是比較流行的一種解決辦法,比較有名的Disruptor框架就采用了這種解決辦法提高性能,Disruptor是一個線程內通信框架,用於線程里共享數據。與LinkedBlockingQueue類似,提供了一個高速的生產者消費者模型,廣泛用於批量IO讀寫,在硬盤讀寫相關的程序中應用十分廣泛,Apache旗下的HBase、Hive、Storm等框架都有使用Disruptor。

4、Cache Line偽共享解決方案

處理偽共享的兩種方式:

 

  • 字節填充:增大元素的間隔,使得不同線程存取的元素位於不同的cache line上,典型的空間換時間。

 

  • 在每個線程中創建對應元素的本地拷貝,結束后再寫回全局數組。

Java6 中實現字節填充:

public class PaddingObject{
    public volatile long value = 0L;    // 實際數據
    public long p1, p2, p3, p4, p5, p6; // 填充
}

PaddingObject 類中需要保存一個 long 類型的 value 值,如果多線程操作同一個 CacheLine 中的 PaddingObject 對象,便無法完全發揮出 CPU Cache 的優勢。

實際數據 value + 用於填充的 p1~p6 總共只占據了 7 * 8 = 56 個字節,而 Cache Line 的大小應當是 64 字節,這是有意而為之,在 Java 中,對象頭還占據了 8 個字節,所以一個 PaddingObject 對象可以恰好占據一個 Cache Line。

Java7 中實現字節填充:

在 Java7 之后,一個 JVM 的優化給字節填充造成了一些影響,上面的代碼片段 public long p1, p2, p3, p4, p5, p6; 會被認為是無效代碼被優化掉,有回歸到了偽共享的窘境之中。

為了避免 JVM 的自動優化,需要使用繼承的方式來填充。

abstract class AbstractPaddingObject{
    protected long p1, p2, p3, p4, p5, p6;// 填充
}

public class PaddingObject extends AbstractPaddingObject{
    public volatile long value = 0L;    // 實際數據
}

Java8 中實現字節填充:

//JDK 8中提供的注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {

    /**
     * The (optional) contention group tag.
     * This tag is only meaningful for field level annotations.
     *
     * @return contention group tag.
     */
    String value() default "";
}

在 JDK 8 里提供了一個新注解@Contended,可以用來減少false sharing的情況。JVM在計算對象布局的時候就會自動把標注的字段拿出來並且插入合適的大小padding。

因為這個功能暫時還是實驗性功能,暫時還沒到默認普及給用戶代碼用的程度。要在用戶代碼(非bootstrap class loader或extension class loader所加載的類)中使用@Contended注解的話,需要使用 -XX:-RestrictContended 參數。

代碼優化如下:

public final class T  {

    @sun.misc.Contended
    public static class X {
        //8字節
        private volatile long x = 0L;
    }
    private static X[] arr = new X[2];

    static {
        arr[0] = new X();
        arr[1] = new X();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(()->{
            for(long i = 0;i < 1000_0000L;i++){
                //volatile的緩存一致性協議MESI或者鎖總線,會消耗時間
                arr[0].x = i;
            }
        });

        Thread thread2 = new Thread(()->{
            for(long i = 0;i< 1000_0000L;i++){
                arr[1].x = i;
            }
        });
        long startTime = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("總計消耗時間:"+(System.nanoTime()-startTime)/100_000);
    }
}

運行結果如下:

總計消耗時間:870

比如在JDK 8 的ConcurrentHashMap 源碼中,使用 @sun.misc.Contended對靜態內部類 CounterCell 進行了修飾。

/* ---------------- 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; }
}

Thread 線程類的源碼中,使用 @sun.misc.Contended 對成員變量進行修飾。

// 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;

一款優秀的開源框架 Disruptor 中的一個數據結構 RingBuffer使用字節填充和繼承的方式來避免偽共享。

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

abstract class RingBufferFields<E> extends RingBufferPad{}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM