“如欲征服java并發,需先征服java內存模型,如欲征服java內存模型,需先征服計算機內存模型” -aworker.

咳!咳!,大家都記好筆記了吧。雖然我不是什么大佬,但是這句話說的還是沒有毛病的。不了解java的內存模型,就不會從跟不上理解java并發的一些行為和機制,而java內存模型畢竟是jvm模擬出來的一部分,其底子還是建立在現代計算機的物理內存模型上來的,所以我們就按照現代計算機的物理內存模型、java內存模型的順序來仔細介紹,為徹底了解java并發機制打下底子。
現代計算機的物理內存模型:

現在計算機最少的都是應該是兩核心了,當然我們也經常在買個人電腦的時候聽過四核四線程、四核八線程等,可以說現在個人電腦標配都是四核心了,為了方便上圖只是列舉了2個核心。現代計算機的內存在邏輯上還是一塊。有人可能問不對啊,我電腦就插了兩塊內存,但是操作系統會把兩塊內存的地址統一抽象,比如每一塊的內存是2048MB地址是000000000000-011111111111MB,兩塊就是0000000000000-0111111111111MB,操作系統會統一編址。所以整體上看還是一塊內存。因為CPU的操作速度太快,如果讓CPU直接操作內存,那么久是對CPU資源的一種巨大浪費,為了解決這個問題現在計算機都給CPU加上緩存,比如一級緩存,二級緩存,甚至三級緩存。緩存速度比內存快,但是是還是趕不上CPU的數據級別,所以在緩存和CPU之間又有了register,register的存儲速度比緩存就快了好多了。
存儲速度上有如下關系:
register > 一級緩存 > 二級緩存 > ... > n級緩存 > 內存
容量上一般有如下關系:
內存 > n級緩存 > ... > 二級緩存 > 一級緩存 > register
之所以可以用緩存和register來緩解CPU和內存之間巨大的速度差別是基于如下原理:
CPU訪問過的內存地址,很有可能在短時間內會被再次訪問。
所以,比如CPU訪問了地址為0x001fffff的內存地址,如果沒有緩存和register,那么CPU再下次訪問這個內存地址的時候就還要去內存讀,但是如果有緩存,緩存會把CPU訪問過的數據先存儲起來,等CPU待會再找地址為0x001fffff的內存地址時候,發現其在緩存中就存在了,那么好了,這就不用在訪問內存了。速度自然就提升了。這就涉及到計算機組成原理的知識了,如果想了解可以google一下,這里就不在做更深的介紹了到這里就夠用了。
了解現代計算機物理內存模型工作原理后,那么再理解多線程開發中最關系的三個概念就有的放矢了。先介紹下三個概念:
- 操作原子性:一個操作要么全做,要么全不做,那么這個操作就符合原子性。比如你給你老婆銀行卡轉500塊錢,就包括兩個操作,自己賬戶先減500,你老婆賬戶加500。但是這個轉賬操作應該滿足原子性。如果銀行只執行了你自己賬戶的扣錢操作,沒有執行給你老婆賬戶的加錢操作。丟了500塊錢是小事,被老婆大人罰跪搓衣板可就不得了了。所以你自己賬戶減錢,老婆賬戶加錢,這兩個操作要么都做了,要么都別做。例如如下操作:
a = a + 1;
結合我們上述的現代計算機的內存模型,計算機執行a=a+1時候會分成三個原子性操作:
- 把a的值(比如4)從內存中取出放到CPU的緩存系統中
- 從緩存系統中取出a的值加1(4+1)得到新結果
- 把新結果存回到內存中
一個“a=a+1”操作計算機中被拆分成三個原子性操作,那么完全可以出現CPU執行完1.操作后,去執行別的操作了。這就是并發操作原子性問題的根本來源。
- 操作有序性:例如如下代碼:
public class A {
public int a;
public boolean b = false;
public void methodA(){
a = 3;
b = true;
a = a + 1;
}
public void methodB(){
a = 3;
b = (a == 4);
a = a + 1;
}
}
methodA方法代碼先經過java編譯器編譯成字節碼,然后字節碼然后被操作系統解釋成機器指令,在這個解釋過程中,操作系統可能發現,咦?在給變量b賦值為true后又操作了a變量,干脆我操作系統自己改改執行順序,把對a變量的兩個操作都執行完,然后再執行對b的操作,這就叫指令重排序。這樣就會節省操作時間,如下圖沒有進行指令重排序時:

