深入理解 CPU 緩存:編寫高性能 Java 代碼的終極指南
在當(dāng)今高性能計算的時代,程序性能已不再僅僅取決于算法的復(fù)雜度,更深層次地依賴于對底層硬件的理解與利用。CPU緩存作為連接處理器與內(nèi)存的關(guān)鍵橋梁,直接影響著程序的運行效率。隨著CPU主頻提升逐漸逼近物理極限,架構(gòu)層面的優(yōu)化,尤其是緩存機(jī)制的合理利用,成為提升軟件性能的核心突破口。
CPU緩存通過“局部性原理”將熱點數(shù)據(jù)預(yù)加載至高速緩存中,大幅減少CPU等待數(shù)據(jù)的時間。從最初的單級緩存到如今的多級緩存體系,從直接映射到組相聯(lián)映射,緩存技術(shù)的演進(jìn)不僅體現(xiàn)了計算機(jī)體系結(jié)構(gòu)設(shè)計者的智慧,也為我們編寫高性能代碼提供了底層支持。
但僅僅知道緩存的存在是遠(yuǎn)遠(yuǎn)不夠的。作為一名Java開發(fā)者,深入理解CPU緩存的工作機(jī)制,并將其應(yīng)用于實際開發(fā)中,是邁向高性能編程的關(guān)鍵一步。無論是數(shù)組的訪問順序、分支預(yù)測的優(yōu)化,還是多核環(huán)境下線程與CPU核心的綁定策略,每一個細(xì)節(jié)都可能帶來數(shù)量級級別的性能差異。
本文將從計算機(jī)體系結(jié)構(gòu)的發(fā)展歷程出發(fā),深入剖析CPU緩存的核心原理,并結(jié)合豐富的Java代碼示例,揭示那些隱藏在底層硬件之下的性能優(yōu)化技巧,幫助您寫出真正高效的程序。

一、回顧計算機(jī)體系
1. 馮諾依曼計算機(jī)體系
早期的馮諾依曼計算機(jī),大抵功能和工作流程如下:
- 輸入設(shè)備接收用戶輸入的指令信息
- 數(shù)據(jù)到達(dá)存儲器,控制器從存儲器中取出指令交給運算器執(zhí)行
- 控制器從存儲器中撈數(shù)據(jù)和指令交給運算器完成運算,最后交由輸出設(shè)備響應(yīng)給用戶
可以看到控制流程的調(diào)度由運算器負(fù)責(zé),導(dǎo)致一個簡單的執(zhí)行需要經(jīng)過多個不必要的工序,使得整個執(zhí)行流程變得繁瑣冗長:

2. 現(xiàn)代計算機(jī)
現(xiàn)在計算機(jī)對此進(jìn)行了改造,可以看出他們將需要處理的數(shù)據(jù)的運算器和存儲器放到兩邊,然后存儲器負(fù)責(zé)運輸數(shù)據(jù)給這兩者,數(shù)據(jù)經(jīng)過運算器計算之后再將數(shù)據(jù)經(jīng)過存儲器再轉(zhuǎn)交給控制器轉(zhuǎn)發(fā)到輸出設(shè)備:

了解這樣的體系結(jié)構(gòu)后,我們將這些組成部分整合起來,就得到了現(xiàn)代計算機(jī)的重要組成部分——CPU。CPU包含了運算器和控制器,主存儲器(也就是我們常說的內(nèi)存)以及輔存(就是硬盤):
我們將職責(zé)進(jìn)行劃分就構(gòu)成了下圖所示的樣子:
- 由運算器和控制器構(gòu)成CPU
- 主存也就是我們常說的內(nèi)存
- 外設(shè)部分由一些I/O設(shè)備、輔存(就是硬盤)組成

