前言
對於我們所編寫的源代碼最終以指令形式而順序執行,程序只是處理器自上而下執行的文本文件中列出的操作列表,其實這是錯誤的理解,計算機能夠根據需要更改某些低級操作的順序,尤其是在讀取和寫入內存時,出於性能原因,會進行內存重排序,內存重排序是一種利用指令來進行對應操作,通過這種操作極大地提高了程序的速度,但是,另一方面,它可能對無鎖多線程造成嚴重破壞性,本節我們來分析何為重排序。
何為重排序
程序被加載到主內存中以便執行,CPU的任務是運行存儲在其中的指令,並在必要時讀取和寫入數據,那么具體CPU具體是如何操作的呢?獲取指令、解碼從主存儲器中加載所有所需數據的指令、執行指令、將生成的結果寫回並存儲到主內存中。現代CPU能夠每納秒執行十條指令,但是需要數十納秒才能從主內存中獲取一些數據,與處理器相比,這種類型的內存變得非常慢,為了減少加載和存儲操作中的延遲,因此操作系統為CPU配備了一個很小但又非常快的特殊內存塊,稱為緩存,所以CPU將使用寄存器-緩存,高速緩存是處理器存儲其最常使用的數據的地方,以避免與主內存的緩慢交互,當處理器需要讀取或寫入主內存時,它首先檢查該數據的副本在其自己的緩存中是否可用,如果是這樣,則處理器直接讀取或寫入高速緩存,而不必等待較慢的主內存響應,現代的CPU由多個內核組成—執行實際計算的組件,每個內核都有自己的緩存塊,該緩存塊又連接到主內存,如下圖所示:
具體地說,解碼模塊可以具有一個派遣隊列,在該隊列中,提取的指令將保留,直到其請求的數據從主內存加載到緩存中或它們的從屬指令完成為止,當一些指令正在等待(或停頓)時,就緒的指令會同時解碼並下推到管道中,如果舊數據尚未在高速緩存中,則回寫模塊會將存儲請求放入存儲緩沖區中(高速緩存控制器按高速緩存行存儲和加載數據,每條高速緩存行通常大於單個內存訪問),並開始處理下一條獨立指令。在將舊數據放入緩存后,或者如果它已經在緩存中,指令將使用新結果覆蓋緩存,最終,新數據將最終根據不同的策略異步刷新到主內存(例如,當必須從高速緩存中為新的高速緩存行或與其他數據一起以批處理方式處理數據時),總而言之,通過加入緩存使計算機運行速度更快, 或者說它可以使處理器始終保持忙碌和高效的狀態,從而幫助處理器因等待主內存響應避免浪費不必要的時間。
class ReadWriteDemo { int A = 0; boolean B = false; //CPU1 (thread1) runs this method void writer() { A = 10; B = true; } //CPU2 (thread2) runs this method void reader() { while (!B) continue; System.out.println(A == 10); } }
編寫上述代碼后,我們會假設write方法將在reader方法執行之前完成,在理想情況下這種假設正確無疑,但是,如果使用CPU寄存器的緩存和緩沖,這種假設將可能是錯誤的,例如,如果字段B已經在高速緩存中,而A不在,則B可以早於A存入主內存,即使A和B都在高速緩存中,B仍有可能早於A存入主內存或者A從主內存中先加載到B之前或者A在B存儲前加載之前等類似多種可能性結果,簡而言之,將語句在原始代碼中的排序方式稱為程序順序,單個內存引用(加載或存儲)完成的順序稱為執行順序,由於CPU高速緩存,緩沖區和推測性執行在指令完成時間上增加了太多的異步性,因此執行順序不一定與其程序順序相同,這就是CPU中執行重排序的方式。如果程序是單線程或者方法writer中的字段A和B僅由一個線程訪問,我們實際上並不用關心重排序,因為方法writer中的兩個存儲區是獨立的,即使兩個存儲被重排序。但是,如果程序為多線程,那么可能需要考慮執行順序,例如,CPU1執行方法writer,而CPU2執行方法reader,由於線程使用共享的主內存進行通信,並且由於CPU緩存一致性協議,緩存對訪問是透明的,因此當從內存中加載數據時,如果從未從任何CPU加載過數據,則從主內存中獲取,如果該CPU擁有數據,則為來自另一個CPU的高速緩存,如果擁有數據,則為來自其自身的高速緩存,如果CPU1無序執行方法writer,則上述打印出false,即使CPU1按照程序順序執行了方法writer,打印結果仍有可能為false,因為CPU2可以在執行while語句時之前執行打印結果,因為從邏輯上講,在完成while語句之后才應該打印結果(這稱為控制依賴),但是,CPU2可以自由地先推測性地執行打印結果,一般來講,當CPU看到諸如if或while語句之類的分支時,直到該分支指令完成之前,它才知道在哪里獲取下一條指令,但是,如果它等待分支指令而又找不到足夠的獨立指令,則會降低CPU性能,因此,CPU1可以根據其預測推測性地執行打印結果,稍后可以批准其預測路徑正確時,它將提交執行,在reader方法情況下,這意味着在打印結果之后,CPU1在while語句中找到了B == true,由於CPU並不知道我們關心A和B的執行順序,因此必須使用所謂的內存屏障來告知它們順序必須使用同步構造以強制執行的排序語義。如果兩個CPU都引用相同的內存位置,說明它們具有數據依賴性,則沒有一個CPU將對存儲的給定操作進行重排序,否則將違反程序語義,基於以上分析,我們得出結論:單線程程序在順序化語義as-if-serial下運行,重排序的效果僅對多線程程序可見(或者一個線程中的重新排序僅對其他線程可見/對其他線程很重要),當CPU本質上執行給不了我們實際想要的排序語義時,程序必須使用同步機制。
指令調度說明
只要編譯器不違反程序語義(這里的編譯指代的是JIT編譯器)就可以自由地根據其優化對代碼進行物理或邏輯重排序,現代編譯器具有許多強大的代碼轉換,如下:
public class Main { public static void main(String[] args) { int A = 10; int B = A + 10; int C = 20; } }
假設編譯器通過復雜的分析發現A不在緩存中,而C在緩存中,因此,A=10將觸發多周期的數據加載,而C=20則可以在單個周期內完成,編譯器可以直接跳過對A=10和B=A+10進行賦值操作而執行C=20,以將停頓減少1,如果編譯器可以找到更多獨立的指令,則可以通過減少更多的停頓來進行相同的重排序。由上述我們知道在單核計算機上,硬件內存的重排序並不是問題,線程是操作系統控制的軟件結構,CPU僅接收連續的存儲指令流,它們仍然可以重排序,但是要遵循一個基本規則:給定內核的內存訪問在該內核中似乎是在程序中編寫的,因此,可能會發生內存重排序,但前提是它不會破壞最終結果。接下來我們再來看一個例子(源於java並發實戰)
public class UnsafeLazyInitialization { private static Resource resource; public static Resource getInstance() { if (resource == null) resource = new Resource(); return resource; } }
如上使用先檢查后操作模式實例化Resource,不用多講,很有可能兩個線程可以在該方法中同時抵達,都將resource視為null並初始化變量。這里還涉及到我們上一節所講解的部分初始化對象問題,導致對象無法正確安全發布,當我們初始化一個對象具體會進行5步操作:分配內存、創建對象、使用默認值初始化字段(比如int、boolean等)、運行構造函數、將對象的引用分配給變量,但是這里在進行第4步操作之前就運行第5步操作,所以getInstance方法將返回一個非空但不一致的對象(具有未初始化字段)的引用。但是上述方法也很有可能返回null,因為JMM對此允許, 要了解為什么這樣做是可行的,我們需要詳細分析讀寫,並評估它們之間是否存在事先發生聯系(happens-before),我們將上述代碼進行如下重寫,以清楚地顯示讀取和寫入:
public class UnsafeLazyInitialization { private static Resource resource; public static Resource getInstance() { Resource temp = resource; if (resource == null) resource = temp = new Resource(); return temp; } }
通過聲明一個Resource的臨時變量temp,此時在線程1和線程2都為null,接下來將在線程1中為null,而在線程2中不為null,因為它已由線程1初始化,最終線程1返回實例,而線程2返回null。
總結
本節我們詳細講解了重排序的概念以及引入重排序的原因,下一節我們進入到內存模型,感謝您的閱讀,我們下節見。