Java併發編程——線程安全性深層緣由

線程安全性深層緣由

這裏咱們將會從計算機硬件和編輯器等方面來詳細瞭解線程安全產生的深層緣由。java

緩存一致性問題

CPU內存架構

隨着CPU的發展,而由於CPU的速度和內存速度不匹配的問題(CPU寄存器的訪問速度很是快,而內存訪問速度相對偏慢),全部在CPU和內存之間出現了多級高速緩存。下圖是現代CPU和內存的通常架構圖:
CPU高速緩存
咱們能夠看到高速緩存也分爲三級緩存,越靠近寄存器的級別緩存訪問速度越快。其中L3 Cache爲多核共享的,L1和L2 Cache爲單核獨享,而L1又有數據緩存(L1 d)和指令緩存(L1 i)。程序員

正由於高速緩存的出現,各CPU內核從主內存獲取相同的數據將會存在於緩存中,當多核都對此數據進行操做並修改值,此時另外的核心並不知道此值已被其餘核心修改,從而出現緩存不一致的問題。編程

如何解決緩存一致性問題

解決緩存一致性問題通常有兩個方法:segmentfault

  1. 第一個是採用總線鎖,在總線級別加鎖,這樣從內存種訪問到的數據將被當個CPU核心獨佔,在多核的狀況下對單個資源將是串行化的。這種方式性能上將大打折扣。
  2. 第二個是採用緩存鎖,在緩存的級別上進行加鎖。此種方式須要某種協議對緩存行數據進行同步,後面所說的緩存一致行協議即是一種實現。

緩存一致性協議(MESI)

爲了解決緩存一致性的問題,一些CPU系列(好比Intel奔騰系列)採用了MESI協議來解決緩存一致性問題。此協議將每一個緩存行(Cache Line)使用4種狀態進行標記。緩存

  • M: 被修改(Modified)

該緩存行只被緩存在該CPU核心的緩存中,而且是被修改過的(dirty),即與主存中的數據不一致,該緩存行中的內存須要在將來的某個時間點(容許其它CPU讀取請主存中相應內存以前)寫回(write back)主存。當被寫回主存以後,該緩存行的狀態會變成獨享(exclusive)狀態。安全

  • E: 獨享的(Exclusive)

該緩存行只被緩存在該CPU核心緩存中,它是未被修改過的(clean),與主存中數據一致。該狀態能夠在任什麼時候刻當有其它CPU核心讀取該內存時變成共享狀態(shared)。一樣地,當CPU核心修改該緩存行中內容時,該狀態能夠變成Modified狀態。多線程

  • S: 共享的(Shared)

該狀態意味着該緩存行可能被多個CPU緩存,而且各個緩存中的數據與主存數據一致(clean),當有一個CPU修改該緩存行中,其它CPU中該緩存行能夠被做廢(變成無效狀態(Invalid))。架構

  • I: 無效的(Invalid)

該緩存是無效的(可能有其它CPU核心修改了該緩存行)併發

在MESI協議中,每一個CPU核心的緩存控制器不只知道本身的操做(local read和local write),每一個核心的緩存控制器經過監聽也知道其餘CPU中cache的操做(remote read和remote write),再肯定本身cache中共享數據的狀態是否須要調整。異步

  • local read(LR):讀本地cache中的數據;
  • local write(LW):將數據寫到本地cache;
  • remote read(RR):其餘核心發生read;
  • remote write(RW):其餘核心發生write;

針對操做,緩存行的狀態遷移圖以下:
MESI狀態遷移圖

指令重排序問題

在咱們編程過程當中,習慣性程序思惟認爲程序是按咱們寫的代碼順序執行的,舉個例子來講,某個程序中有三行代碼:

int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3

從程序員角度執行順序應該是1 -> 2 -> 3,實際通過編譯器和CPU的優化頗有可能執行順序會變成 2 -> 1 -> 3(注意這樣的優化重排並無改變最終的結果)。相似這種不影響單線程語義的亂序執行咱們稱爲指令重排。(後面講Java內存模型也會講到這部分。)

編譯器指令重排

舉個例子,咱們先看能夠看一段代碼:

class ReorderExample {  
    int a = 0;  
    boolean flag = false;  
    public void write() {  
        a = 1;                     // 1  
        flag = true;               // 2  
    }
  
    public void read() {  
        if (flag) {                // 3  
            int i =  a * a;        // 4  
        }
    }
}

在單線程的狀況下若是先write再read的話,i的結果應該是1。可是在多線程的狀況下,編譯器極可能對指令進行重排,有可能出現的執行順序是2 -> 3 -> 4 -> 1。這個時候的i的結果就是0了。(1和2之間以及3和4之間不存在數據依賴,有關數據依賴在後面的Java內存模型中會講到。)

CPU指令重排

在CPU層面,一條指令被分爲多個步驟來執行,每一個步驟會使用不一樣的硬件(好比寄存器、存儲器、算術邏輯單元等)。執行多個指令時採用流水線技術進行執行,以下示意圖:
圖片描述
注意這裏出現的」停頓「,出現這個緣由是由於步驟22須要步驟13獲得結果後才能進行。CPU爲了進通常優化:消除一些停頓,這時會將指令3(指令3對指令2和1都沒有數據依賴)移到指令2以前進行運行。這樣就出現了指令重排,根本緣由是爲了優化指令的執行。

內存系統重排

CPU通過長時間的優化,在寄存器和L1緩存之間添加了LoadBuffer、StoreBuffer來下降阻塞時間。LoadBuffer、StoreBuffer,合稱排序緩衝(Memoryordering Buffers (MOB)),Load緩衝64長度,store緩衝36長度,Buffer與L1進行數據傳輸時,CPU無須等待。

  1. CPU執行load讀數據時,把讀請求放到LoadBuffer,這樣就不用等待其它CPU響應,先進行下面操做,稍後再處理這個讀請求的結果。
  2. CPU執行store寫數據時,把數據寫到StoreBuffer中,待到某個適合的時間點,把StoreBuffer的數據刷到主存中。

由於StoreBuffer的存在,CPU在寫數據時,真實數據並不會當即表現到內存中,因此對於其它CPU是不可見的;一樣的道理,LoadBuffer中的請求也沒法拿到其它CPU設置的最新數據;因爲StoreBuffer和LoadBuffer是異步執行的,因此在外面看來,先寫後讀,仍是先讀後寫,沒有嚴格的固定順序。

因爲引入StoreBuffer和LoadBuffer致使異步模式,從而致使內存數據的讀寫多是亂序的(也就是內存系統的重排序)。

內存屏障

爲了解決CPU優化帶來的不可見、重排序的問題,可使用內存屏障(memory barrier)來阻止必定的優化(在後面介紹Java內存模型也會詳細結合講內存屏障)。不一樣的CPU架構對內存屏障的實現方式與實現程度很是不同,下面咱們看下X86架構中內存屏障的實現。

Store Barrier

使全部Store Barrier以前發生的內存更新都是可見的。

Load Barrier

使全部Store Barrier以前發生的內存更新,對Load Barrier以後的load操做都是可見的。

Full Barrier

全部Full Barrier以前發生的操做,對全部Full Barrier以後的操做都是可見的。

延伸

在程序咱們常說的三大性質:可見性、原子性、有序性。經過線程安全性深層緣由咱們能更好的理解這三大性質的根本性緣由。(可見性、原子性、有序性會在後面文章中進行詳細講解。)

上一篇:Java併發編程——線程基礎查漏補缺

相關文章
相關標籤/搜索