並發高?可能是編譯優化引發有序性問題
摘要:CPU為了對程序進行優化,會對程序的指令進行重排序,此時程序的執行順序和代碼的編寫順序不一定一致,這就可能會引起有序性問題。
本文分享自華為雲社區《【高並發】解密導致並發問題的第三個幕後黑手——有序性問題》,作者:冰 河 。
有序性
有序性是指:按照代碼的既定順序執行。
說的通俗一點,就是代碼會按照指定的順序執行,例如,按照程序編寫的順序執行,先執行第一行代碼,再執行第二行代碼,然後是第三行代碼,以此類推。如下圖所示。
指令重排序
編譯器或者解釋器為了優化程序的執行性能,有時會改變程序的執行順序。但是,編譯器或者解釋器對程序的執行順序進行修改,可能會導致意想不到的問題!
在單線程下,指令重排序可以保證最終執行的結果與程序順序執行的結果一致,但是在多線程下就會存在問題。
如果發生了指令重排序,則程序可能先執行第一行代碼,再執行第三行代碼,然後執行第二行代碼,如下所示。
例如下面的三行代碼。
int x = 1; int y = 2; int z = x + y;
CPU發生指令重排序時,能夠保證x=1和y = 2這兩行代碼在z = x + y這行代碼的上面,而x = 1和 y = 2的順序就不一定了。在單線程下不會出現問題,但是在多線程下就不一定了。
有序性問題
CPU為了對程序進行優化,會對程序的指令進行重排序,此時程序的執行順序和代碼的編寫順序不一定一致,這就可能會引起有序性問題。
在Java程序中,一個經典的案例就是使用雙重檢查機制來創建單例對象。例如,在下面的代碼中,在getInstance()方法中獲取對象實例時,首先判斷instance對象是否為空,如果為空,則鎖定當前類的class對象,並再次檢查instance是否為空,如果instance對象仍然為空,則為instance對象創建一個實例。
package io.binghe.concurrent.lab01; /** * @author binghe * @version 1.0.0 * @description 測試單例 */ public class SingleInstance { private static SingleInstance instance; public static SingleInstance getInstance(){ if(instance == null){ synchronized (SingleInstance.class){ if(instance == null){ instance = new SingleInstance(); } } } return instance; } }
如果編譯器或者解釋器不會對上面的程序進行優化,整個代碼的執行過程如下所示。
注意:為了讓大家更加明確流程圖的執行順序,我在上圖中標註了數字,以明確線程A和線程B執行的順序。
假設此時有線程A和線程B兩個線程同時調用getInstance()方法來獲取對象實例,兩個線程會同時發現instance對象為空,此時會同時對SingleInstance.class加鎖,而JVM會保證只有一個線程獲取到鎖,這裡我們假設是線程A獲取到鎖。則線程B由於未獲取到鎖而進行等待。接下來,線程A再次判斷instance對象為空,從而創建instance對象的實例,最後釋放鎖。此時,線程B被喚醒,線程B再次嘗試獲取鎖,獲取鎖成功後,線程B檢查此時的instance對象已經不再為空,線程B不再創建instance對象。
上面的一切看起來很完美,但是這一切的前提是編譯器或者解釋器沒有對程序進行優化,也就是說CPU沒有對程序進行重排序。而實際上,這一切都只是我們自己覺得是這樣的。
在真正高並發環境下運行上面的代碼獲取instance對象時,創建對象的new操作會因為編譯器或者解釋器對程序的優化而出現問題。也就是說,問題的根源在於如下一行代碼。
instance = new SingleInstance();
對於上面的一行代碼來說,會有3個CPU指令與其對應。
1.分配內存空間。
2.初始化對象。
3.將instance引用指向內存空間。
正常執行的CPU指令順序為1—>2—>3,CPU對程序進行重排序後的執行順序可能為1—>3—>2。此時,就會出現問題。
當CPU對程序進行重排序後的執行順序為1—>3—>2時,我們將線程A和線程B調用getInstance()方法獲取對象實例的兩種步驟總結如下所示。
【第一種步驟】
(1)假設線程A和線程B同時進入第一個if條件判斷。
(2)假設線程A首先獲取到synchronized鎖,進入synchronized代碼塊,此時因為instance對象為null,所以,此時執行instance = new SingleInstance()語句。
(3)在執行instance = new SingleInstance()語句時,線程A會在JVM中開闢一塊空白的內存空間。
(4)線程A將instance引用指向空白的內存空間,在沒有進行對象初始化的時候,發生了線程切換,線程A釋放synchronized鎖,CPU切換到線程B上。
(5)線程B進入synchronized代碼塊,讀取到線程A返回的instance對象,此時這個instance不為null,但是並未進行對象的初始化操作,是一個空對象。此時,線程B如果使用instance,就可能出現問題!!!
【第二種步驟】
(1)線程A先進入if條件判斷,
(2)線程A獲取synchronized鎖,並進行第二次if條件判斷,此時的instance為null,執行instance = new SingleInstance()語句。
(3)線程A在JVM中開闢一塊空白的內存空間。
(4)線程A將instance引用指向空白的內存空間,在沒有進行對象初始化的時候,發生了線程切換,CPU切換到線程B上。
(5)線程B進行第一次if判斷,發現instance對象不為null,但是此時的instance對象並未進行初始化操作,是一個空對象。如果線程B直接使用這個instance對象,就可能出現問題!!!
在第二種步驟中,即使發生線程切換時,線程A沒有釋放鎖,則線程B進行第一次if判斷時,發現instance已經不為null,直接返回instance,而無需嘗試獲取synchronized鎖。
我們可以將上述過程簡化成下圖所示。
總結
導致並發編程產生各種詭異問題的根源有三個:緩存導致的可見性問題、線程切換導致的原子性問題和編譯優化帶來的有序性問題。我們從根源上理解了這三個問題產生的原因,能夠幫助我們更好的編寫高並發程序。