概述
Java的內存模型(Java Memory Model )簡稱JMM。首先應該明白,Java內存模型是一個規范,主要規定了以下兩點:
- 規定了一個線程如何以及何時可以看到其他線程修改過后的共享變量的值,即線程之間共享變量的可見性。
- 如何在需要的時候對共享變量進行同步。
JMM定義了Java虛擬機(JVM)在計算機內存(RAM)中的工作方式。
而在並發編程中,我們所要處理的兩個關鍵問題就是這兩條標准的體現:線程之間如何通信以及線程之間如何同步。通信是指線程之間以何種機制來交換信息。在命令式的編程中,線程之間的通信機制有兩種:共享內存和消息傳遞。
在共享內存並發的模型里,線程之間共享程序的公共狀態,線程之間通過讀-寫內存中的公共狀態來隱式進行通信。典型的共享內存通信方式就是通過共享對象進行通信。
在消息傳遞的並發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯示進行通信,在java中典型的消息傳遞方式就是wait()和notify()。
同步是指程序用於控制不同線程之間操作發生相對順序的機制。
在共享內存並發模型里,同步是顯示進行的,程序員必須顯示指定某個方法或某段代碼需要在線程之間互斥進行。
在消息傳遞的並發模型里,由於消息的發送必須在消息的接受之前,因此同步是隱式進行的。
Java的並發采用的就是共享內存模型,Java線程之間的通信總是隱式進行的,整個通信過程對程序員是完全透明的。
上面講到了Java線程之間的通信采用的是過共享內存模型,這里提到的共享內存模型指的就是Java內存模型(簡稱JMM),JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
- 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
- 然后,線程B到主內存中去讀取線程A之前已更新過的共享變量。
下面通過示意圖來說明這兩個步驟:
如上圖所示,本地內存A和B有主內存中共享變量x的副本。假設初始時,這三個內存中的x值都為0。線程A在執行時,把更新后的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改后的x值刷新到主內存中,此時主內存中的x值變為了1。隨后,線程B到主內存中去讀取線程A更新后的x值,此時線程B的本地內存的x值也變為了1。
從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為java程序員提供內存可見性保證。
上面也說到了,Java內存模型只是一個抽象概念,那么它在Java中具體是怎么工作的呢?為了更好的理解Java內存模型的工作方式,下面就JVM對Java內存模型的實現、硬件內存模型及它們之間的橋接做詳細介紹。
JVM對Java內存模型的實現
在JVM內部,Java虛擬機在執行Java程序的過程中會把它所管理的內存划分為若干不同的數據區域,這些區域都有各自的用途以及創建和銷毀的時間。
主要區域如下圖所示:堆(Heap),虛擬機棧(VM Stack),方法區(Method Area),本地方法棧(Native Method Stack),程序計數器(PC Register)。
從上面的圖中可以看出:
-
- 堆和方法區線程共享的;
-
- 棧和程序計數器是線程私有的。
詳細說明和異常拋出:
虛擬機棧: 每一個運行在Java虛擬機上的線程都擁有自己的線程棧,虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的時候都會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈表、方法出口信息等。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。棧的生命周期與線程相同。
線程棧之間是相互隔離的,一個線程僅能訪問自己的線程棧。一個線程創建的本地變量對其它線程不可見,僅自己可見。即使兩個線程執行同樣的代碼,這兩個線程任然在在自己的線程棧中的代碼來創建本地變量。因此,每個線程擁有每個本地變量的獨有版本。
所有原始類型的本地變量都存放在線程棧上,因此對其它線程不可見。一個線程可能向另一個線程傳遞一個原始類型變量的拷貝,但是它不能共享這個原始類型變量自身。
本地方法棧:本地方法棧與虛擬機棧的作用相似,不同之處在於虛擬機棧為虛擬機執行的Java方法服務,而本地方法棧則為虛擬機使用到的Native方法服務。有的虛擬機直接把本地方法棧和虛擬機棧合二為一。
程序計數器:程序計數器保存着每一條線程下一次執行指令位置。
堆:用來保存程序中所創建的所有對象、數組元素。堆內存在線程之間是共享的。
方法區:方法區是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據 。
數據存儲總結:
一個本地變量如果是原始類型,那么它會被完全存儲到棧區。
一個本地變量也有可能是一個對象的引用,這種情況下,這個本地引用會被存儲到棧中,但是對象本身仍然存儲在堆區。
對於一個對象的成員方法,這些方法中包含本地變量,仍需要存儲在棧區,即使它們所屬的對象在堆區。
對於一個對象的成員變量,不管它是原始類型還是包裝類型,都會被存儲到堆區。
Static類型的變量以及類本身相關信息都會隨着類本身存儲在堆區。
堆中的對象可以被多線程共享。如果一個線程獲得一個對象的引用,它便可訪問這個對象的成員變量。如果兩個線程同時調用了同一個對象的同一個方法,那么這兩個線程便可同時訪問這個對象的成員變量,但是對於該對象的本地變量,每個線程都會拷貝一份到自己的線程棧中。也就是,如果兩個線程同時訪問同一個對象的私有變量,這時他們獲得的是這個對象的私有拷貝。
下圖展示了上面描述的過程:
線程並發的三大概念:原子性,有序性,可見性
1.定義
原子性:即一個操作或者多個操作 要么全部執行並且執行的過程不會被任何因素打斷,要么就都不執行。
2.實例
一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那么必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。
試想一下,如果這2個操作不具備原子性,會造成什么樣的后果。假如從賬戶A減去1000元之后,操作突然中止。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。
所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。
同樣地反映到並發編程中會出現什么結果呢?
舉個最簡單的例子,大家想一下假如為一個32位的變量賦值過程不具備原子性的話,會發生什么后果?
i = 9;
假若一個線程執行到這個語句時,我暫且假設為一個32位的變量賦值包括兩個過程:為低16位賦值,為高16位賦值。
那么就可能發生一種情況:當將低16位數值寫入之后,突然被中斷,而此時又有一個線程去讀取i的值,那么讀取到的就是錯誤的數據。
3.Java中的原子性
在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要么執行,要么不執行。
上面一句話雖然看起來簡單,但是理解起來並不是那么容易。看下面一個例子i:
請分析以下哪些操作是原子性操作:
x = 10; //語句1
y = x; //語句2
x++; //語句3
x = x + 1; //語句4
咋一看,可能會說上面的4個語句中的操作都是原子性操作。其實只有語句1是原子性操作,其他三個語句都不是原子性操作。
語句1是直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。
語句2實際上包含2個操作,它先要去讀取x的值,再將x的值寫入工作內存,雖然讀取x的值以及 將x的值寫入工作內存 這兩個操作都是原子性操作,但是合起來就不是原子性操作了。
同樣的,x++和 x = x+1包括3個操作:讀取x的值,進行加1操作,寫入新的值。
所以上面4個語句只有語句1的操作具備原子性。
也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。
從上面可以看出,Java內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大范圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。
關於synchronized和Lock的使用,參考:關於synchronized和ReentrantLock之多線程同步詳解
1.定義:在執行程序時,為了提高性能,編譯器和處理器會對指令做重排序。
下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。
2.實例
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什么呢?這里可能會發生指令重排序(Instruction Reorder)。
比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那么就有可能在執行過程中,語句2先執行而語句1后執行。
但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?再看下面一個例子:
int a = 10; //語句1
int r = 2; //語句2
a = a + 3; //語句3
r = a*a; //語句4
這段代碼有4個語句,那么可能的一個執行順序是:
那么可不可能是這個執行順序呢: 語句2 語句1 語句4 語句3
不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那么處理器會保證Instruction 1會在Instruction 2之前執行。
雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:
//線程1:
context = loadContext(); //語句1
inited = true; //語句2
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那么就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。
從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程並發執行的正確性。
也就是說,要想並發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
3.Java中的有序性
在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程並發執行的正確性。
在Java里面,可以通過volatile關鍵字來保證一定的“有序性”。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。
關於volatile 和 sychronized的區別詳見:volatile和synchronized的區別
1.定義:可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
2.實例:
//線程1執行的代碼
int i = 0;
i = 10;
//線程2執行的代碼
j = i;
由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值加載到自己線程的工作內存中,然后賦值為10,那么在線程1的工作內存當中i的值變為10了,卻沒有立即寫入到主存當中。
此時線程2執行 j = i,它會先去主存讀取i的值並加載到線程2的工作內存當中,注意此時內存當中i的值還是0,那么就會使得j的值為0,而不是10.
這就是可見性問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。
3.Java中的可見性
對於可見性,Java提供了volatile關鍵字來保證可見性。
當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。
而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。
另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,並且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。
上面講到了,通過內存屏障可以禁止特定類型處理器的重排序,從而讓程序按我們預想的流程去執行。內存屏障,又稱內存柵欄,是一個CPU指令,基本上它是一條這樣的指令:
- 保證特定操作的執行順序。
- 影響某些數據(或則是某條指令的執行結果)的內存可見性。
編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化性能。插入一條Memory Barrier會告訴編譯器和CPU:不管什么指令都不能和這條Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier
(寫入屏障)將刷出所有在Barrier之前寫入 cache 的數據,因此,任何CPU上的線程都能讀取到這些數據的最新版本。
這和java有什么關系?上面java線程並發中講到的volatile就是基於Memory Barrier實現的。
如果一個變量是volatile
修飾的,JMM會在寫入這個字段之后插進一個Write-Barrier
指令,並在讀這個字段之前插入一個Read-Barrier
指令。這意味着,如果寫入一個volatile
變量,就可以保證:
- 一個線程寫入變量a后,任何線程訪問該變量都會拿到最新值。
- 在寫入變量a之前的寫入操作,其更新的數據對於其他線程也是可見的。因為Memory Barrier會刷出cache中的所有先前的寫入。
從jdk5開始,java使用新的JSR-133內存模型,基於happens-before的概念來闡述操作之間的內存可見性。
在JMM中,如果一個操作的執行結果需要對另一個操作可見,那么這兩個操作之間必須要存在happens-before關系,這里的兩個操作既可以在同一個線程,也可以在不同的兩個線程中。
與程序員密切相關的happens-before規則如下:
- 程序順序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作
- 監視器鎖規則:一個unLock操作先行發生於后面對同一個鎖的lock操作。
- volatile域規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作
- 傳遞性規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。
下面我們來解釋一下前4條規則:
第一條對於程序次序規則來說,就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生於書寫在后面的操作”,這個應該是程序看起來執行的順序是按照代碼順序執行的,但是虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。
第二條規則也比較容易理解,也就是說無論在單線程中還是多線程中,同一個鎖如果處於被鎖定的狀態,那么必須先對鎖進行了釋放操作,后面才能繼續進行lock操作。
第三條規則是一條比較重要的規則。直觀地解釋就是,如果一個線程先去寫一個變量,然后一個線程去進行讀取,那么寫入操作肯定會先行發生於讀操作。
第四條規則實際上就是體現happens-before原則具備傳遞性。
注意:兩個操作之間具有happens-before關系,並不意味前一個操作必須要在后一個操作之前執行!僅僅要求前一個操作的執行結果,對於后一個操作是可見的,且前一個操作按順序排在后一個操作之前。