JAVA 拾遺 — CPU Cache 與緩存行

最近的兩篇文章,介紹了我參加的中間件比賽中一些相對重要的優化,但實際上還存在不少細節優化,出於篇幅限制並未說起,在最近的博文中,我會將他們整理成獨立的知識點,並歸類到個人系列文章「JAVA 拾遺」中。php

引言

public class Main {
    static long[][] arr;

    public static void main(String[] args) {
        arr = new long[1024 * 1024][8];
        // 橫向遍歷
        long marked = System.currentTimeMillis();
        for (int i = 0; i < 1024 * 1024; i += 1) {
            for (int j = 0; j < 8; j++) {
                sum += arr[i][j];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");

        marked = System.currentTimeMillis();
        // 縱向遍歷
        for (int i = 0; i < 8; i += 1) {
            for (int j = 0; j < 1024 * 1024; j++) {
                sum += arr[j][i];
            }
        }
        System.out.println("Loop times:" + (System.currentTimeMillis() - marked) + "ms");
    }
}
複製代碼

如上述代碼所示,定義了一個二維數組 long[][] arr 而且使用了橫向遍歷和縱向遍歷兩種順序對這個二位數組進行遍歷,遍歷總次數相同,只不過循環的方向不一樣,代碼中記錄了這兩種遍歷方式的耗時,不妨先賣個關子,他們的耗時會有區別嗎?html

這問題問的和中小學試卷中的:「它們之間有區別嗎?若有,請說出區別。」同樣沒有水準,沒區別的話文章到這兒就結束了。事實上,在個人機器上(64 位 mac)屢次運行後能夠發現:橫向遍歷的耗時大約爲 25 ms,縱向遍歷的耗時大約爲 60 ms,前者比後者快了 1 倍有餘。若是你瞭解上述現象出現的緣由,大概能猜到,今天這篇文章的主角即是他了— CPU Cache&Cache Line。java

在學生生涯時,不斷收到這樣建議:《計算機網絡》、《計算機組成原理》、《計算機操做系統》、《數據結構》四門課程是相當重要的,而在我這些年的工做經驗中也不斷地意識到前輩們如此建議的緣由。做爲一個 Java 程序員,你能夠選擇不去理解操做系統,組成原理(相比這兩者,網絡和數據結構跟平常工做聯繫得相對緊密),這不會下降你的 KPI,但瞭解他們可使你寫出更加計算機友好(Mechanical Sympathy)的代碼。linux

下面的章節將會出現很多操做系統相關的術語,我將逐個介紹他們,並最終將他們與 Java 聯繫在一塊兒。git

什麼是 CPU 高速緩存?

CPU 是計算機的心臟,最終由它來執行全部運算和程序。主內存(RAM)是數據(包括代碼行)存放的地方。這二者的定義你們應該不會陌生,那 CPU 高速緩存又是什麼呢?程序員

計算機系統中,CPU高速緩存是用於減小處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位於自頂向下的第二層,僅次於CPU寄存器。其容量遠小於內存,但速度卻能夠接近處理器的頻率。github

當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。若是存在(命中),則不經訪問內存直接返回該數據;若是不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。面試

緩存之因此有效,主要是由於程序運行時對內存的訪問呈現局部性(Locality)特徵。這種局部性既包括空間局部性(Spatial Locality),也包括時間局部性(Temporal Locality)。有效利用這種局部性,緩存能夠達到極高的命中率。數組

在處理器看來,緩存是一個透明部件。所以,程序員一般沒法直接干預對緩存的操做。可是,確實能夠根據緩存的特色對程序代碼實施特定優化,從而更好地利用緩存緩存

— 維基百科

CPU 緩存架構

左圖爲最簡單的高速緩存的架構,數據的讀取和存儲都通過高速緩存,CPU 核心與高速緩存有一條特殊的快速通道;主存與高速緩存都連在系統總線上(BUS),這條總線還用於其餘組件的通訊。簡而言之,CPU 高速緩存就是位於 CPU 操做和主內存之間的一層緩存。

爲何須要有 CPU 高速緩存?

隨着工藝的提高,最近幾十年 CPU 的頻率不斷提高,而受制於製造工藝和成本限制,目前計算機的內存在訪問速度上沒有質的突破。所以,CPU 的處理速度和內存的訪問速度差距愈來愈大,甚至能夠達到上萬倍。這種狀況下傳統的 CPU 直連內存的方式顯然就會由於內存訪問的等待,致使計算資源大量閒置,下降 CPU 總體吞吐量。同時又因爲內存數據訪問的熱點集中性,在 CPU 和內存之間用較爲快速而成本較高(相對於內存)的介質作一層緩存,就顯得性價比極高了。

爲何須要有 CPU 多級緩存?

結合 圖片 -- CPU 緩存架構,再來看一組 CPU 各級緩存存取速度的對比

  1. 各類寄存器,用來存儲本地變量和函數參數,訪問一次須要1cycle,耗時小於1ns;
  2. L1 Cache,一級緩存,本地 core 的緩存,分紅 32K 的數據緩存 L1d 和 32k 指令緩存 L1i,訪問 L1 須要3cycles,耗時大約 1ns;
  3. L2 Cache,二級緩存,本地 core 的緩存,被設計爲 L1 緩存與共享的 L3 緩存之間的緩衝,大小爲 256K,訪問 L2 須要 12cycles,耗時大約 3ns;
  4. L3 Cache,三級緩存,在同插槽的全部 core 共享 L3 緩存,分爲多個 2M 的段,訪問 L3 須要 38cycles,耗時大約 12ns;

大體能夠得出結論,緩存層級越接近於 CPU core,容量越小,速度越快,同時,沒有披露的一點是其造價也更貴。因此爲了支撐更多的熱點數據,同時追求最高的性價比,多級緩存架構應運而生。

什麼是緩存行(Cache Line)?

上面咱們介紹了 CPU 多級緩存的概念,而以後的章節咱們將嘗試忽略「多級」這個特性,將之合併爲 CPU 緩存,這對於咱們理解 CPU 緩存的工做原理並沒有大礙。

緩存行 (Cache Line) 即是 CPU Cache 中的最小單位,CPU Cache 由若干緩存行組成,一個緩存行的大小一般是 64 字節(這取決於 CPU),而且它有效地引用主內存中的一塊地址。一個 Java 的 long 類型是 8 字節,所以在一個緩存行中能夠存 8 個 long 類型的變量。

多級緩存

試想一下你正在遍歷一個長度爲 16 的 long 數組 data[16],原始數據天然存在於主內存中,訪問過程描述以下

  1. 訪問 data[0],CPU core 嘗試訪問 CPU Cache,未命中。
  2. 嘗試訪問主內存,操做系統一次訪問的單位是一個 Cache Line 的大小 — 64 字節,這意味着:既從主內存中獲取到了 data[0] 的值,同時將 data[0] ~ data[7] 加入到了 CPU Cache 之中,for free~
  3. 訪問 data[1]~data[7],CPU core 嘗試訪問 CPU Cache,命中直接返回。
  4. 訪問 data[8],CPU core 嘗試訪問 CPU Cache,未命中。
  5. 嘗試訪問主內存。重複步驟 2

CPU 緩存在順序訪問連續內存數據時揮發出了最大的優點。試想一下上一篇文章中提到的 PageCache,其實發生在磁盤 IO 和內存之間的緩存,是否是有殊途同歸之妙?只不過今天的主角— CPU Cache,相比 PageCache 更加的微觀。

再回到文章的開頭,爲什麼橫向遍歷 arr = new long[1024 * 1024][8] 要比縱向遍歷更快?此處獲得瞭解答,正是更加友好地利用 CPU Cache 帶來的優點,甚至有一個專門的詞來修飾這種行爲 — Mechanical Sympathy。

僞共享

一般提到緩存行,大多數文章都會提到僞共享問題(正如提到 CAS 便會提到 ABA 問題通常)。

僞共享指的是多個線程同時讀寫同一個緩存行的不一樣變量時致使的 CPU 緩存失效。儘管這些變量之間沒有任何關係,但因爲在主內存中鄰近,存在於同一個緩存行之中,它們的相互覆蓋會致使頻繁的緩存未命中,引起性能降低。僞共享問題難以被定位,若是系統設計者不理解 CPU 緩存架構,甚至永遠沒法發現 — 原來個人程序還能夠更快。

僞共享
僞共享

正如圖中所述,若是多個線程的變量共享了同一個 CacheLine,任意一方的修改操做都會使得整個 CacheLine 失效(由於 CacheLine 是 CPU 緩存的最小單位),也就意味着,頻繁的多線程操做,CPU 緩存將會完全失效,降級爲 CPU core 和主內存的直接交互。

僞共享問題的解決方法即是字節填充。

僞共享-字節填充
僞共享-字節填充

咱們只須要保證不一樣線程的變量存在於不一樣的 CacheLine 便可,使用多餘的字節來填充能夠作點這一點,這樣就不會出現僞共享問題。在代碼層面如何實現圖中的字節填充呢?

Java6 中實現字節填充

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

PaddingObject 類中須要保存一個 long 類型的 value 值,若是多線程操做同一個 CacheLine 中的 PaddingObject 對象,便沒法徹底發揮出 CPU Cache 的優點(想象一下你定義了一個 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;    // 實際數據
}
複製代碼

Tips:實際上我在本地 mac 下測試過 jdk1.8 下的字節填充,並不會出現無效代碼的優化,我的猜想和 jdk 版本有關,不過爲了保險起見,仍是使用相對穩妥的方式去填充較爲合適。

若是你對這個現象感興趣,測試代碼以下:

public final class FalseSharing implements Runnable {
    public final static int NUM_THREADS = 4; // change
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;

    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];

    static {
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
    }

    public FalseSharing(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception {
        final long start = System.currentTimeMillis();
        runTest();
        System.out.println("duration = " + (System.currentTimeMillis() - start));
    }

    private static void runTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];

        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharing(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }

    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }

