何爲內存重排序?

前言

咱們知道對於咱們所編寫的代碼經過計算機如何順序執行以源代碼編寫的指令,程序只是處理器自上而下執行的文本文件中列出的操做列表,其實這是錯誤的理解,計算機可以根據須要更改某些低級操做的順序,尤爲是在讀取和寫入內存時,出於性能緣由,會進行內存重排序,內存重排序是一種利用指令來進行對應操做,經過這種操做極大地提升了程序的速度,可是,另外一方面,它可能對無鎖多線程形成嚴重破壞性,本節咱們來分析何爲重排序。java

何爲重排序

程序被加載到主內存中以便執行,CPU的任務是運行存儲在其中的指令,並在必要時讀取和寫入數據,那麼具體CPU具體是如何操做的呢?獲取指令、解碼從主存儲器中加載全部所需數據的指令、執行指令、將生成的結果寫回並存儲到主內存中。現代CPU可以每納秒執行十條指令,可是須要數十納秒才能從主內存中獲取一些數據,與處理器相比,這種類型的內存變得很是慢,爲了減小加載和存儲操做中的延遲,所以操做系統爲CPU配備了一個很小但又很是快的特殊內存塊,稱爲緩存,因此CPU將使用寄存器-緩存,高速緩存是處理器存儲其最常使用的數據的地方,以免與主內存的緩慢交互,當處理器須要讀取或寫入主內存時,它首先檢查該數據的副本在其本身的緩存中是否可用,若是是這樣,則處理器直接讀取或寫入高速緩存,而沒必要等待較慢的主內存響應,現代的CPU由多個內核組成—執行實際計算的組件,每一個內核都有本身的緩存塊,該緩存塊又鏈接到主內存,以下圖所示:緩存

具體地說,解碼模塊能夠具備一個派遣隊列,在該隊列中,提取的指令將保留,直到其請求的數據從主內存加載到緩存中或它們的從屬指令完成爲止,當一些指令正在等待(或停頓)時,就緒的指令會同時解碼並下推到管道中,若是舊數據還沒有在高速緩存中,則回寫模塊會將存儲請求放入存儲緩衝區中(高速緩存控制器按高速緩存行存儲和加載數據,每條高速緩存行一般大於單個內存訪問),並開始處理下一條獨立指令。在將舊數據放入緩存後,或者若是它已經在緩存中,指令將使用新結果覆蓋緩存,最終,新數據將最終根據不一樣的策略異步刷新到主內存(例如,當必須從高速緩存中爲新的高速緩存行或與其餘數據一塊兒以批處理方式處理數據時),總而言之,經過加入緩存使計算機運行速度更快, 或者說它可使處理器始終保持忙碌和高效的狀態,從而幫助處理器因等待主內存響應避免浪費沒必要要的時間。 安全

 

很顯然,這種緩存機制會增長多核操做系統複雜性,有了緩存咱們將須要詳細的規則來肯定數據如何在不一樣的緩存之間流動,也就是使得各個緩存副本中的數據一致性以此確保每一個內核都具備最新的版本,它們被稱爲緩存一致性協議(高速緩存一致性是最重要的問題,它因爲增長了芯片多處理器上的內核數量以及將在這些處理器上運行的共享內存程序而迅速影響了多核處理器的性能。 「窺探協議」和「基於目錄的協議」是用於實現緩存之間一致性的兩種協議,這些協議的主要目的是實現多核處理器的高速緩存中數據值的一致性和驗證,以便經過任何高速緩存讀取存儲器地址都將返回寫入該地址的最新數據),可能會致使巨大的性能損失,所以,操做系統則使用內存重排序技巧,以充分利用每一個內核,內存從新排序可能有幾個緣由,例如,考慮兩個被指令訪問主內存中相同數據塊的內核,內核A從內存中讀取,內核B對其進行寫入,可能會迫使內核A等待,而內核B將其本地緩存的數據寫回到主內存中,以便內核A能夠讀取到最新信息,等待中的內核可能會選擇提早運行其餘內存指令,而不是浪費寶貴的時間而不作任何事情,啓用某些優化後,編譯器和虛擬機也能夠自由地從新排序指令,這些更改在編譯時發生,能夠經過查看彙編代碼或字節碼知道,軟件內存重排序以利用基礎硬件可能提供的任何功能,只是爲了使代碼運行更快。咱們來看以下代碼:
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併發實戰)app

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),咱們將上述代碼進行以下重寫,以清楚地顯示讀取和寫入:異步

 

在此示例中,容許21和24都遵照10或13,而且合法執行了該程序,假設線程1看到resource爲空並對其進行了初始化,線程2看到x不爲空,應該會實例化Resource,可是結果可能返回null的resource,這是爲什麼呢?前面咱們講解過CPU緩存一致性協議,當線程1執行實例化Resource時,此時須要寫入到主內存,同時線程2恰好要獲取resource將進行等待,爲了解決緩存一致性引發的等待問題,JIT經過指令進行重排序,接下來將跳過實例化resource並寫入,直接返回resource,此時結果就爲null。咱們將重排序操做改形成以下,相信能更好理解一點
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。函數

總結

本節咱們詳細講解了重排序的概念以及引入重排序的緣由,下一節咱們進入到內存模型,感謝您的閱讀,咱們下節見。性能

相關文章
相關標籤/搜索