圖中CPU和緩存系統要進行9次通信,緩存系統和內存要通信7次,假設cpu和緩存系統通信一次用時1ms,緩存系統和內存通信一次用時10ms,那么總用時 9乘1 + 7乘10 = 79ms。經過指令重排序后,總共用時 6乘1 + 6乘10 = 66ms 如下圖所示:

經過指令重排序的確可以提程序運行效率,所以現代計算機都會對指令進行重排序,但是這種重排序也不是無腦重排序,重排序的基礎是前后語句不存在依賴關系時,才有可能發生指令重排序。所以A類的methodB方法不會發生指令重排序。指令重排序在單線程環境里面這不會有什么問題,但是多線程中就可能發生意外。比如線程1中執行如下代碼:
instance.methodA();
另一個線程2執行如下代碼:
while(instance.a != 4){ //a只要不等4,線程就讓出CPU,等待調度器再次執行此線程
Thread.yield(); //讓出CPU,線程進入就緒態
}
System.out.print(instance.b);
其中instance是A類的一個實例。如果線程1 發生了指令重排序, 那么這線程2的打印結果很有可能是false,這就和我們對代碼的直觀觀察結果出處很大。如果線上產品出錯的原因是指令重排序導致的,幾乎不能可能排查出來。
-
操作可見性 :
在“操作有序性” 中的線程線程2 ,還有可能會沒有任何輸出結果。因為線程2 要想有輸出必須要滿足instance.a =4,但這是在線程1中調用methodA 方法后instance.a 的值才為4 。而要想讓線程2 看到這個新值,必須要把線程1的修改及時寫回內存, 同時通知線程2 存在緩存系統中的instance.a值已經過期,需要去內存中獲取最新值。如果我們的類A和線程1、線程2調用的代碼沒有特殊的聲明,那么操作系統不能保證上述過程一定發生。即可能發生線程1對instance.a的修改對線程2不一定可見,這就是操作的可見性問題。
java多線程的所有問題都植根于“操作原子性”、“操作有序性”、“操作可見性”而引發的。
上面介紹了現代計算機的內存模型以及其引起的在并發編程的三個問題,下面來介紹下java的內存模型。java為了實現其夸平臺的特性,使用了一種虛擬機技術,java程序運行在這虛擬機上,那么不管你是windows系統,linux系統,unix系統,只要我java虛擬機屏蔽一切操作系統帶來的差異,向java程序提供專用的、各系統無差別的虛擬機,那么java程序員就不需要關心底層到底是什么操作系統了。對于int類型的變量其取值范圍永遠是 -2^31 -1 至 2^31,即4個字節。但是對C\C++,這個操作系統的int可能是4字節,那個可能是8字節。C++程序員跨平臺寫代碼,痛苦異常。這個給我們編程帶來極大方便的虛擬機就是大名鼎鼎的JVM(Java Virtual Machine)。既然是虛擬機那么就需要模擬真正物理機的所有設備,像CPU,網絡,存儲等。和我們程序員最密切的就是JVM的存儲,這就是java內存模型(Java Memory Model 簡稱JMM)。有別于我們真實的物理存儲模型,JMM把存儲分為線程棧區和堆區。在JVM中的每個線程都有自己獨立的線程棧,而堆區用來存儲java的對象實例。java中各種變量的存儲有一下規則:
- 成員變量一定存儲在堆區。
- 局部變量如果是基本數據類型存儲在線程棧中,如果是非基本數據類型存儲,其引用存儲在線程棧中,但具體的對象實例還是存儲在棧中。
因為java內存模型是在具體的物理內存模型的基礎上實現的,并且為了運行效率,java也支持指令重排序。所以java并發編程也有“原子性”、“有序性”、“可見性”三個問題。