二、詳解cpu緩存
1. 內(nèi)存性能的局限性
計算機(jī)發(fā)展初期,CPU執(zhí)行指令時用到的數(shù)據(jù)都必須從內(nèi)存中加載,但由于內(nèi)存的讀寫速度與CPU不在一個級別,這就使得CPU的性能受到內(nèi)存讀寫效率的制約而降低。由此設(shè)計者們開始思考,在CPU流水線指令設(shè)計理念的基礎(chǔ)上,CPU的執(zhí)行效率是否還有提升的空間。
通過觀察發(fā)現(xiàn),CPU每次執(zhí)行指令時用到的數(shù)據(jù),其前后相鄰的數(shù)據(jù)有很大概率也會被用到。例如下面這段循環(huán)代碼,每次輸出元素后都會訪問其后方的元素,因此設(shè)計者開始考慮在CPU上增加一層緩存,通過提前預(yù)讀數(shù)據(jù)來提升CPU運算效率,從而避免因等待內(nèi)存讀取而產(chǎn)生的阻塞問題:
for (int i = 0; i < 10; i++) {
System.out.println(list.get(i));
}由此引入了CPU緩存的設(shè)計,也就有了CPU緩存行的概念,即當(dāng)CPU使用到內(nèi)存中的某塊數(shù)據(jù)時,會將這塊內(nèi)存數(shù)據(jù)連同其附近的數(shù)據(jù)一起加載到CPU緩存中:

考慮到CPU中緩存電路采用讀寫速度較高的SRAM電路,耗用的晶體管數(shù)量比較多,現(xiàn)代CPU L1緩存通常為每個核心32KB到64KB,同時緩存對應(yīng)的基本單位默認(rèn)是緩存行,默認(rèn)大小為64字節(jié)。
于是我們就有了這樣一組cpu緩存的粗略設(shè)計:
- 每個緩存行都有一個內(nèi)存空間記錄緩存的數(shù)據(jù)地址
- 緩存行專門用無效標(biāo)識記錄當(dāng)前數(shù)據(jù)是否有效,若標(biāo)識為無效則cpu需要從內(nèi)存中讀取數(shù)據(jù)
- 臟數(shù)據(jù)標(biāo)識說明這個數(shù)據(jù)被cpu修改過,需要刷新到內(nèi)存中
- 數(shù)據(jù)字段記錄cpu要操作的數(shù)據(jù)信息

2. 緩存加載的算法
有了緩存基礎(chǔ)之后,設(shè)計者們就開始考慮如何從內(nèi)存中高效地加載數(shù)據(jù)并寫入CPU緩存行中。一開始考慮采用遍歷尋址的方式,但這種方式不僅效率低下,還違背了緩存行設(shè)計的初衷,無法實現(xiàn)快速預(yù)讀和使用。
因此設(shè)計者考慮采用數(shù)據(jù)取模地址并寫入的設(shè)計思路,但是問題又來了,如果兩個要寫入的數(shù)據(jù)地址發(fā)生沖突要如何解決?于是設(shè)計者打算將緩存空間分為四塊,也就是四路組相連。在進(jìn)行緩存寫入時,對四個緩存空間都進(jìn)行取模運算,將數(shù)據(jù)寫入有空閑空間的緩存塊中,這樣既能保證寫入效率,又能盡可能減少沖突:

3. 指令與數(shù)據(jù)緩存分離
在CPU內(nèi)部流水線中,如果執(zhí)行指令的電路和讀取數(shù)據(jù)的電路都需要訪問內(nèi)存,而訪問內(nèi)存的地址譯碼器只有一個,這就使得一方必須阻塞等待,導(dǎo)致雙方中必有一方需要阻塞等待,這就是結(jié)構(gòu)冒險:

于是就考慮到一種名為哈佛架構(gòu)的設(shè)計,即將內(nèi)存空間分為指令空間和數(shù)據(jù)空間以避免這種沖突,但是這種做法會導(dǎo)致內(nèi)存失去動態(tài)分配的優(yōu)勢??紤]到當(dāng)前CPU引入了緩存的概念,可以在緩存的基礎(chǔ)上將執(zhí)行指令和數(shù)據(jù)讀取兩個模塊的緩存空間分離,利用CPU緩存讀取來盡可能避免指令和數(shù)據(jù)訪問沖突。當(dāng)然,這種情況在緩存未命中的情況下還是會出現(xiàn)結(jié)構(gòu)冒險:

