java并發(fā)性能陷阱--偽共享
緩存可以說(shuō)是計(jì)算機(jī)領(lǐng)域最偉大的發(fā)明之一,
經(jīng)常會(huì)有人問(wèn),緩存是越多越好么?
一般人們都會(huì)斬釘截鐵的回答不是。
至于為什么?
往往無(wú)法直覺(jué)回答了,可能會(huì)從緩存一致性,空間占用等幾個(gè)角度逐一分析。
今天就來(lái)看看由于一致性導(dǎo)致的緩存問(wèn)題。
在之前的文章中,我們聊過(guò)JMM java的內(nèi)存模型(一定要有所了解,不太清楚的同學(xué)可以看下前文鏈接http://www.rzrgm.cn/jilodream/p/9452391.html),可以知道線(xiàn)程并不是直接讀寫(xiě)內(nèi)存,而是調(diào)用線(xiàn)程自己的工作空間。
但這只是一個(gè)邏輯模型,線(xiàn)程我們可以理解為cpu的核心,工作空間所對(duì)應(yīng)的位置一般是指cpu的緩存。就像下圖這樣:

目前主流的cpu就是每個(gè)核心有自己的多級(jí)緩存,一般還會(huì)加一個(gè)共享緩存,
越靠近核,緩存越小,但越快,成本也越大。
java線(xiàn)程實(shí)際對(duì)應(yīng)的就是這個(gè)核,工作空間對(duì)應(yīng)的就是這個(gè)緩存。
如果你詳細(xì)思考,就會(huì)考慮到緩存中的數(shù)據(jù)時(shí)如何加載變量的。畢竟變量又長(zhǎng)有短,如何加載定位的?
一般來(lái)說(shuō),我們將內(nèi)存劃分成若干的塊,(防盜連接:本文首發(fā)自http://www.rzrgm.cn/jilodream/ )每一塊是64個(gè)字節(jié)(主流是這個(gè)大?。?/span>
同時(shí)我們將緩存劃分成若干的緩存行,也是64個(gè)字節(jié)。
cpu每次加載時(shí),不是按照某個(gè)變量加載,而是將已經(jīng)劃分好的整塊內(nèi)容直接加載到緩存行中。因?yàn)閺臄?shù)據(jù)的使用經(jīng)驗(yàn)來(lái)看,一般我們?cè)谑褂媚硞€(gè)變量時(shí),很大可能會(huì)使用鄰近變量,這種緩存的預(yù)判加載,提高了緩存的命中率。
有小伙伴會(huì)有疑問(wèn),會(huì)不會(huì)不同核的緩存行加載的數(shù)據(jù)跨了內(nèi)存塊了,也就是A核的緩存行是 xyz變量,B核的緩存行是yza變量。這是不會(huì)的,緩存塊是根據(jù)內(nèi)存的地址和偏移量劃分好的,不會(huì)根據(jù)不同核來(lái)劃分不同的邊界的。

做過(guò)緩存設(shè)計(jì)的同學(xué)肯定知道,在設(shè)計(jì)時(shí)一定要考慮數(shù)據(jù)一致性的問(wèn)題。如果多份緩存以及主存之間的數(shù)據(jù)不一致,就無(wú)法并發(fā)處理,無(wú)法得到準(zhǔn)確的結(jié)果。
(ps,cpu一般是通過(guò)MESI緩存一致性協(xié)議并且配合失效緩存隊(duì)列等等來(lái)實(shí)現(xiàn)的,感興趣的讀者可以查下相關(guān)內(nèi)容)
從前文中的java內(nèi)存模型中可以知道,當(dāng)volatile變量發(fā)生變化時(shí),java通過(guò)內(nèi)存屏障,來(lái)強(qiáng)制失效其它c(diǎn)pu核心中緩存。
但是在真實(shí)情況下,cpu是按照行來(lái)緩存變量的,而不是單個(gè)變量,此時(shí)標(biāo)記失效的就是整個(gè)緩存行。那么就會(huì)出現(xiàn)類(lèi)似一個(gè)情況:
線(xiàn)程1操作變量a,線(xiàn)程2操作變量b,根據(jù)緩存的加載機(jī)制
(1)兩者的均加載同一段緩存行。
(2)當(dāng)線(xiàn)程1 修改完變量a時(shí),通知其它線(xiàn)程失效該緩存行
(3)線(xiàn)程2修改變量b,發(fā)現(xiàn)緩存行失效,重新加載緩存行,修改完變量b后,重新知會(huì)其它線(xiàn)程該行已經(jīng)失效
這樣當(dāng)線(xiàn)程1每次修改變量a,線(xiàn)程2每次修改變量b時(shí),當(dāng)前緩存都不斷的需要重新加載,本質(zhì)上已經(jīng)失去了緩存的意義,還增加了緩存狀態(tài)控制,緩存重新加載的開(kāi)銷(xiāo)。
這種在相同的緩存行的多個(gè)變量,但是由于并發(fā)原因,導(dǎo)致緩存不斷失效,無(wú)法利用緩存讀取變量的場(chǎng)景,我們就稱(chēng)之為偽共享。(False Sharing)

