一、問(wèn)題引入
由一個(gè)案例引進(jìn)拉讯,先上代碼
public class NoCacheLinePadding{
private volatile long cacheLine1 = 1L;
private volatile long cacheLine2 = 1L;
public void setCacheLine1(long cacheLine1) {
this.cacheLine1 = cacheLine1;
}
public void setCacheLine2(long cacheLine2) {
this.cacheLine2 = cacheLine2;
}
public long getCacheLine1() {
return cacheLine1;
}
public long getCacheLine2() {
return cacheLine2;
}
}
void testNoCacheLine() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(2);
NoCacheLinePadding noCacheLinePadding = new NoCacheLinePadding();
Thread a = new Thread(() -> {
for (int i=0;i<100000000;i++) {
noCacheLinePadding.setCacheLine1(i);
}
countDownLatch.countDown();
});
Thread b = new Thread(() -> {
for (int i=0;i<100000000;i++) {
noCacheLinePadding.setCacheLine2(i);
}
countDownLatch.countDown();
});
long start = System.nanoTime();
a.start();
b.start();
countDownLatch.await();
long end = System.nanoTime();
System.out.println("cache1= "+noCacheLinePadding.getCacheLine1()+",cache2="+noCacheLinePadding.getCacheLine2()+",耗時(shí): "+ (end - start)/ 1000000 +"毫秒" );
}
上面是一個(gè) 由兩個(gè)線程分別去循環(huán)1億次去修改一個(gè)對(duì)象中兩個(gè)不同屬性的測(cè)試用例。
測(cè)試結(jié)果:cache1= 99999999,cache2=99999999,耗時(shí): 461毫秒
/**
* @author zhangxiaohu
* @descript p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15 是緩存行填充,解決cpu緩存?zhèn)喂蚕韱?wèn)題
* @date 2023-4-11
*/
public class CacheLinePadding{
private volatile long p0,p1,p2,p3,p4,p5,p6;
private volatile long cacheLine1 = 1L;
private volatile long p9,p10,p11,p12,p13,p14,p15;
private volatile long cacheLine2 = 1L;
public void setCacheLine1(long cacheLine1) {
this.cacheLine1 = cacheLine1;
}
public void setCacheLine2(long cacheLine2) {
this.cacheLine2 = cacheLine2;
}
public long getCacheLine1() {
return cacheLine1;
}
public long getCacheLine2() {
return cacheLine2;
}
}
void testCacheLine() throws Exception{
CountDownLatch countDownLatch = new CountDownLatch(2);
CacheLinePadding cacheLinePadding = new CacheLinePadding();
Thread a = new Thread(() -> {
for (int i=0;i<100000000;i++) {
cacheLinePadding.setCacheLine1(i);
}
countDownLatch.countDown();
});
Thread b = new Thread(() -> {
for (int i=0;i<100000000;i++) {
cacheLinePadding.setCacheLine2(i);
}
countDownLatch.countDown();
});
long start = System.nanoTime();
a.start();
b.start();
countDownLatch.await();
long end = System.nanoTime();
System.out.println("cache1= "+cacheLinePadding.getCacheLine1()+",cache2="+cacheLinePadding.getCacheLine2()+",耗時(shí): "+ (end - start)/ 1000000 +"毫秒" );
}
這個(gè)測(cè)試用例和第一個(gè)基本一致,不同的是我們?cè)趯?duì)象中加了一些看似多余的屬性p0,p1,p2,p3,p4,p5,p6 p9,p10,p11,p12,p13,p14,p15(下文會(huì)解釋為什么要這么做)
測(cè)試結(jié)果:cache1= 99999999,cache2=99999999,耗時(shí): 109毫秒
問(wèn)題:第二個(gè)測(cè)試用例只是多加入了一些多余的屬性降淮,為什么時(shí)間消耗 和 第一個(gè)測(cè)試用例的時(shí)間消耗 差距將近 5 倍?
為了搞清楚cpu緩存?zhèn)喂蚕淼母拍畈龋旅嫖覀儗囊韵聨讉€(gè)方面來(lái)解釋佳鳖。
- cpu緩存結(jié)構(gòu)
- cpu緩存?zhèn)喂蚕硎窃趺串a(chǎn)生的
- 如何解決cpu緩存?zhèn)喂蚕淼膯?wèn)題
2.1、cpu緩存結(jié)構(gòu)
隨著計(jì)算機(jī)的發(fā)展媒惕,CPU 和內(nèi)存的訪問(wèn)性能相差越來(lái)越?系吩,于是就在 CPU 內(nèi)部嵌?了 CPUCache(?速緩存),CPU Cache 離 CPU 核?相當(dāng)近妒蔚,因此它的訪問(wèn)速度是很快的穿挨,于是它充當(dāng)了 CPU 與內(nèi)存之間的緩存??月弛。CPU Cache 通常分為三級(jí)緩存:L1 Cache、L2 Cache科盛、L3 Cache帽衙,級(jí)別越低的離 CPU 核?越近,訪問(wèn)速度也快贞绵,但是存儲(chǔ)容量相對(duì)就會(huì)越?厉萝。其中,在多核?的 CPU ?但壮,每個(gè)核?
都有各?的 L1/L2 Cache冀泻,? L3 Cache 是所有核?共享使?的。從上圖也可以看到蜡饵,從上往下弹渔,存儲(chǔ)設(shè)備的容量會(huì)越?,?訪問(wèn)速度會(huì)越慢溯祸≈ǎ可以看到, CPU 訪問(wèn) L1 Cache 速度?訪問(wèn)內(nèi)存快 100 倍焦辅,這就是為什么 CPU ?會(huì)有L1~L3 Cache 的原因博杖,?的就是把 Cache 作為 CPU 與內(nèi)存之間的緩存層,以減少對(duì)內(nèi)存的訪問(wèn)頻率筷登。
CPU 從內(nèi)存中讀取數(shù)據(jù)到 Cache 的時(shí)候剃根,并不是?個(gè)字節(jié)?個(gè)字節(jié)讀取,?是?塊?塊的?式來(lái)讀取數(shù)據(jù)的前方,這?塊?塊的數(shù)據(jù)被稱為 CPU Line(緩存?)狈醉,所以 CPU Line 是 CPU從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位?于 CPU Line ??,在 Linux 系統(tǒng)可以?下?的?式查看到惠险,大部分服務(wù)器的Cache Line ??是 64 字節(jié)苗傅,也就意味著 L1 Cache ?次載?數(shù)據(jù)的??是 64 字節(jié)
那么對(duì)數(shù)組的加載, CPU 就會(huì)加載數(shù)組??連續(xù)的多個(gè)數(shù)據(jù)到 Cache ?班巩,因此我們應(yīng)該按照物理內(nèi)存地址分布的順序去訪問(wèn)元素渣慕,這樣訪問(wèn)數(shù)組元素的時(shí)候,Cache 命中率就會(huì)很?抱慌,于是就能減少?gòu)膬?nèi)存讀取數(shù)據(jù)的頻率逊桦, 從?可提?程序的性能,但是在我們不使?數(shù)組抑进,?是使?單獨(dú)的變量的時(shí)候卫袒,則會(huì)有 Cache 偽共享的問(wèn)題,Cache 偽共享問(wèn)題上是?個(gè)性能殺?单匣,我們應(yīng)該要規(guī)避它夕凝。接下來(lái)宝穗,就來(lái)看看 Cache 偽共享是什么??如何避免這個(gè)問(wèn)題码秉。
2.2涩搓、cpu緩存?zhèn)喂蚕硎侨绾萎a(chǎn)生的
還是以我們文章開頭的代碼舉例現(xiàn)在假設(shè)有?個(gè)雙核?的 CPU厘肮,這兩個(gè) CPU 核?并?運(yùn)?著兩個(gè)不同的線程电爹,它們同時(shí)從內(nèi)存中讀取兩個(gè)不同的數(shù)據(jù)诞外,分別是類型為 long 的變量 A(代碼中的cacheline1) 和 B(代碼中的cacheline2),這個(gè)兩個(gè)數(shù)據(jù)的地址在物理內(nèi)存上是連續(xù)的府蔗,如果 Cahce Line 的??是 64 字節(jié)晋控,并且變量 A 在 Cahce Line 的開頭位置,那么這兩個(gè)數(shù)據(jù)是位于同?個(gè) Cache Line 中姓赤,?因?yàn)?CPU Line 是 CPU 從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位赡译,所以這兩個(gè)數(shù)據(jù)會(huì)被同時(shí)讀?到了兩個(gè) CPU 核?中各? Cache 中
我們來(lái)思考?個(gè)問(wèn)題,如果這兩個(gè)不同核?的線程分別修改不同的數(shù)據(jù)不铆,?如 1 號(hào) CPU 核?的線程只修改了 變量 A蝌焚,或 2 號(hào) CPU 核?的線程的線程只修改了變量 B,會(huì)發(fā)?什么呢
①. 最開始變量 A 和 B 都還不在 Cache ??誓斥,假設(shè) 1 號(hào)核?綁定了線程 A只洒,2 號(hào)核?綁定了線程 B,線程 A 只會(huì)讀寫變量 A劳坑,線程 B 只會(huì)讀寫變量 B
②. 1 號(hào)核?讀取變量 A毕谴,由于 CPU 從內(nèi)存讀取數(shù)據(jù)到 Cache 的單位是 Cache Line,也正好變量 A 和 變量 B 的數(shù)據(jù)歸屬于同?個(gè) Cache Line距芬,所以 A 和 B 的數(shù)據(jù)都會(huì)被加載到Cache涝开,并將此 Cache Line 標(biāo)記為「獨(dú)占」?fàn)顟B(tài)
③. 接著,2 號(hào)核?開始從內(nèi)存?讀取變量 B蔑穴,同樣的也是讀取 Cache Line ??的數(shù)據(jù)到Cache 中,此 Cache Line 中的數(shù)據(jù)也包含了變量 A 和 變量 B惧浴,此時(shí) 1 號(hào)和 2 號(hào)核?的Cache Line 狀態(tài)變?yōu)椤腹蚕頎顟B(tài)
④. 1 號(hào)核?需要修改變量 A存和,發(fā)現(xiàn)此 Cache Line 的狀態(tài)是「共享」?fàn)顟B(tài),所以先需要通過(guò)總線發(fā)送消息給 2 號(hào)核?衷旅,通知 2 號(hào)核?把 Cache 中對(duì)應(yīng)的 Cache Line 標(biāo)記為「已失效」?fàn)顟B(tài)捐腿,然后 1 號(hào)核?對(duì)應(yīng)的 Cache Line 狀態(tài)變成「已修改」?fàn)顟B(tài),并且修改變量 A
⑤. 之后柿顶,2 號(hào)核?需要修改變量 B茄袖,此時(shí) 2 號(hào)核?的 Cache 中對(duì)應(yīng)的 Cache Line 是已失效狀態(tài),另外由于 1 號(hào)核?的 Cache 也有此相同的數(shù)據(jù)嘁锯,且狀態(tài)為「已修改」?fàn)顟B(tài)宪祥,所以要先把 1 號(hào)核?的 Cache 對(duì)應(yīng)的 Cache Line 寫回到內(nèi)存聂薪,然后 2 號(hào)核?再?gòu)膬?nèi)存讀取 CacheLine ??的數(shù)據(jù)到 Cache 中,最后把變量 B 修改到 2 號(hào)核?的 Cache 中蝗羊,并將狀態(tài)標(biāo)記為「已修改」?fàn)顟B(tài)藏澳。
所以,可以發(fā)現(xiàn)如果 1 號(hào)和 2 號(hào) CPU 核?這樣持續(xù)交替的分別修改變量 A 和 B耀找,就會(huì)重復(fù)④ 和 ⑤ 這兩個(gè)步驟翔悠,Cache 并沒(méi)有起到緩存的效果,雖然變量 A 和 B 之間其實(shí)并沒(méi)有任何的關(guān)系野芒,但是因?yàn)橥瑫r(shí)歸屬于?個(gè) Cache Line 蓄愁,這個(gè) Cache Line 中的任意數(shù)據(jù)被修改后,都會(huì)相互影響狞悲,從?出現(xiàn) ④ 和 ⑤ 這兩個(gè)步驟因此撮抓,這種因?yàn)槎鄠€(gè)線程同時(shí)讀寫同?個(gè) Cache Line 的不同變量時(shí),?導(dǎo)致 CPU Cache 失
效的現(xiàn)象稱為偽共享(False Sharing)
2.3效诅、避免偽共享的方法
因此胀滚,對(duì)于多個(gè)線程共享的熱點(diǎn)數(shù)據(jù),即經(jīng)常會(huì)修改的數(shù)據(jù)乱投,應(yīng)該避免這些數(shù)據(jù)剛好在同?個(gè) Cache Line 中咽笼,否則就會(huì)出現(xiàn)為偽共享的問(wèn)題。接下來(lái)戚炫,看看在實(shí)際項(xiàng)?中是?什么?式來(lái)避免偽共享的問(wèn)題的剑刑。在 Linux 內(nèi)核中存在 __cacheline_aligned_in_smp 宏定義,是?于解決偽共享的問(wèn)題我們可以使?上?介紹的宏定義双肤,將 b 的地址設(shè)置為 Cache Line 對(duì)?地址施掏,如下
所以,避免 Cache 偽共享實(shí)際上是?空間換時(shí)間的思想茅糜,浪費(fèi)?部分 Cache 空間七芭,從?換來(lái)性能的提升。
再回到我們文章開頭中的引入案例中蔑赘,就可以明白狸驳,為什么我們?cè)诘诙€(gè)測(cè)試案例中,性能會(huì)提升這么多缩赛。 其實(shí)就是因?yàn)槲覀冊(cè)赾acheline1前p0-p6,在cacheline2中也定義了p9~p15的變量耙箍。按照一個(gè)cacheline 64字節(jié)來(lái)計(jì)算,long類型的p0~p6 再加上cacheline1 剛好64字節(jié)酥馍,p9~p15加上cacheline2 也是64字節(jié)辩昆,這樣就可以保證這 cacheline1 和 cacheline2 一定是分布在 不同的cacheLine 中。
其實(shí)旨袒,拋開本文的案例汁针,我們依舊可以在一些開源的代碼中找到這樣的處理方法术辐,比如在 jdk1.8 版本中的高并發(fā)累加器LongAdder中,內(nèi)部cell元素就用了@sun.misc.Contended扇丛,這個(gè)注解原理就是通過(guò)繼承的方式來(lái)填充屬性术吗,保證多個(gè)變量分布在不同的cacheline中。此外帆精,在超高并發(fā)的Disruptor內(nèi)存隊(duì)列中 中有?個(gè) RingBuffer较屿,也是通過(guò)字節(jié)填充 + 繼承的?式,來(lái)避免偽共享的問(wèn)題卓练,有興趣的讀者可以自行翻閱源碼隘蝎。
3、總結(jié)
在實(shí)際生產(chǎn)項(xiàng)目中襟企,我們很難通過(guò)工具嗅探到cpu緩存?zhèn)喂蚕淼陌l(fā)生嘱么。但是該現(xiàn)象發(fā)生有一個(gè)共同特點(diǎn),就是多個(gè)線程共享的熱點(diǎn)數(shù)據(jù)顽悼,即經(jīng)常會(huì)修改的數(shù)據(jù)曼振,應(yīng)該避免這些數(shù)據(jù)剛好在同?個(gè) Cache Line 中。