一、Java內存模型介紹
內存模型的作用范圍:
在Java中,所有實例域、靜態域和數組元素存放在堆內存中,線程之間共享,下文稱之為“共享變量”。局部變量、方法參數、異常處理器等不會在線程之間共享,不存在內存可見性問題,也不受內存模型的影響。
重排序與可見性:
現代編譯器在編譯源碼時會做一些優化處理,對代碼指令進行重排序;現代流水線結構的處理器為了提高並行度,在執行時也可能對指令做一些順序上的調整。重排序包括編譯器重排序、指令級並行重排序和內存系統重排序等。一般來說,編譯器和處理器在做重排序的時候都會做一些保證,保證程序的執行結果與重排序之前指令的執行結果相同。即as-if-serial,不管怎樣重排序,都不能改變程序的執行結果。
CPU在執行指令時一般都會使用緩存技術來提高效率,如果不同線程使用不同的緩存空間則會造成一個線程對一個共享變量的更新不能及時反映給其他線程,也就是多線程對共享變量更新的可見性問題,這個問題是非常復雜的。
Java內存模型的抽象:
對於上述問題,Java內存模型(JMM)為程序員提供了一個抽象層面的描述,我們不用去關心編譯器、處理器對指令做了怎樣的重排序,也不用關心復雜的系統緩存機制,只要遵循JMM的規則,JMM就能為我們提供代碼順序性、共享變量可見性的保證,從而得到預期的執行結果。
JMM決定了一個線程對共享變量的寫入何時對另一個線程可見。從抽象來講,線程共享變量存放在主內存(main memory),每個線程持有一個本地內存(local memory),本地內存中存儲了該線程讀寫共享變量的副本(本地內存是JMM的一個抽象概念,並不是真實存在的)。如下圖:
如果A、B兩個線程要通信要經過以下兩步:首先線程A將本地內存中更新過的共享變量刷新到主內存中,然后線程B到主內存中讀取A之前更新過的變量。
JMM通過控制主內存與每個線程的本地內存之間的交互來為Java程序員提供可見性保證。
重排序:
現代編譯器和處理器會對指令執行的順序進行重排序,以此提高程序的性能。這些重排序可能會導致多線程程序出現內存可見性問題。為了不改變程序的執行結果,對於編譯器,JMM會禁止特定類型的編譯器重排序;對於處理器重排序,JMM要求在Java編譯生產指令序列時,插入特定類型的內存屏障(memory barriers)來禁止特定類型的重排序。
JMM把內存屏障分為以下四類:
屏障類型 | 指令示例 | 說明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 確保Load1數據的裝載之前於在Load2及其所有后續裝載指令 |
StoreStore Barriers | Store1; StoreStore; Store2 | 確保Store1刷新數據到內存之前與Store2及其后續存儲指令 |
LoadStore Barriers | Load1; LoadStore; Store2 | 確保Load1數據裝載之前於Store2及其后續存儲指令 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 確保Store1刷新數據到內存之前於Load2及其后續裝載指令。 StoreLoad Barriers會使該屏障之前的所有內存訪問指令完成后才執行屏障后的指令。 |
StoreLoad Barriers是一個全能型屏障,同時具有其他三個屏障的效果。
Happens-before:
從JDK1.5開始,Java使用新的JSR-133內存模型(以下所有都是針對該內存模型講的),使用happens-before的概念來闡述操作之間的內存可見性。
如果一個操作要對另一個操作可見,那這兩個操作之間必須存在happens-before關系。這兩個操作可以在一個線程內,也可以在不同線程之間。ps.(兩個操作存在happens-before關系並不意味着前一個操作必須在后一個操作之前執行,僅僅要求前一個操作對后一個操作可見。)
常見的與程序員相關的happens-before規則如下:
①程序順序規則:一個線程中的每個操作happens-before於其后的任意操作;
②監視器鎖規則:對一個監視器的解鎖happens-before於隨后對這個監視器的加鎖;
③volatile規則:對一個volatile域的寫happens-before於任意后續對該域的讀操作(該規則多個線程之間也成立);
④傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C
數據依賴性:如果兩個操作訪問同一個變量,且這兩個操作其中一個為寫操作時,這兩個操作就存在數據依賴性。如下示例:
寫后讀 | a=1; b=a; |
寫后寫 | a=1; a=2; |
讀后寫 | a=b; b=a; |
上述三類情況存在數據依賴性,此時不允許重排序,否則程序的結果可能會改變。
as-if-serial語義:
as-if-serial語義的意思是:在單線程內,不管怎么重排序,程序的執行結果不變,在程序員看來,就像順序執行的一樣。
示例:
a =
1
;
//A b = 2 ; //B c = a + b; //C |
前兩條語句就可以進行重排序,而第三條語句與前兩條存在依賴關系,不能重排序。
上述A happens-before B,B happens-before C,但並不保證A在B之前執行,只需要保證操作A對B可見(這里A操作不需要對B可見,因此可以重排)
重排序對多線程的影響:
示例:
class
Demo { boolean flag = false; int a = 0 ; public void fun1() { a = 1 ; //A flag = true; //B } public void fun2() { if (flag) { //C a = a + a; //D } } } |
假設上述類中fun1()和fun2()在不同線程中執行,操作A、B沒有依賴關系,可能被重排序;操作C、D雖然存在控制依賴關系,現代編譯器和處理器為了提高並行度,可能采取激進的方法(即先求出if語句塊中的值存於臨時變量中,如果if條件為真則使用該值,否則丟棄)對其進行重排序,這都可能改變程序的執行結果。
順序一致性內存模型:
計算機科學家們提出了一個理想化的理論參考模型--順序一致性模型,它為程序員提供了極強的內存可見性,具有如下兩大特性:
①一個線程中的所有操作必須按照程序順序來執行;
②所有線程(無論同步與否)都只能看到一個單一的操作執行順序。每個操作都必須是原子的且立刻對所有線程可見。
示例:
假設有A和B兩個線程並發執行,A線程中有三個操作,順序是A1->A2->A3,線程B中也有三個操作,順序是B1->B2->B3。 先假設這兩個線程使用監視器同步,A線程先獲得監視器,執行完畢釋放監視器后線程B開始執行。那么他們在順序一致性模型中執行效果如下:
現在我們再假設這兩個線程未進行同步,其在順序一致性模型中執行效果如下:
可以看到,未同步的程序在順序一致性模型中雖然整體執行順序是無序的,但所有線程都只看到一個一致的整體執行順序。如上圖,線程A和B看到的執行順序都是B1->A1->A2->B2-A3->B3。之所以能得到這個保證是因為順序一致性內存模型中的每個操作必須立即對任何線程可見。
但是JMM中沒有這個保證。比如當前線程寫數據到本地內存中,在還沒有刷新到主內存之前,這個寫操作只對當前線程可見,從其他線程角度觀察,可以認為這個寫操作根本還沒有被當前線程執行過。這種情況下,當前線程和其他線程看到的操作執行順序將不一致。
同步程序的一致性效果:
示例:
class
SynchronizedDemo { int a = 0 ; boolean flag = false; public synchronized void write() { a = 1 ; flag = true; } public synchronized void read() { if (flag) { int i = a; } } } |
上述代碼使用同步方法,線程A先執行write()方法,釋放鎖后線程B獲取鎖並執行read()方法,執行流程如下:
在順序一致性模型中,所有操作按順序執行。在JMM中,臨界區內的代碼可以重排序(JMM不允許臨界區內的代碼“逸出”到臨界區之外),JMM會在進入和退出臨界區的關鍵點上做一些限定,使得現場在這兩個關鍵點處具有和順序一致性模型具有相同的內存視圖。雖然現場A在臨界區內做了重排序,但由於監視器的互斥性,這里線程B根本無法“觀察”到線程A在臨界區內的重排序,這樣既提高了效率又不改變程序的執行結果。
對於未同步的多線程程序,JMM只提供最小安全性:線程執行讀操作取得的值,要么是之前線程寫入的,要么是默認值(0,null,false),JMM保證線程讀取的數據不是無中生有冒出來的。為了實現最小安全,JVM在堆上分配對象時首先會清空內存空間,然后才分配對象(因此對象分配時,域的默認初始化已經完成)。
此外,JMM的最小安全不保證對64位的long和double型變量的讀寫具有原子性,而順序一致性模型保證對所有內存讀寫操作具有原子性。
二、Volatile特性
volatile變量的單次讀寫,相當於使用了一個鎖對這些單個讀/寫做了同步。
原子性:對volatile變量的單次讀寫操作具有原子性(ps.這里存在爭議,暫且這么寫,保留意見);
可見性:鎖的happens-before規則保證釋放鎖和獲取鎖的兩個線程之間的可見性,這意味着對一個volatile變量的讀操作總能看到之前任意線程對這個volatile變量最后的寫入,即對volatile變量的寫操作對其他線程立即可見。
當寫一個volatile變量時,JMM會把線程對應的本地內存中的共享變量刷新到主內存;
當讀一個volatile變量時,JMM會把改下昵稱對應的本地內存置為無效,接下來從主內存中讀取共享變量的值。
從內存語義的角度來說,volatile的寫-讀於鎖的釋放-獲取具有相同的內存效果。因此如果線程A對volatile變量的寫操作在線程B對volatile變量的讀操作之前,則其存在happens-before關系。
示例:
class VolatileDemo { volatile boolean flag = false; int a=0;
public void fun1() { a=1; //A flag = true; //B } public void fun2() { if (flag) { //C a=a+a; //D } } }
上述操作A happens-before 操作B,操作C happens-before 操作D,如果線程1調用fun1()方法之后線程2調用fun2()方法,則操作B happens-before 操作C,根據happens-before的傳遞性,則有A happens-before D,因此可以保證操作D可以正確讀取到操作A的賦值。
Volatile的內存語義是JMM通過在volatile讀寫操作前后插入內存屏障實現的。
三、鎖的特性
鎖的釋放與獲取遵循happens-before規則,釋放鎖線程臨界區的操作結果對獲取鎖的線程可見。
當線程釋放鎖時,JMM會把線程對應的本地內存中的共享變量刷新到主內存;
當線程獲取鎖時,JMM會把改下昵稱對應的本地內存置為無效,接下來從主內存中讀取共享變量的值。
ReentrantLock是java.util.concurrent.locks包下的一個鎖的實現,依賴對volatile變量的讀寫和compareAndSet(CAS)操作實現鎖機制。其中CAS操作使用不同的CPU指令實現單次操作的原子性,具有volatile讀寫操作相同的內存語義。 類圖如下:
ReentrantLock根據對搶占鎖的線程的處理方式不同,分為公平鎖和非公平鎖,首先看公平鎖,使用公平鎖加鎖時,加鎖方法lock()的方法調用主要有以下四步:
1. ReentrantLock : lock() 2. FairSync : lock() 3. AbstractQueuedSynchronizer : acquire(int arg) 4. ReentrantLock : tryAcquire(int acquires)
在第四步才開始真正加鎖,該方法的源碼如下:
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState();();//獲取鎖的開始,state是volatile類型變量 if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
從上面方法可以看出,加鎖方法首先讀取volatile變量state。
使用公平鎖的unlock()方法調用軌跡如下:
1. ReentrantLock : unlock() 2. AbstractQueuedSynchronizer : release(int arg) 3. Sync : tryRelease(int releases)
在第三步調用時才真正開始釋放鎖,該方法源碼如下:
protected final boolean tryRelease (int releases){ int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false ; if (c == 0){ free = true ; setExclusiveOwnerThread( null ); } setState(c);//釋放鎖后,寫volatile變量state return free; }
從上面代碼可以看出,在釋放鎖的最后寫volatile變量state。
公平鎖在釋放鎖的最后寫volatile變量state,在獲取鎖的時候首先讀這個volatile變量。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變量之后該變量對獲取鎖的線程可見。
Java中的CAS操作同時具有volatile讀和volatile寫的內存語義,因此Java線程之間通信現在有了以下四種方式:
1、A線程寫volatile變量,隨后B線程讀這個volatile變量。
2、A線程寫volatile變量,隨后B線程用CAS更新這個volatile變量。
3、A線程用CAS更新一個volatile變量,隨后B線程用CAS更新這個volatile變量。
4、A線程用CAS更新一個volatile變量,隨后B線程讀這個volatile變量。A線程寫
Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操作,同時,volatile變量的讀/寫和CAS可以實現線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:
1、首先,聲明共享變量為volatile;
2、然后,使用CAS的原子條件更新來實現線程之間的同步;
3、同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信。
四、Final 的特性
與前面介紹的鎖和volatile相比較,對final域的讀和寫更像是普通的變量訪問。對於final域,編譯器和處理器要遵守兩個(分別對應讀寫)重排序規則:
1、在構造函數內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
2、初次讀一個包含final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。
寫final域的重排序規則
寫final域的重排序規則禁止把final域的寫重排序到構造函數之外。這個規則的實現包含下面2個方面:
1、JMM禁止編譯器把final域的寫重排序到構造函數之外。
2、編譯器會在final域的寫之后,構造函數return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數之外 。
寫final域的重排序規則可以確保:在對象引用為任意線程可見之前,對象的final域已經被正確初始化過了,而普通域不具有這個保障
對於引用類型,寫final域的重排序規則對編譯器和處理器增加了如下約束:
在構造函數內對一個final引用的對象的成員域的寫入,與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
讀final域的重排序規則
在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。初次讀對象引用與初次讀該對象包含的final域,這兩個操作之間存在間接依賴關系。由於編譯器遵守間接依賴關系,因此編譯器不會重排序這兩個操作。
讀final域的重排序規則可以確保:在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用。在這個示例程序中,如果該引用不為null,那么引用對象的final域一定已經被A線程初始化過了。