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