JAVA 拾遺 — CPU Cache 與快取行

Kirito的技術分享發表於2019-01-22

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

引言

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 並且使用了橫向遍歷和縱向遍歷兩種順序對這個二位陣列進行遍歷,遍歷總次數相同,只不過迴圈的方向不同,程式碼中記錄了這兩種遍歷方式的耗時,不妨先賣個關子,他們的耗時會有區別嗎?

這問題問的和中小學試卷中的:“它們之間有區別嗎?如有,請說出區別。”一樣沒有水準,沒區別的話文章到這兒就結束了。事實上,在我的機器上(64 位 mac)多次執行後可以發現:橫向遍歷的耗時大約為 25 ms,縱向遍歷的耗時大約為 60 ms,前者比後者快了 1 倍有餘。如果你瞭解上述現象出現的原因,大概能猜到,今天這篇文章的主角便是他了— CPU Cache&Cache Line。

在學生生涯時,不斷收到這樣建議:《計算機網路》、《計算機組成原理》、《計算機作業系統》、《資料結構》四門課程是至關重要的,而在我這些年的工作經驗中也不斷地意識到前輩們如此建議的原因。作為一個 Java 程式設計師,你可以選擇不去理解作業系統,組成原理(相比這二者,網路和資料結構跟日常工作聯絡得相對緊密),這不會降低你的 KPI,但瞭解他們可以使你寫出更加計算機友好(Mechanical Sympathy)的程式碼。

下面的章節將會出現不少作業系統相關的術語,我將逐個介紹他們,並最終將他們與 Java 聯絡在一起。

什麼是 CPU 快取記憶體?

CPU 是計算機的心臟,最終由它來執行所有運算和程式。主記憶體(RAM)是資料(包括程式碼行)存放的地方。這兩者的定義大家應該不會陌生,那 CPU 快取記憶體又是什麼呢?

計算機系統中,CPU快取記憶體是用於減少處理器訪問記憶體所需平均時間的部件。在金字塔式儲存體系中它位於自頂向下的第二層,僅次於CPU暫存器。其容量遠小於記憶體,但速度卻可以接近處理器的頻率。

當處理器發出記憶體訪問請求時,會先檢視快取內是否有請求資料。如果存在(命中),則不經訪問記憶體直接返回該資料;如果不存在(失效),則要先把記憶體中的相應資料載入快取,再將其返回處理器。

快取之所以有效,主要是因為程式執行時對記憶體的訪問呈現區域性性(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 相關的技術分享。

關注微信公眾號

相關文章