    public final static class VolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6; // 填充,能夠註釋後對比測試
    }


}
複製代碼

Java8 中實現字節填充

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    String value() default "";
}
複製代碼

注意須要同時開啓 JVM 參數:-XX:-RestrictContended=false

@Contended 註解會增長目標實例大小,要謹慎使用。默認狀況下,除了 JDK 內部的類,JVM 會忽略該註解。要應用代碼支持的話,要設置 -XX:-RestrictContended=false,它默認爲 true(意味僅限 JDK 內部的類使用)。固然,也有個 –XX: EnableContented 的配置參數,來控制開啓和關閉該註解的功能,默認是 true,若是改成 false,能夠減小 Thread 和 ConcurrentHashMap 類的大小。參加《Java性能權威指南》210 頁。

— @Im 的補充

Java8 中終於提供了字節填充的官方實現,這無疑使得 CPU Cache 更加可控了,無需擔憂 jdk 的無效字段優化,無需擔憂 Cache Line 在不一樣 CPU 下的大小到底是不是 64 字節。使用 @Contended 註解能夠完美的避免僞共享問題。

一些最佳實踐

可能有讀者會問:做爲一個普通開發者,須要關心 CPU Cache 和 Cache Line 這些知識點嗎?這就跟前幾天比較火的話題:「程序員有必要懂 JVM 嗎?」同樣,仁者見仁了。但確實有很多優秀的源碼在關注着這些問題。他們包括:

ConcurrentHashMap

面試中問到要吐的 ConcurrentHashMap 中,使用 @sun.misc.Contended 對靜態內部類 CounterCell 進行修飾。另外還包括併發容器 Exchanger 也有相同的操做。

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

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;
複製代碼

RingBuffer

來源於一款優秀的開源框架 Disruptor 中的一個數據結構 **RingBuffer ,**我後續會專門花一篇文章的篇幅來介紹這個數據結構

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

abstract class RingBufferFields<E> extends RingBufferPad{}
複製代碼

使用字節填充和繼承的方式來避免僞共享。

面試題擴展

問:說說數組和鏈表這兩種數據結構有什麼區別?

瞭解了 CPU Cache 和 Cache Line 以後想一想可不能夠有一些特殊的回答技巧呢?

參考資料

高性能隊列——Disruptor

神奇的緩存行填充

僞共享和緩存行填充

關於CPU Cache -- 程序猿須要知道的那些事

歡迎關注個人微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會獲得回覆,帶來更多 Java 相關的技術分享。

關注微信公衆號
相關文章
相關標籤/搜索