4. 多級緩存空間
看到引入緩存后的優(yōu)勢,設(shè)計者提出了多級緩存概念。結(jié)合緩存電路和晶體管的制作成本,最終的折中方案為:
- 一級緩存容量最小,每個CPU獨有一份一級緩存L1-cache,效率最高,CPU訪問數(shù)據(jù)的時間成本只需要2~4個時鐘周期,緩存分為i-cache和d-cache分別存儲指令和數(shù)據(jù)
- 二級緩存容量稍大,成本相對控制,所以訪問時間表現(xiàn)稍差,大約需要10~20個時鐘周期
- 三級緩存多核CPU共享,CPU訪問時間大約20~60個時鐘周期
- 內(nèi)存大約是200~300個時鐘周期
這里補(bǔ)充一下CPU時鐘周期的概念,時鐘周期是CPU主頻的倒數(shù)。例如我們現(xiàn)在有一個CPU的主頻為2GHz,一個時鐘周期的計算過程如下:
時鐘周期=1/主頻
因為:主頻單位為hz即 次/秒
2GHz= 2*10^9 hz
時鐘周期=1/ 2*10^9
= 0.0000000005次/秒
= 0.5ns所以一個一個2GHz的cpu對應(yīng)的時鐘周期大約是0.5ns即0.5ns執(zhí)行一次cpu事件,也就是每秒鐘發(fā)生20億次事件。
5. cpu緩存基本概念
在CPU訪問數(shù)據(jù)的時候,都會優(yōu)先從緩存中獲取數(shù)據(jù),如果沒有找到再去內(nèi)存中讀取,若內(nèi)存中也找不到,則從磁盤中讀取數(shù)據(jù)并加載到CPU CACHE中。

當(dāng)CPU CACHE開始加載數(shù)據(jù)的時候,并不是按需精確讀取一段數(shù)據(jù),而是一小塊一小塊地進(jìn)行數(shù)據(jù)讀取。而這一小塊數(shù)據(jù)就是我們?nèi)粘Kf的CPU Line(緩存行)。
在Linux中執(zhí)行命令cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size,我們就能看到自己CPU L1 Cache對應(yīng)的CPU Line的大小。以筆者服務(wù)器為例,每個緩存行大小為64字節(jié):
這64字節(jié)是什么概念呢?我們都知道一個整型變量是4個字節(jié)。假如我們聲明一個數(shù)組int data[] = new int[32768];,當(dāng)CPU cache載入data[0]時,由于cache line的大小為64字節(jié),也就是說還會有一些空閑的空間沒用到,根據(jù)局部性原理,CPU就會將剩余部分用于存儲data[0]附近的數(shù)據(jù),也就是說載入data[0]時,cache line順手將索引1-15的元素也加載到CPU cache line中。

當(dāng)cache中有內(nèi)存加載的數(shù)據(jù)時,我們怎么知道這個數(shù)據(jù)對應(yīng)的內(nèi)存那塊數(shù)據(jù)呢?
其實CPU早就考慮到這點了,實現(xiàn)方式也很簡單,通過直接映射Cache(Direct Mapped Cache),說白了就是將內(nèi)存地址和CPU cache line做一個映射,我們都知道內(nèi)存的一塊塊數(shù)據(jù)稱為內(nèi)存塊,實現(xiàn)地址映射的方式也很簡單,就是將內(nèi)存塊地址進(jìn)行取模運算后再將存到對應(yīng)的cache line中。
例如:我們有8個cache line以及32個內(nèi)存塊,當(dāng)cache需要載入15號塊時,cache就會將其存到15%8=7,即7號cache line中。

假如映射地址沖突了怎么辦?這個問題也很簡單,針對每個cache line 增加一個Tag標(biāo)記就好了,例如第4個cache line 存儲的是內(nèi)存塊15的數(shù)據(jù),對應(yīng)的第4個cache line,對應(yīng)第4個cache line的緩存空間數(shù)據(jù)結(jié)構(gòu)為:
- 設(shè)置valid標(biāo)識為1說明當(dāng)前緩存數(shù)據(jù)有效且未過期(緩存數(shù)據(jù)有效,操作系統(tǒng)其他線程未對緩存對應(yīng)的內(nèi)存數(shù)據(jù)做修改)
- 設(shè)置設(shè)置tag標(biāo)識說明cache line對應(yīng)的內(nèi)存數(shù)據(jù)是內(nèi)存塊15
- offset告知cache line中對應(yīng)的偏移地址是我們實際要用到數(shù)據(jù),這樣的數(shù)據(jù)用專業(yè)術(shù)語稱為字(word)

