一段代碼引來的思考:為什麼程序一直走不出Thread_One的while循環呢?
public class Test{
public static boolean threadOneFlag = true;
public volatile static boolean threadTwoFlag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("thread_one_start");
while (threadOneFlag){ }
System.out.println("thread_one_end");
},"Thread_One").start();
System.out.println("thread_two_start");
while (threadTwoFlag){ }
System.out.println("thread_two_end");
},"Thread_Two").start();
Thread.sleep(1000);
//對threadOneFlag變量的修改在線程Thread_One中並不可見
threadOneFlag = false;
threadTwoFlag = false;
}
運行結果:
從硬體層面了解可見性的本質
程序運行時用到的存儲設備有:CPU、內存、磁碟(IO設備),三者有不同的處理速度,而且差異很大。當一個程序運行時如果三者都需要訪問,如果不做任何處理的話,計算效率受限於最慢的設備,計算機硬體對此做了一些優化:
CPU增加了高速緩存
多核CPU並且增加了進程、線程概念,通過時間片切換最大化提升CPU的使用率
編譯器的指令優化,更合理的去利用好CPU的高速緩存
這些優化雖然提升了計算機的計算效率,但是卻帶來的可見性和重排序的問題,下面慢慢講解
CPU高速緩存
存在的意義:絕大多數的運算任務不能僅通過處理器來完成,還需要和內存進行交互。例如:讀取運算數據,存儲運算結果。因為計算機的存儲設備與處理器運算速度差距很大,所以會增加CPU高速緩存作為兩者之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步到內存之中。
存在的弊端:會帶來緩存一致性的問題
CPU高速緩存的結構:
分為L1,L2,L3三級緩存,L1和L2是CPU私有的,其中L1最小,L1又分為數據緩存和指令緩存
緩存一致性
當高速緩存存在以後,每個CPU獲取/存儲數據直接操作高速緩存,而不是內存,這樣當多個線程運行在不同CPU中時。同一份內存數據就可能會緩存於多個CPU高速緩存中,如不進行限制,就會出現緩存一致性問題
CPU層面提出了兩種解決辦法:1. 總線鎖,2. 緩存鎖
總線鎖和緩存鎖
總線鎖:在多CPU下,當其中一個處理器要對共享內存進行操作的時候,在總線上發出一個LOCK信號,使得其他處理器無法訪問共享數據,開銷很大,如果我們能夠控制鎖的粒度就能減少開銷,從而引入了緩存鎖。
緩存鎖:只要保證多個CPU緩存的同一份數據是一致的就可以了,基於緩存一致性協議來實現的
緩存一致性協議
為了達到數據訪問的一致,需要各個處理器在訪問緩存時遵循一些協議,在讀寫時根據協議來操作,常見的協議有MSI、MESI、MOSI。最常見的是MESI協議。
MESI協議
在MESI協議中,每個緩存的緩存控制器不僅知道自己的讀寫操作,而且也監聽其他Cache的讀寫操作。共有四種狀態,分別是:
M(Modify)表示共享數據只緩存在當前CPU緩存中,並且是被修改的狀態。此時表示當前CPU緩存數據與主內存中不一致,其他CPU緩存中如果緩存了當前數據應是無效狀態,因為該數據已被修改且並未更新到主內存
E(Exclusive)表示緩存的獨佔狀態,數據只緩存在當前CPU緩存中,並且沒有被修改
S(Shared)表示數據可能被多個CPU緩存,並且各個緩存中的數據和主內存中的數據一致
I(Invalid)表示當前緩存已經失效
圖解四種狀態:
對於MESI協議,從CPU讀寫角度來說會遵循一下原則:
CPU讀請求:緩存處於M、E、S狀態都可以被讀取,I狀態CPU只能從主內存中讀取數據
CPU寫請求:緩存處於M、E狀態才可以被寫入主內存中。對於S狀態的寫,需要將其他CPU中緩存行設置為無效才可寫。
使用總線鎖和緩存鎖機制之後,CPU對於內存的操作可以做如下抽象:
MESI協議的不足之處
當一個CPU_0需要將緩存中的數據進行寫入時,首先需要發送失效信息給其他緩存了該數據的CPU,等回執確認之後才會進行寫入。等待回執確認的過程中CPU_0會處於阻塞狀態,為了避免阻塞造成的資源浪費,CPU中引入了Store Bufferes。
引入Sotr Bufferes後,CPU_0在寫入共享數據時,只需將數據寫入store bufferes中,同時向其他緩存了共享數據的CPU發送失效指令就可以做其他操作了。由store bufferes等待回執確認信息,並負責同步到主內存
這種優化方式帶來了兩個現象,引起重排序的問題:
數據什麼時候提交不確定,因為需要等待其他CPU確認回執之後才會提交,這是一個異步操作
引入storebufferes後,處理器會先嘗試從storebuffere中讀取值,如果storebufferes中有數據,則直接從storebuffer中讀取,否則再從緩存行中讀取
重排序
請看如下代碼:假如exeToCPU0和exeToCPU1執行在不同CPU上,當exeToCPU0執行完兩行賦值代碼時,此時exeToCPU1執行if語句時,isFinsh = true,但是可能value並不為10,這就是重排序問題。
原因在於:假設CPU0緩存的兩個變量及狀態為:isFinish(E),value(S),CPU0修改value時只會先將修改結果保存到Store Buffer中,然後繼續執行isFinish=true指令,因為isFinish是(E),所以會直接將修改結果寫入內存中。此時CPU1讀書兩個值時,可能的結果就是:isfinish=true,value=3(不等於10)
為了解決此類問題,CPU層面提出了內存屏障
CPU層面的內存屏障
可以將其粗獷的理解為:將store buffer中的指令寫入到內存,從而使得其他訪問同一共享內存的線程的可見性
X86的 memory barrier的指令包括:讀屏障、寫屏障以及全屏障
寫屏障:告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,也就是,寫屏障之前的指令對於屏障之後的讀操作都是可見的。
讀屏障:處理器讀屏障之後的讀操作都在屏障之後執行
全屏障:確保屏障前的內存讀寫操作的結果都對屏障之後的操作可見
這些都不需要我們程式設計師來維護,和我們直接打交道的是JMM
JMM
JMM全稱是Java Memory Model,是隸屬於JVM的,是屬於語言級別的抽象內存模型,可以簡單理解為對硬體模型的抽象,它定義了共享內存中多線程程序讀寫操作的行為規範。JMM並沒有提升或者損失執行性能,也沒有直接限制指令重排序,JMM只是將底層問題抽象到JVM層面,是基於CPU層面提供的內存屏障及限制編譯器的重排序來解決問題的
JMM抽象模型分為主內存和工作內存。主內存是所有線程共享的,工作內存是每個線程獨佔的。線程對變量的所有操作都必須在工作內存中進行,不能直接讀寫主內存中的變量,線程之間共享變量的傳遞都是基於主內存來完成的
JMM體統了一些禁用緩存以及禁止重排序的方法,來解決可見性和有序性問題,例如:volatile、synchronized、final
在JMM中如果一個操作的執行結果必須對另外一個操作可見,兩個操作必須要存在happens-before關係,即happen-before規則(具體參見:happen-before規則)。