接指令重排序對主存的一次訪問一般花費硬件的數百次時鐘周期。處理器通過緩存(caching)能夠從數量級上降低內存延遲的成本這些緩存為了性能重新排列待定內存操作的順序。也就是說,程序的讀寫操作不一定會按照它要求處理器的順序執行。重排序的背景我們知道現代CPU的主頻越來越高,與cache的交互次數也越來越多。當CPU的計算速度遠遠超過訪問cache時,會產生cache wait,過多的cache wait就會造成性能瓶頸。
針對這種情況,多數架構(包括X86)采用了一種將cache分片的解決方案,即將一塊cache劃分成互不關聯地多個 slots (邏輯存儲單元,又名 Memory Bank 或 Cache Bank),CPU可以自行選擇在多個 idle bank 中進行存取。這種 SMP 的設計,顯著提高了CPU的并行處理能力,也回避了cache訪問瓶頸。Memory Bank的劃分一般 Memory bank 是按cache address來劃分的。比如 偶數adress 0×12345000 分到 bank 0, 奇數address 0×12345100 分到 bank1。
重排序的種類編譯期重排。編譯源代碼時,編譯器依據對上下文的分析,對指令進行重排序,以之更適合于CPU的并行執行。運行期重排,CPU在執行過程中,動態分析依賴部件的效能,對指令做重排序優化。Java語言規范規定了JVM線程內部維持順序化語義,也就是說只要程序的最終結果等同于它在嚴格的順序化環境下的結果,那么指令的執行順序就可能與代碼的順序不一致。這個過程通過叫做指令的重排序。指令重排序存在的意義在于:JVM能夠根據處理器的特性(CPU的多級緩存系統、多核處理器等)適當的重新排序機器指令,使機器指令更符合CPU的執行特點,最大限度的發揮機器的性能。程序執行最簡單的模型是按照指令出現的順序執行,這樣就與執行指令的CPU無關,最大限度的保證了指令的可移植性。這個模型的專業術語叫做順序化一致性模型。但是現代計算機體系和處理器架構都不保證這一點(因為人為的指定并不能總是保證符合CPU處理的特性)。
我們來看最經典的一個案例:
package xylz.study.concurrency.atomic; public class ReorderingDemo { static int x = 0, y = 0, a = 0, b = 0; public static void main(String[] args) throws Exception { for (int i = 0; i < 100; i++) {x=y=a=b=0;Thread one = new Thread() {public void run() {a = 1;x = b;}};Thread two = new Thread() {public void run() {b = 1;y = a;}};one.start();two.start();one.join();two.join();System.out.println(x + " " + y);}} }
在這個例子中one/two兩個線程修改區x,y,a,b四個變量,在執行100次的情況下,可能得到(0 1)或者(1 0)或者(1 1)。事實上按照JVM的規范以及CPU的特性有很可能得到(0 0)。當然上面的代碼大家不一定能得到(0 0),因為run()里面的操作過于簡單,可能比啟動一個線程花費的時間還少,因此上面的例子難以出現(0,0)。但是在現代CPU和JVM上確實是存在的。由于run()里面的動作對于結果是無關的,因此里面的指令可能發生指令重排序,即使是按照程序的順序執行,數據變化刷新到主存也是需要時間的。
假定是按照a=1;x=b;b=1;y=a;執行的,x=0是比較正常的,雖然a=1在y=a之前執行的,但是由于線程one執行a=1完成后還沒有來得及將數據1寫回主存(這時候數據是在線程one的堆棧里面的),線程two從主存中拿到的數據a可能仍然是0(顯然是一個過期數據,但是是有可能的),這樣就發生了數據錯誤。在兩個線程交替執行的情況下數據的結果就不確定了,在機器壓力大,多核CPU并發執行的情況下,數據的結果就更加不確定了。Happens-before法則Java的內存結構如下如果多線程之間不共享數據,這也表現得很好,但是如果多線程之間要共享數據,那么這些亂序執行,數據在寄存器中這些行為將導致程序行為的不確定性,現在處理器已經是多核時代了,這些問題將會更加嚴重,每個線程都有自己的工作內存,多個線程共享主內存,如圖如果共享數據,什么時候同步到主內存讓別人的線程讀取數據呢?這又是不確定的,如果非要一致,那么代價高昂,這將犧牲處理器的性能,所以現在的處理器會犧牲存儲一致性來換取性能,如果程序要確保共享數據的時候獲得一致性,處理器通常了提供了一些關卡指令,這個可以幫助程序員來實現,但是各種處理器都不一樣,如果要使程序能夠跨平臺是不可能的,怎么辦?
使用Java,由JMM(Java Memeory Model Action)來屏蔽,我們只要和JMM的規定來使用一致性保證就搞定了,那么JMM又提供了什么保證呢?JMM的定義是通過動作的形式來描述的,所謂動作,包括變量的讀和寫,監視器加鎖和釋放鎖,線程的啟動和拼接,這就是傳說中的happen before,要想A動作看到B動作的結果,B和A必須滿足happen before關系。最后為大家總結了happen before法則:
1、程序次序法則,如果A一定在B之前發生,則happen before;
2、監視器法則,對一個監視器的解鎖一定發生在后續對同一監視器加鎖之前;
3、Volatie變量法則:寫volatile變量一定發生在后續對它的讀之前;
4、線程啟動法則:Thread.start一定發生在線程中的動作;
5、線程終結法則:線程中的任何動作一定發生在括號中的動作之前(其他線程檢測到這個線程已經終止,從Thread.join調用成功返回,Thread.isAlive()返回false);
6、中斷法則:一個線程調用另一個線程的interrupt一定發生在另一線程發現中斷;
7、終結法則:一個對象的構造函數結束一定發生在對象的finalizer之前;
8、傳遞性:A發生在B之前,B發生在C之前,A一定發生在C之前。