基于cache line,當(dāng)cpu需要用到這份數(shù)據(jù)時,就會執(zhí)行如下流程:
- 基于內(nèi)存地址通過運算定位到cache line
- cache line無數(shù)據(jù)則從內(nèi)存加載,反之進(jìn)入步驟3
- 查看valid標(biāo)識為是否為有效,若無效則從內(nèi)存重新加載,若有效進(jìn)入步驟4
- 查看offset從cache line定位到實際用到的字(word)
三、(實踐)通過CPU的緩存工作機(jī)制編寫高性能代碼
1. 遍歷順序適配CPU緩存加載順序
先看看下面這段代碼,這段代碼按列遍歷二維數(shù)組,在筆者電腦上運行需要291毫秒:
int[][] arr = new int[10000][10000];
long start = System.currentTimeMillis();
// 逐列遍歷數(shù)據(jù)
for (int i = 0; i < 10000; i++) {
for (int j = 0; j < 10000; j++) {
arr[j][i] = 0;
}
}
long end = System.currentTimeMillis();再看看這段按行遍歷的代碼,在筆者電腦上運行只需要30毫秒:
int[][] arr = new int[10000][10000];
long start = System.currentTimeMillis();
//逐行遍歷二維數(shù)據(jù)
for (int i = 0; i < 10000; i++) {
for (int j = 0; j < 10000; j++) {
arr[i][j] = 0;
}
}
long end = System.currentTimeMillis();
System.out.println("耗時:" + (end - start));讓我們回顧一下CPU cache的原理,由于局部性原理,加載某個數(shù)據(jù)時會加載其附近的數(shù)據(jù),而第一段代碼是按列(縱向)遍歷數(shù)據(jù)的。這意味著CPU CACHE中的數(shù)據(jù)不一定包含for循環(huán)所需的數(shù)據(jù)。 CPU cache只會順序加載其附近的數(shù)據(jù),假如我們訪問arr[0][0],那么它會順序加載arr[0][1]、arr[0][2]、arr[0][3]等。這就導(dǎo)致按列遍歷過程中,只有第一次訪問的列在緩存中,其他列的數(shù)據(jù)都要從內(nèi)存中讀取。這就是為什么第二段代碼的遍歷順序與局部性原理的加載順序一致,所以效率更高。

2. 綁定CPU(設(shè)置cpu親和力)實現(xiàn)多核 CPU 的緩存命中率
現(xiàn)代CPU都是多核心的,進(jìn)程可能在不同CPU核心間來回切換執(zhí)行,這對CPU Cache是不友好的的。雖然L3 Cache是多核心之間共享的,但是L1和L2 Cache都是每個核心獨有的。如果一個進(jìn)程在不同核心間來回切換,各個核心的緩存命中率就會受到影響;相反,如果進(jìn)程都在同一個核心上執(zhí)行,那么其數(shù)據(jù)的L1和L2 Cache的緩存命中率可以得到有效提高。緩存命中率高意味著CPU可以減少訪問內(nèi)存的頻率。
當(dāng)有多個「計算密集型」線程同時執(zhí)行時,為了防止因切換到不同核心而導(dǎo)致緩存命中率下降的問題,我們可以將線程綁定在某一個CPU核心上,這樣性能可以得到非??捎^的提升。

