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