Java并發(fā)程序刺客之假共享的原理及復(fù)現(xiàn)
前言
前段時(shí)間在各種社交平臺(tái)“雪糕刺客”這個(gè)詞比較火,簡(jiǎn)單的來(lái)說(shuō)就是雪糕的價(jià)格非常高!其實(shí)在并發(fā)程序當(dāng)中也有一個(gè)刺客,如果在寫并發(fā)程序的時(shí)候不注意不小心,這個(gè)刺客很可能會(huì)拖累我們的并發(fā)程序,讓我們并發(fā)程序執(zhí)行的效率變低,讓并發(fā)程序付出很大的代價(jià),這和“雪糕刺客”當(dāng)中的“刺客”的含義是一致的。這個(gè)并發(fā)程序當(dāng)中的刺客就是——假共享(False Sharing)。
假共享(False Sharing)
緩存行
當(dāng)CPU從更慢級(jí)別的緩存讀取數(shù)據(jù)的時(shí)候(三級(jí)Cache會(huì)從內(nèi)存當(dāng)中讀取數(shù)據(jù),二級(jí)緩存會(huì)從三級(jí)緩存當(dāng)中讀取數(shù)據(jù),一級(jí)緩存會(huì)從二級(jí)緩存當(dāng)中讀取數(shù)據(jù),緩存級(jí)別越低執(zhí)行速度越快),CPU并不是一個(gè)字節(jié)一個(gè)字節(jié)的讀取的,而是一次會(huì)讀取一塊數(shù)據(jù),然后將這個(gè)數(shù)據(jù)緩存到CPU當(dāng)中,而這一塊數(shù)據(jù)就叫做緩存行。有一種緩存行的大小就是64字節(jié),那么我們?yōu)槭裁磿?huì)做這種優(yōu)化呢?這是因?yàn)?strong>局部性原理,所謂局部性原理簡(jiǎn)單說(shuō)來(lái)就是,當(dāng)時(shí)使用一個(gè)數(shù)據(jù)的時(shí)候,它附近的數(shù)據(jù)在未來(lái)的一段時(shí)間你也很可能用到,比如說(shuō)我們遍歷數(shù)組,我們通常從前往后進(jìn)行遍歷,比如我們數(shù)組當(dāng)中的數(shù)據(jù)大小是8個(gè)字節(jié),如果我們的緩存行是64個(gè)字節(jié)的話,那么一個(gè)緩存行就可以緩存8個(gè)數(shù)據(jù),那么我們?cè)诒闅v第一個(gè)數(shù)據(jù)的時(shí)候?qū)⑦@8個(gè)數(shù)據(jù)加載進(jìn)入緩存行,那么我們?cè)诒闅v未來(lái)7個(gè)數(shù)據(jù)的時(shí)候都不需要再?gòu)膬?nèi)存當(dāng)中拿數(shù)據(jù),直接從緩存當(dāng)中拿就行,這就可以節(jié)約程序執(zhí)行的時(shí)間。
假共享
當(dāng)兩個(gè)線程在CPU上兩個(gè)不同的核心上執(zhí)行代碼的時(shí)候,如果這兩個(gè)線程使用了同一個(gè)緩存行C,而且對(duì)這個(gè)緩存行當(dāng)中兩個(gè)不同的變量進(jìn)行寫操作,比如線程A對(duì)變量a進(jìn)行寫操作,線程B對(duì)變量b進(jìn)行寫操作。而由于緩存一致性(Cache coherence)協(xié)議的存在,如果其中A線程對(duì)緩存行C中變量a進(jìn)行了寫操作的話,為了保證各個(gè)CPU核心的數(shù)據(jù)一致(也就是說(shuō)兩個(gè)CPU核心看到了a的值是一樣的,因?yàn)閍的值已經(jīng)發(fā)生變化了,需要讓另外的CPU核心知道,不然另外的CPU核心使用的就是舊的值,那么程序結(jié)果就不對(duì)了),其他核心的這個(gè)緩存行就會(huì)失效,如果他還想使用這個(gè)緩存行的話就需要重新三級(jí)Cache加載,如果數(shù)據(jù)不存在三級(jí)Cache當(dāng)中的話,就會(huì)從內(nèi)存當(dāng)中加載,而這個(gè)重新加載的過(guò)程就會(huì)很拖累程序的執(zhí)行效率,而事實(shí)上線程A寫的是變量a,線程B寫的是變量b,他們并沒(méi)有真正的有共享的數(shù)據(jù),只是他們需要的數(shù)據(jù)在同一個(gè)緩存行當(dāng)中,因此稱這種現(xiàn)象叫做假共享(False Sharing)。
上面我們談到了,當(dāng)緩存行失效的時(shí)候會(huì)從三級(jí)Cache或者內(nèi)存當(dāng)中加載,而多個(gè)不同的CPU核心是共享三級(jí)Cache的(上圖當(dāng)中已經(jīng)顯示出來(lái)了),其中一個(gè)CPU核心更新了數(shù)據(jù),會(huì)把數(shù)據(jù)刷新到三級(jí)Cache或者內(nèi)存當(dāng)中,因此這個(gè)時(shí)候其他的CPU核心去加載數(shù)據(jù)的時(shí)候就是新值了。
上面談到的關(guān)于CPU的緩存一致性(Cache coherence)的內(nèi)容還是比較少的,如果你想深入了解緩存一致性(Cache coherence)和緩存一致性協(xié)議可以仔細(xì)去看這篇文章。
我們?cè)賮?lái)舉一個(gè)更加具體的例子:
假設(shè)在內(nèi)存當(dāng)中,變量a和變量b都占四個(gè)字節(jié),而且他們的內(nèi)存地址是連續(xù)且相鄰的,現(xiàn)在有兩個(gè)線程A和B,線程A要不斷的對(duì)變量a進(jìn)行+1操作,線程B需要不斷的對(duì)變量進(jìn)行+1操作,現(xiàn)在這個(gè)兩個(gè)數(shù)據(jù)所在的緩存行已經(jīng)被緩存到三級(jí)緩存了。
- 線程A從三級(jí)緩存當(dāng)中將數(shù)據(jù)加載到二級(jí)緩存和一級(jí)緩存然后在CPU- Core0當(dāng)中執(zhí)行代碼,線程B從三級(jí)緩存將數(shù)據(jù)加載到二級(jí)緩存和一級(jí)緩存然后在CPU- Core1當(dāng)中執(zhí)行代碼。
- 線程A不斷的執(zhí)行a += 1,因?yàn)榫€程B緩存的緩存行當(dāng)中包含數(shù)據(jù)a,線程A在修改a的值之后,就會(huì)在總線上發(fā)送消息,讓其他處理器當(dāng)中含有變量a的緩存行失效,在處理器將緩存行失效之后,就會(huì)在總線上發(fā)送消息,表示緩存行已經(jīng)失效,線程A所在的CPU- Core0收到消息之后將更新后的數(shù)據(jù)刷新到三級(jí)Cache。
- 這個(gè)時(shí)候線程B所在的CPU-Core1當(dāng)中含有a的緩存行已經(jīng)失效,因?yàn)樽兞縝和變量a在同一個(gè)緩存行,現(xiàn)在線程B想對(duì)變量b進(jìn)行加一操作,但是在一級(jí)和二級(jí)緩存當(dāng)中已經(jīng)沒(méi)有了,它需要三級(jí)緩存當(dāng)中加載這個(gè)緩存行,如果三級(jí)緩存當(dāng)中沒(méi)有就需要去內(nèi)存當(dāng)中加載。
- 仔細(xì)分析上面的過(guò)程你就會(huì)發(fā)現(xiàn)線程B并沒(méi)有對(duì)變量a有什么操作,但是它需要的緩存行就失效了,雖然和線程B共享需要同一個(gè)內(nèi)容的緩存行,但是他們之間并沒(méi)有真正共享數(shù)據(jù),所以這種現(xiàn)象叫做假共享。
Java代碼復(fù)現(xiàn)假共享
復(fù)現(xiàn)假共享
下面是兩個(gè)線程不斷對(duì)兩個(gè)變量執(zhí)行++操作的代碼:
class Data { public volatile long a; public volatile long b; } public class FalseSharing { public static void main(String[] args) throws InterruptedException { Data data = new Data(); long start = System.currentTimeMillis(); Thread A = new Thread(() -> { for (int i = 0; i < 500_000_000; i++) { data.a += 1; } }, "A"); Thread B = new Thread(() -> { for (int i = 0; i < 500_000_000; i++) { data.b += 1; } }, "B"); A.start(); B.start(); A.join(); B.join(); long end = System.currentTimeMillis(); System.out.println("花費(fèi)時(shí)間為:" + (end - start)); System.out.println(data.a); System.out.println(data.b); } }
上面的代碼比較簡(jiǎn)單,這里就不進(jìn)行說(shuō)明了,上面的代碼在我的筆記本上的執(zhí)行時(shí)間大約是17秒。
上面的代碼變量a和變量b在內(nèi)存當(dāng)中的位置是相鄰的,他們?cè)诒籆PU加載之后會(huì)在同一個(gè)緩存行當(dāng)中,因此會(huì)存在假共享的問(wèn)題,程序的執(zhí)行時(shí)間會(huì)變長(zhǎng)。
下面的代碼是優(yōu)化過(guò)后的代碼,在變量a前面和后面分別加入56個(gè)字節(jié)的數(shù)據(jù),再加上a的8個(gè)字節(jié)(long類型是8個(gè)字節(jié)),這樣a前后加上a的數(shù)據(jù)有64個(gè)字節(jié),而現(xiàn)在主流的緩存行是64個(gè)字節(jié),夠一個(gè)緩存行的大小,因?yàn)閿?shù)據(jù)a和數(shù)據(jù)b就不會(huì)在同一個(gè)緩存行當(dāng)中,因此就不會(huì)存在假共享的問(wèn)題了。而下面的代碼在我筆記本當(dāng)中執(zhí)行的時(shí)間大約為5秒。這就足以看出假共享會(huì)對(duì)程序的執(zhí)行帶來(lái)多大影響了。
class Data { public volatile long a1, a2, a3, a4, a5, a6, a7; public volatile long a; public volatile long b1, b2, b3, b4, b5, b6, b7; public volatile long b; } public class FalseSharing { public static void main(String[] args) throws InterruptedException { Data data = new Data(); long start = System.currentTimeMillis(); Thread A = new Thread(() -> { for (int i = 0; i < 500_000_000; i++) { data.a += 1; } }, "A"); Thread B = new Thread(() -> { for (int i = 0; i < 500_000_000; i++) { data.b += 1; } }, "B"); A.start(); B.start(); A.join(); B.join(); long end = System.currentTimeMillis(); System.out.println("花費(fèi)時(shí)間為:" + (end - start)); System.out.println(data.a); System.out.println(data.b); } }
JDK解決假共享
為了解決假共享的問(wèn)題,JDK為我們提供了一個(gè)注解@Contened
解決假共享的問(wèn)題。
import sun.misc.Contended; class Data { // public volatile long a1, a2, a3, a4, a5, a6, a7; @Contended public volatile long a; // public volatile long b1, b2, b3, b4, b5, b6, b7; @Contended public volatile long b; } public class FalseSharing { public static void main(String[] args) throws InterruptedException { Data data = new Data(); long start = System.currentTimeMillis(); Thread A = new Thread(() -> { for (long i = 0; i < 500_000_000; i++) { data.a += 1; } }, "A"); Thread B = new Thread(() -> { for (long i = 0; i < 500_000_000; i++) { data.b += 1; } }, "B"); A.start(); B.start(); A.join(); B.join(); long end = System.currentTimeMillis(); System.out.println("花費(fèi)時(shí)間為:" + (end - start)); System.out.println(data.a); System.out.println(data.b); } }
上面代碼的執(zhí)行時(shí)間也是5秒左右,和之前我們自己在變量的左右兩邊插入變量的效果是一樣的,但是JDK提供的這個(gè)接口和我們自己實(shí)現(xiàn)的還是有所區(qū)別的。(注意:上面的代碼是在JDK1.8下執(zhí)行的,如果要想@Contended
注解生效,你還需要在JVM參數(shù)上加入-XX:-RestrictContended
,這樣上面的代碼才能生效否則是不能夠生效的)
- 在我們自己解決假共享的代碼當(dāng)中,是在變量
a
的左右兩邊加入56個(gè)字節(jié)的其他變量,讓他和變量b
不在同一個(gè)緩存行當(dāng)中。 - 在JDK給我們提供的注解
@Contended
,是在被加注解的字段的右邊加入一定數(shù)量的空字節(jié),默認(rèn)加入128空字節(jié),那么變量a
和變量b
之間的內(nèi)存地址大一點(diǎn),最終不在同一個(gè)緩存行當(dāng)中。這個(gè)字節(jié)數(shù)量可以使用JVM參數(shù)-XX:ContendedPaddingWidth=64
,進(jìn)行控制,比如這個(gè)是64個(gè)字節(jié)。 - 除此之外
@Contended
注解還能夠?qū)⒆兞窟M(jìn)行分組:
class Data { @Contended("a") public volatile long a; @Contended("bc") public volatile long b; @Contended("bc") public volatile long c; }
在解析注解的時(shí)候會(huì)讓同一組的變量在內(nèi)存當(dāng)中的位置相鄰,不同的組之間會(huì)有一定數(shù)量的空字節(jié),配置方式還是跟上面一樣,默認(rèn)每組之間空字節(jié)的數(shù)量為128。
比如上面的變量在內(nèi)存當(dāng)中的邏輯布局詳細(xì)布局如下:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 20 0a 06 00 (00100000 00001010 00000110 00000000) (395808)
12 132 (alignment/padding gap)
144 8 long Data.a 0
152 128 (alignment/padding gap)
280 8 long Data.b 0
288 8 long Data.c 0
296 128 (loss due to the next object alignment)
Instance size: 424 bytes
Space losses: 260 bytes internal + 128 bytes external = 388 bytes total
上面的內(nèi)容是通過(guò)下面代碼打印的,你只要在pom文件當(dāng)中引入包jol
即可:
從更低層次C語(yǔ)言看假共享
前面我們是使用Java語(yǔ)言去驗(yàn)證假共享,在本小節(jié)當(dāng)中我們通過(guò)一個(gè)C語(yǔ)言的多線程程序(使用pthread)去驗(yàn)證假共享。(下面的代碼在類Unix系統(tǒng)都可以執(zhí)行)
#include <stdio.h> #include <pthread.h> #include <time.h> #define CHOOSE // 這里定義了 CHOOSE 如果不想定義CHOOSE 則將這一行注釋掉即可 // 定義一個(gè)全局變量 int data[1000]; void* add(void* flag) { // 這個(gè)函數(shù)的作用就是不斷的往 data 當(dāng)中的某個(gè)數(shù)據(jù)進(jìn)行加一操作 int idx = *((int *)flag); for (long i = 0; i < 10000000000; ++i) { data[idx]++; } } int main() { pthread_t a, b; #ifdef CHOOSE // 如果定義了 CHOOSE 則執(zhí)行下面的代碼 讓兩個(gè)線程操作的變量隔得遠(yuǎn)一點(diǎn) 讓他們不在同一個(gè)緩存行當(dāng)中 int flag_a = 0; int flag_b = 100; printf("遠(yuǎn)離\n"); #else // 如果沒(méi)有定義 讓他們隔得近一點(diǎn) 也就是說(shuō)讓他們?cè)谕粋€(gè)緩存行當(dāng)中 int flag_a = 0; int flag_b = 1; printf("臨近\n"); #endif pthread_create(&a, NULL, add, &flag_a); // 創(chuàng)建線程a 執(zhí)行函數(shù) add 傳遞參數(shù) flag_a 并且啟動(dòng) pthread_create(&b, NULL, add, &flag_b); // 創(chuàng)建線程b 執(zhí)行函數(shù) add 傳遞參數(shù) flag_b 并且啟動(dòng) long start = time(NULL); pthread_join(a, NULL); // 主線程等待線程a執(zhí)行完成 pthread_join(b, NULL); // 主線程等待線程b執(zhí)行完成 long end = time(NULL); printf("data[0] = %d\t data[1] = %d\n", data[0], data[1]); printf("cost time = %ld\n", (end - start)); return 0; }
上面代碼的輸出結(jié)果如下圖所示:
我們首先來(lái)解釋一下上面time
命令的輸出:
readl
:這個(gè)表示真實(shí)世界當(dāng)中的墻鐘時(shí)間,就是表示這個(gè)程序執(zhí)行所花費(fèi)的時(shí)間,這個(gè)秒單位和我們平常說(shuō)的秒是一樣的。user
:這個(gè)表示程序在用戶態(tài)執(zhí)行的CPU時(shí)間,CPU時(shí)間和真實(shí)時(shí)間是不一樣的,這里需要注意區(qū)分,這里的秒和我們平常的秒是不一樣的。sys
:這個(gè)表示程序在內(nèi)核態(tài)執(zhí)行所花費(fèi)的CPU時(shí)間。
從上面程序的輸出結(jié)果我們可以很明顯的看出來(lái)當(dāng)操作的兩個(gè)整型變量相隔距離遠(yuǎn)的時(shí)候,也就是不在同一個(gè)緩存行的時(shí)候,程序執(zhí)行的速度是比數(shù)據(jù)隔得近在同一個(gè)緩存行的時(shí)候快得多,這也從側(cè)面顯示了假共享很大程度的降低了程序執(zhí)行的效率。
總結(jié)
在本篇文章當(dāng)中主要討論了以下內(nèi)容:
- 當(dāng)多個(gè)線程操作同一個(gè)緩存行當(dāng)中的多個(gè)不同的變量時(shí),雖然他們事實(shí)上沒(méi)有對(duì)數(shù)據(jù)進(jìn)行共享,但是他們對(duì)同一個(gè)緩存行當(dāng)中的數(shù)據(jù)進(jìn)行修改,而由于緩存一致性協(xié)議的存在會(huì)導(dǎo)致程序執(zhí)行的效率降低,這種現(xiàn)象叫做假共享。
- 在Java程序當(dāng)中我們?nèi)绻胱尪鄠€(gè)變量不在同一個(gè)緩存行當(dāng)中的話,我們可以在變量的旁邊通過(guò)增加其他變量的方式讓多個(gè)不同的變量不在同一個(gè)緩存行。
- JDK也為我們提供了
Contended
注解可以在字段的后面通過(guò)增加空字節(jié)的方式讓多個(gè)數(shù)據(jù)不在同一個(gè)緩存行,而且你需要在JVM參數(shù)當(dāng)中加入-XX:-RestrictContended
,同時(shí)你可以通過(guò)JVM參數(shù)-XX:ContendedPaddingWidth=64
調(diào)整空字節(jié)的數(shù)目。JDK8之后注解Contended
在JDK當(dāng)中的位置有所變化,大家可以查詢一下。 - 我們也是用了C語(yǔ)言的API去測(cè)試了假共享,事實(shí)上在Java虛擬機(jī)當(dāng)中底層的線程也是通過(guò)調(diào)用
pthread_create
進(jìn)行創(chuàng)建的。
到此這篇關(guān)于Java并發(fā)程序刺客之假共享的原理及復(fù)現(xiàn)的文章就介紹到這了,更多相關(guān)Java并發(fā) 假共享內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringBoot中整合MyBatis-Plus的方法示例
這篇文章主要介紹了SpringBoot中整合MyBatis-Plus的方法示例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09關(guān)于@Scheduled參數(shù)及cron表達(dá)式解釋
這篇文章主要介紹了關(guān)于@Scheduled參數(shù)及cron表達(dá)式解釋,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-12-12解決Mybatis中mapper.xml文件update,delete及insert返回值問(wèn)題
這篇文章主要介紹了解決Mybatis中mapper.xml文件update,delete及insert返回值問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-11-11舉例講解Java的RTTI運(yùn)行時(shí)類型識(shí)別機(jī)制
這篇文章主要介紹了Java的RTTI運(yùn)行時(shí)類型識(shí)別機(jī)制,包括泛化的Class引用以及類型檢查instanceof等知識(shí)點(diǎn),需要的朋友可以參考下2016-05-05SpringBoot中配置Web靜態(tài)資源路徑的方法
這篇文章主要介紹了SpringBoot中配置Web靜態(tài)資源路徑的方法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-09-09java數(shù)據(jù)結(jié)構(gòu)與算法之簡(jiǎn)單選擇排序詳解
這篇文章主要介紹了java數(shù)據(jù)結(jié)構(gòu)與算法之簡(jiǎn)單選擇排序,結(jié)合實(shí)例形式分析了選擇排序的原理、實(shí)現(xiàn)方法與相關(guān)操作技巧,需要的朋友可以參考下2017-05-05Java將網(wǎng)絡(luò)圖片轉(zhuǎn)成輸入流以及將url轉(zhuǎn)成InputStream問(wèn)題
這篇文章主要介紹了Java將網(wǎng)絡(luò)圖片轉(zhuǎn)成輸入流以及將url轉(zhuǎn)成InputStream問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-01-01