在Linux上提供了sched_setaffinity方法,用于實現(xiàn)將線程綁定到某個CPU核心的功能,就像下面這段代碼,筆者將兩個線程綁定到4號和5號cpu上:
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <signal.h>
#include <string.h>
#define __USE_GNU
#include <sched.h>
#include <pthread.h>
int cpu_core_count;
/**
* 線程函數(shù)1 - 綁定到指定CPU核心并執(zhí)行空循環(huán)
* @param arg 指向CPU核心編號的指針
* @return NULL
*/
void *thread_func1(void *arg) {
cpu_set_t cpu_set_mask; // CPU核心集合掩碼
int *cpu_core_id = (int*)arg; // CPU核心ID
// 顯示要綁定的CPU核心
printf("Binding thread 1 to CPU core %d\n", *cpu_core_id);
// 初始化CPU集合并設(shè)置要綁定的CPU核心
CPU_ZERO(&cpu_set_mask); // 清空CPU集合
CPU_SET(*cpu_core_id, &cpu_set_mask); // 將指定CPU核心添加到集合中
// 設(shè)置當(dāng)前線程的CPU親和力
if (sched_setaffinity(0, sizeof(cpu_set_mask), &cpu_set_mask)) {
printf("Warning: set affinity failed!\n");
} else {
// 簡單的無限循環(huán)
while (1) {
// 空循環(huán),不執(zhí)行任何操作
}
}
return NULL;
}
/**
* 線程函數(shù)2 - 綁定到指定CPU核心并執(zhí)行空循環(huán)
* @param arg 指向CPU核心編號的指針
* @return NULL
*/
void *thread_func2(void *arg) {
cpu_set_t cpu_set_mask; // CPU核心集合掩碼
int *cpu_core_id = (int*)arg; // CPU核心ID
// 顯示要綁定的CPU核心
printf("Binding thread 2 to CPU core %d\n", *cpu_core_id);
// 初始化CPU集合并設(shè)置要綁定的CPU核心
CPU_ZERO(&cpu_set_mask); // 清空CPU集合
CPU_SET(*cpu_core_id, &cpu_set_mask); // 將指定CPU核心添加到集合中
// 設(shè)置當(dāng)前線程的CPU親和力
if (sched_setaffinity(0, sizeof(cpu_set_mask), &cpu_set_mask) == -1) {
printf("Warning: set affinity failed!\n");
} else {
// 簡單的無限循環(huán)
while (1) {
// 空循環(huán),不執(zhí)行任何操作
}
}
return NULL;
}
/**
* 主函數(shù) - 創(chuàng)建兩個線程并分別綁定到CPU核心4和5
* @return 0表示正常退出
*/
int main() {
pthread_t thread1_handle; // 線程1句柄
pthread_t thread2_handle; // 線程2句柄
int target_cpu_core_4 = 4; // 目標(biāo)CPU核心4
int target_cpu_core_5 = 5; // 目標(biāo)CPU核心5
// 獲取系統(tǒng)CPU核心數(shù)量
cpu_core_count = sysconf(_SC_NPROCESSORS_CONF);
// 檢查系統(tǒng)是否有足夠的CPU核心
if (cpu_core_count <= 5) {
printf("Warning: System has only %d CPU cores, binding to CPU 4 and 5 may fail\n", cpu_core_count);
}
// 創(chuàng)建線程并綁定到指定CPU核心
pthread_create(&thread1_handle, NULL, (void *)thread_func1, &target_cpu_core_4);
pthread_create(&thread2_handle, NULL, (void *)thread_func2, &target_cpu_core_5);
// 等待線程結(jié)束
pthread_join(thread1_handle, NULL);
pthread_join(thread2_handle, NULL);
printf("Main thread end\n");
return 0;
}編譯啟動后,指定的兩個cpu使用率跑滿了:

當(dāng)然,對應(yīng)java語言也支持這一點,只是在高并發(fā)架構(gòu)的今天,這種手段帶來的收益遠(yuǎn)不如其他架構(gòu)層面的優(yōu)化,所以筆者就不多做贅述了,感興趣的讀者可以查閱這篇文章:http://www.enmalvi.com/2024/06/27/java-affinity/
四、小結(jié)
本文從計算機(jī)體系結(jié)構(gòu)的發(fā)展歷程作為切入點,深入介紹了CPU發(fā)展初期面臨的性能瓶頸以及緩存技術(shù)的引入?;诔绦驁?zhí)行的局部性特點,提出了局部性原理和緩存預(yù)加載思想。同時針對結(jié)構(gòu)冒險問題,提出了指令和數(shù)據(jù)緩存分離的設(shè)計理念。
文章還詳細(xì)闡述了多級緩存架構(gòu)、緩存映射策略等核心技術(shù),并通過實際代碼示例展示了如何編寫緩存友好型代碼,包括合理的數(shù)據(jù)遍歷順序和條件判斷優(yōu)化等最佳實踐。
此外,本文還介紹了多核CPU環(huán)境下通過線程綁定技術(shù)提升緩存命中率的方法,為優(yōu)化程序性能提供了實用的指導(dǎo)。希望本文的內(nèi)容能幫助您更好地理解和應(yīng)用CPU緩存技術(shù)。




