這種情況其實(shí)不僅僅是java,其它語(yǔ)言,甚至是多緩存的業(yè)務(wù),都會(huì)有類(lèi)似的問(wèn)題。
即由于并發(fā)引起的緩存聯(lián)動(dòng)失效,即使對(duì)我當(dāng)前業(yè)務(wù)沒(méi)有實(shí)際影響,但是由于緩存一致性的協(xié)議設(shè)計(jì),我們判斷當(dāng)前緩存已經(jīng)臟了。我們就需要重新加載。緩存的優(yōu)勢(shì)喪失,成本卻被無(wú)限放大。
就像下邊這個(gè)例子:
緩存類(lèi):
1 public class CacheA { 2 volatile int a; 3 4 volatile int b; 5 }
線(xiàn)程類(lèi):
1 public class Main { 2 private static CacheA cache = new CacheA(); 3 private static final int TOTAL = 1000000; 4 5 public static void main(String[] args) { 6 Runnable r1 = new Runnable() { 7 @Override 8 public void run() { 9 long startTime = System.currentTimeMillis(); 10 for (int i = 0; i < TOTAL; i++) { 11 cache.a = (i-99999)*(i+99999); 12 } 13 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 14 long cost = endTime - startTime; // 耗時(shí)(毫秒) 15 16 System.out.println("方法耗時(shí)1: " + cost + " 毫秒"); 17 } 18 }; 19 20 Runnable r2 = new Runnable() { 21 @Override 22 public void run() { 23 long startTime = System.currentTimeMillis(); 24 for (int i = 0; i < TOTAL; i++) { 25 cache.b =(i-99999)*(i+99999); 26 } 27 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 28 long cost = endTime - startTime; // 耗時(shí)(毫秒) 29 30 System.out.println("方法耗時(shí)2: " + cost + " 毫秒"); 31 } 32 }; 33 34 Thread t1=new Thread(r1); 35 t1.start(); 36 37 Thread t2=new Thread(r2); 38 t2.start(); 39 } 40 }
代碼邏輯是兩個(gè)線(xiàn)程并發(fā)修改兩個(gè)變量,這兩個(gè)變量在同一個(gè)實(shí)例里邊。
輸出結(jié)果是這樣的:
Connected to the target VM, address: '127.0.0.1:58237', transport: 'socket' 方法耗時(shí)2: 19 毫秒 方法耗時(shí)1: 22 毫秒 Disconnected from the target VM, address: '127.0.0.1:58237', transport: 'socket' Process finished with exit code 0
我們來(lái)修改代碼,加上很多無(wú)效的變量,重新執(zhí)行,
緩存類(lèi):
1 public class CacheB { 2 volatile int a; 3 long temp1=0; 4 long temp2=0; 5 long temp3=0; 6 long temp4=0; 7 long temp5=0; 8 long temp6=0; 9 long temp7=0; 10 11 volatile int b; 12 }
線(xiàn)程類(lèi):
1 public class Main { 2 private static CacheB cache = new CacheB(); 3 private static final int TOTAL = 1000000; 4 5 public static void main(String[] args) { 6 Runnable r1 = new Runnable() { 7 @Override 8 public void run() { 9 long startTime = System.currentTimeMillis(); 10 for (int i = 0; i < TOTAL; i++) { 11 cache.a = (i-99999)*(i+99999); 12 } 13 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 14 long cost = endTime - startTime; // 耗時(shí)(毫秒) 15 16 System.out.println("方法耗時(shí)1: " + cost + " 毫秒"); 17 } 18 }; 19 20 Runnable r2 = new Runnable() { 21 @Override 22 public void run() { 23 long startTime = System.currentTimeMillis(); 24 for (int i = 0; i < TOTAL; i++) { 25 cache.b =(i-99999)*(i+99999); 26 } 27 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 28 long cost = endTime - startTime; // 耗時(shí)(毫秒) 29 30 System.out.println("方法耗時(shí)2: " + cost + " 毫秒"); 31 } 32 }; 33 34 Thread t1=new Thread(r1); 35 t1.start(); 36 37 Thread t2=new Thread(r2); 38 t2.start(); 39 } 40 }
執(zhí)行結(jié)果如下:
Connected to the target VM, address: '127.0.0.1:58389', transport: 'socket' 方法耗時(shí)1: 10 毫秒 方法耗時(shí)2: 10 毫秒 Disconnected from the target VM, address: '127.0.0.1:58389', transport: 'socket' Process finished with exit code 0
是不是很神奇,我們給一個(gè)對(duì)象加了很多無(wú)用的變量,它居然變快了。而且性能還提升了不少。
這個(gè)優(yōu)化的核心思路就是通過(guò)強(qiáng)制指定內(nèi)存相對(duì)位置,將不相關(guān)的變量強(qiáng)制分配到不同的緩存行上,讓緩存行不會(huì)因?yàn)楫?dāng)前不使用的緩存而被強(qiáng)制失效。
很多人也喜歡這樣子寫(xiě):
private volatile long value; private long p1, p2, p3, p4, p5, p6, p7;
通過(guò)手動(dòng)補(bǔ)齊剩余字節(jié),確保當(dāng)前變量盡可能在一個(gè)緩存行上。
但是這樣子寫(xiě)代碼就很不方便了,(防盜連接:本文首發(fā)自http://www.rzrgm.cn/jilodream/ )我們要增加很多無(wú)意義的字段,或者通過(guò)其它變量穿插起來(lái)。很容易被別人誤改,誤刪,也影響代碼最重要的閱讀性。
因此java在8及以上的版本,增加了一個(gè)注解@Contended
Contended
美[k?n?tend] 英[k?n'tend]
v.競(jìng)爭(zhēng);認(rèn)為;爭(zhēng)奪
這個(gè)注解既可以用在類(lèi)上,也可以用在變量上代碼如下:
緩存類(lèi):
1 import jdk.internal.vm.annotation.Contended; 2 3 /** 4 * @discription 5 */ 6 public class CacheC { 7 @Contended 8 volatile int a; 9 10 @Contended 11 volatile int b; 12 }
線(xiàn)程執(zhí)行類(lèi):
1 public class Main { 2 private static CacheC cache = new CacheC(); 3 private static final int TOTAL = 1000000; 4 5 public static void main(String[] args) { 6 Runnable r1 = new Runnable() { 7 @Override 8 public void run() { 9 long startTime = System.currentTimeMillis(); 10 for (int i = 0; i < TOTAL; i++) { 11 cache.a = (i-99999)*(i+99999); 12 } 13 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 14 long cost = endTime - startTime; // 耗時(shí)(毫秒) 15 16 System.out.println("方法耗時(shí)1: " + cost + " 毫秒"); 17 } 18 }; 19 20 Runnable r2 = new Runnable() { 21 @Override 22 public void run() { 23 long startTime = System.currentTimeMillis(); 24 for (int i = 0; i < TOTAL; i++) { 25 cache.b =(i-99999)*(i+99999); 26 } 27 long endTime = System.currentTimeMillis(); // 結(jié)束時(shí)間(毫秒) 28 long cost = endTime - startTime; // 耗時(shí)(毫秒) 29 30 System.out.println("方法耗時(shí)2: " + cost + " 毫秒"); 31 } 32 }; 33 34 Thread t1=new Thread(r1); 35 t1.start(); 36 37 Thread t2=new Thread(r2); 38 t2.start(); 39 } 40 }
同時(shí)我們要在jdk 啟動(dòng)時(shí)配上虛擬機(jī)參數(shù):
-XX:-RestrictContended
這個(gè)配置參數(shù)表示啟用Contended注解
同時(shí)IDEA等(防盜連接:本文首發(fā)自http://www.rzrgm.cn/jilodream/ )工具還會(huì)提示我們?cè)谂渲弥虚_(kāi)啟編譯選項(xiàng)開(kāi)關(guān),允許代碼訪(fǎng)問(wèn)jdk內(nèi)部/隱藏的api
--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED
使用注解后,執(zhí)行結(jié)果如下
Connected to the target VM, address: '127.0.0.1:56688', transport: 'socket' 方法耗時(shí)2: 11 毫秒 方法耗時(shí)1: 12 毫秒 Disconnected from the target VM, address: '127.0.0.1:56688', transport: 'socket'
和手動(dòng)補(bǔ)齊的速度差不多。
手動(dòng)補(bǔ)齊易于控制,但是影響代碼閱讀,交給虛擬機(jī)自動(dòng)補(bǔ)齊。
通過(guò)注解交給jvm來(lái)強(qiáng)制填充隔離,又因?yàn)槭莾?nèi)部API,不保證穩(wěn)定性,因此大家根據(jù)自己情況來(lái)選用。
如果你覺(jué)得寫(xiě)的不錯(cuò),歡迎轉(zhuǎn)載和點(diǎn)贊。 轉(zhuǎn)載時(shí)請(qǐng)保留作者署名jilodream/王若伊_恩賜解脫(博客鏈接:http://www.rzrgm.cn/jilodream/

浙公網(wǎng)安備 33010602011771號(hào)