我的代碼背叛了我?為什么 a=1, b=2,最后x和y都等于0?
隨著多核架構的普及,并發編程已成為開發者不可或缺的核心技能。在學習過程中,開發者常會遇到這樣的困惑:正確編寫的單線程代碼,為何在并發環境下可能瞬間失效?看似有序的語句執行后,為何結果卻混亂不堪?這些問題,都指向了編程領域的一個關鍵課題——內存模型。
本文以Java語言為例,剖析共享數據在并發環境中的傳播機制、指令執行的有序性保障,以及原子操作的實現原理,從而揭示多線程程序從代碼到處理器執行的底層邏輯。同時,通過剖析工程實踐中常見的并發異常,并追溯其根本原因,幫助讀者構建對并發編程本質的系統理解。
并發之謎:為何我的代碼背叛了我?
在并發編程中,共享變量是指能夠被多個線程同時訪問的變量,如全局變量、靜態變量或對象的實例成員變量。這些變量通常存儲在堆內存中,而非線程私有的棧內存中,因為堆內存對所有線程可見。
共享變量為線程間通信提供了便利,允許線程通過讀寫這些變量來交換信息和協調任務。然而,這種共享機制也帶來了復雜性。當多線程同時讀寫共享變量且缺乏保護措施時,可能引發數據不一致、程序異常甚至系統崩潰等后果。
private int a, b;
private int x, y;
public void test() {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 2;
y = a;
});
// ...start啟動線程,join等待線程
assert x == 2;
assert y == 1;
}
首先,考慮如上代碼片段:定義了兩個共享變量 x 和 y,并在兩個線程中分別對它們進行賦值。當同時啟動這兩個線程并等待它們執行完畢后,x 是否等于 2 且 y 等于 1 呢?答案是不確定的,因為共享變量 x 和 y 可能存在多種執行結果。這種現象在并發編程中并不罕見,常常會導致程序邏輯與預期不符,進而引發困惑。
然而,通過深入分析這些問題的根源,可以發現它們并非無跡可尋。主要原因可以歸結為兩點:首先,處理器與內存之間對共享變量的處理速度存在差異,這會導致可見性問題。其次,編譯器和處理器可能會對代碼指令進行重排序優化,從而導致有序性問題。
可見性:你看到的是真相嗎?

如上圖所示,由于處理器和內存之間的速度差異顯著,為了提高處理效率,處理器并不直接與內存進行通信,而是先將系統內存中的數據加載到處理器內部的緩存(如L1、L2或其他級別緩存)中,然后再進行操作。這一機制基于局部性原理,即處理器在讀取內存數據時,通常以塊為單位進行讀取,每一塊數據稱為緩存行(Cache Line)。當處理器完成對數據的操作后,并不會立即將結果寫回內存,而是先寫入緩存中,并將該緩存行標記為臟(Dirty)狀態。只有當該緩存行被替換時,數據才會被寫回內存。這一過程被稱為寫回策略(Write Back)。
此外,處理器還引入了寫緩沖區(Store Buffer)來進一步提升效率。寫緩沖區用于臨時保存處理器向內存寫入的數據,使得處理器在寫入數據時無需等待慢速的內存操作完成,從而可以繼續執行后續指令,確保指令流水線的持續運行。然而,這種優化機制也帶來了潛在的問題:由于寫緩沖區中的數據并不會立即寫回內存,且寫緩沖區僅對當前處理器可見,其他處理器無法即時感知共享變量的變更。這可能導致處理器的讀寫順序與內存實際操作的讀寫順序不一致,從而引發可見性和有序性問題,進一步增加了并發編程的復雜性。

現在再回來看上面代碼,那么可以得到四種結果:
1)假設處理器A對變量a賦值,但沒及時回寫內存。處理器B對變量b賦值,且及時回寫內存。處理器A從內存中讀到變量b最新值。那么這時結果是:x等于2,y等于0;
2)假設處理器A對變量a賦值,且及時回寫內存。處理器B從內存中讀到變量a最新值。處理器B對變量b賦值,但沒及時回寫內存。那么這時結果是:x等于0,y等于1;
3)假設處理器A和B,都沒及時回寫變量a和b值到內存。那么這時結果是:x等于0,y等于0;
4)假設處理器A和B,都及時回寫變量a和b值到內存,且從內存中讀到變量a和b的最新值。那么這時結果是:x等于2,y等于1。
從上面可發現:除了第四種情況,其他三種情況都存在對共享變量的操作不可見。所謂可見性,便是當一個線程對某個共享變量的操作,另外一個線程立即可見這個共享變量的變更。
而從上面推論可以發現,要達到可見性,需要處理器及時回寫共享變量最新值到內存,也需要其他處理器及時從內存中讀取到共享變量最新值。
因此也可以說只要滿足上述兩個條件。那么就可以保證對共享變量的操作,在并發情況下是線程安全的。在Java語言中,是通過volatile關鍵字實現。volatile 關鍵字并不是 Java 語言的特產,古老的 C 語言里也有,它最原始的意義就是禁用處理器緩存。
對如下代碼中的共享變量:
// instance是volatile變量
volatile Singlenton instance = new Singlenton();
轉換成匯編代碼,如下:
0x01a3de1d: movb 5 0 x 0, 0 x 1104800(% esi);
0x01a3de24: lock addl $ 0 x 0,(% esp);
可以看到volatile修飾的共享變量會多出第二行匯編變量,并且多了一個LOCK指令。LOCK前綴的指令在多核處理器會引發兩件事:
1)將當前處理器緩存行的數據寫回到系統內存;
2)這個寫回內存的操作會使在其他處理器里緩存了該內存地址的數據無效。
上述的操作是通過總線嗅探和總線仲裁來實現。而基于總線嗅探和總線仲裁,現代處理器逐漸形成了各種緩存一致性協議,例如 MESI 協議。

總之操作系統便是基于上述實現,從底層來保證共享變量在并發情況下的線程安全。而對實際開發,只需要在恰當時候加上volatile關鍵字就可以。
除了volatile,也可以使用synchronized關鍵字來保證可見性。 不同于volatile,synchronized通過兩個操作來保證內存可見性:獲取鎖和釋放鎖。當一個線程獲取鎖時,它會清空工作內存中的共享變量,并從主內存中重新加載最新的值。這樣,其他線程在獲取鎖之前無法訪問該變量,從而保證了內存可見性。當線程釋放鎖時,它會將工作內存中的值刷新回主內存,以便其他線程可以看到最新的值。
未完待續
很高興與你相遇!如果你喜歡本文內容,記得關注哦!!!
本文來自博客園,作者:poemyang,轉載請注明原文鏈接:http://www.rzrgm.cn/poemyang/p/19004704
浙公網安備 33010602011771號