深刻理解JAVA併發中的有序性問題和解決之道
歡迎關注專欄【JAVA併發】
更多技術幹活盡在個人公眾號——JAVA旭陽
問題
Java併發情況下總是會遇到各種意向不到的問題,比如下面的程式碼:
int num = 0;
boolean ready = false;
// 執行緒1 執行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 執行緒2 執行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
- 執行緒1中如果發現
ready=true
num + num
,否則等於1,然後將結果儲存到I_Result
物件中 - 執行緒2中先修改
num=2
,然後設定ready=true
那大家覺得I_Result
中的r1值
可能是多少呢?
- r1值等於4, 這個大家都能想到, CPU先執行了執行緒2,然後執行執行緒1
- r1值等於1,這個也容易理解,CPU先執行了執行緒1,然後執行執行緒2
- 那我如果說r1值有可能等於0,大家可能覺得離譜,不信的話,我們驗證下。
壓測驗證結果
由於併發問題出現的概率比較低,我們可以使用openjdk
提供的jcstress
框架進行壓測,就能夠出現各種可能的情況。
jcstress:全名The Java Concurrency Stress tests,是一個實驗工具和一套測試工具,用於幫助研究JVM、類庫和硬體中併發支援的正確性。詳細使用可以參考文章:
https://www.cnblogs.com/wwjj4811/p/14310930.html
- 生成壓測工程
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=com.alvin -DartifactId=juc-order -Dversion=1.0
生成的工程程式碼如下圖:
- 填充測試內容
- 方法
actor1
I_Result
中。 - 方法
actor2
是壓測第二個執行緒乾的活 - 類前面的
@Outcome
註解用來展示驗證結果,特別是id="0"
這個是我們感興趣的結果
- 執行壓測工程
mvn clean install
java -jar target/jcstress.jar
- 檢視執行結果
執行結果如下圖所示:
- 有4000多次出現了0的結果
- 大部分情況的結果還是1和4
你是不是還是很困惑,其實這就是併發執行的一些坑,我們下面來解釋下原因。
原因分析
如果先要出現r1的值等於0
,那麼有一個可能0+0=0
,那麼也就是num=0
。
你可能想num怎麼可能等於0,程式碼邏輯明明是先設定num=2
,然後才修改ready=true
, 最後才會走到num+num
的邏輯啊....
在併發的世界裡,我們千萬不要被固有的思維限制了,那是不是有可能num=2
和ready=true
的執行順序發生了變化呢。如果你想到這裡,也基本接近真相了。
原因: JAVA中在指令不存在依賴的情況下,會進行順序的調整,這種現象叫做指令重排序,是 JIT 編譯器在執行時的一些優化。這也是為什麼出現0的根本原因。
指令重排不會影響單執行緒執行的結果,但是在多執行緒的情況下,會有個可能出現問題。
理解指令重排序
前面提到出現問題的原因是因為指令重排序,你可能還是不大理解指令重排序究竟是什麼,以及它的作用,那我這邊用一個魚罐頭的故事帶大家理解下。
我們可以把工人當做CPU,魚當做指令,工人加工一條魚需要 50 分鐘,如果一條魚、一條魚順序加工,這樣是不是比較慢?
沒辦法得優化下,不然要喝西北風了,發現每個魚罐頭的加工流程有 5 個步驟:
- 去鱗清洗 10分鐘
- 蒸煮瀝水 10分鐘
- 加註湯料 10分鐘
- 殺菌出鍋 10分鐘
- 真空封罐 10分鐘
每個步驟中也是用到不同的工具,那能否可以並行呢?如下圖所示:
我們發現中間用很多步驟是並行做的,大大的提高了效率。但是在並行加工魚的過程中,就會出現順序的調整,比如先做第二條的魚的某個步驟,然後在做第一條魚的步驟。
現代 CPU 支援多級指令流水線,幾乎所有的馮•諾伊曼型計算機的 CPU,其工作都可以分為 5 個階段:取指令、指令譯碼、執行指令、訪存取數和結果寫回,可以稱之為五級指令流水線。CPU 可以在一個時鐘週期內,同時執行五條指令的不同階段(每個執行緒不同的階段),本質上流水線技術並不能縮短單條指令的執行時間,但變相地提高了指令地吞吐率。
處理器在進行重排序時,必須要考慮指令之間的資料依賴性
- 單執行緒環境也存在指令重排,由於存在依賴性,最終執行結果和程式碼順序的結果一致
- 多執行緒環境中執行緒交替執行,由於編譯器優化重排,會獲取其他執行緒處在不同階段的指令同時執行
volatile關鍵字
那麼對於上面的問題,如何解決呢?
使用volatile關鍵字。
volatile
的底層實現原理是記憶體屏障,Memory Barrier(Memory Fence)
- 對
volatile
變數的寫指令後會加入寫屏障 - 對
volatile
變數的讀指令前會加入讀屏障
記憶體屏障本質上是一個CPU指令,形象點理解就是一個柵欄,攔在那裡,無法跨越。
記憶體屏障分為寫屏障和讀屏障,有什麼有呢?
- 保證可見性
- 寫屏障保證在該屏障之前的,對共享變數的改動,都同步到主存當中
- 讀屏障保證在該屏障之後,對共享變數的讀取,載入的是主存中最新資料
- 保證有序性
- 寫屏障會確保指令重排序時,不會將寫屏障之前的程式碼排在寫屏障之後
- 讀屏障會確保指令重排序時,不會將讀屏障之後的程式碼排在讀屏障之前
回到前面的問題,如果對ready
加了volatile
以後,那麼num=2就無法到後面去了,同樣讀取也是,如上圖所示。
final底層也是通過記憶體屏障實現的,它與volatile一樣。
- 對final變數的寫指令加入寫屏障。也就是類初始化的賦值的時候會加上寫屏障。
- 對final變數的讀指令加入讀屏障。載入記憶體中final變數的最新值。
總結
JAVA併發中的有序性問題其實比較難理解,本文通過一個例子驗證了併發情況下會出現有序性的問題,從而引發意想不到的結果。這個主要的原因是為了提高效能,指令會發生重排序導致的。為了解決這樣的問題,我們可以使用volatile
這個關鍵字修飾變數,它能夠保證有序性和可見性,但是無法保證原子性。如果以後遇到一些成員變數或者靜態變數就要特別注意了,需要分析併發情況下會有哪些問題。
如果本文對你有幫助的話,請留下一個贊吧