在併發編程中,須要處理兩個關鍵問題:線程之間如何通訊及線程之間如何同步(這裏的線程是指併發執行的活動實體)。通訊是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通訊機制有兩種:共享內存和消息傳遞。java
在共享內存的併發模型裏,線程之間共享程序的公共狀態,經過寫-讀內存中的公共狀態進行隱式通訊。在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過發送消息來顯式進行通訊。程序員
同步是指程序中用於控制不一樣線程間操做發生相對順序的機制。在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥執行。在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前,所以同步是隱式進行的。編程
Java的併發採用的是共享內存模型,Java線程之間的通訊老是隱式進行,整個通訊過程對程序員徹底透明。若是編寫多線程程序的Java程序員不理解隱式進行的線程之間通訊的工做機制,極可能會遇到各類奇怪的內存可見性問題。數組
Java線程之間的通訊由Java內存模型(本文簡稱爲JMM)控制,JMM決定一個線程對共享變量的寫入什麼時候對另外一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(Main Memory)中,每一個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化緩存
若是線程A與線程B之間要通訊的話,必需要經歷下面2個步驟。安全
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A以前已更新過的共享變量。多線程
在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序。重排序分3種類
型。併發
1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。
2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。
3)內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。app
上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會致使多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是全部的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,經過內存屏障指令來禁止特定類型的處理器重排序。框架
JMM屬於語言級的內存模型,它確保在不一樣的編譯器和不一樣的處理器平臺之上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。
從JDK 5開始,Java使用新的JSR-133內存模型(除非特別說明,本文針對的都是JSR-133內存模型)。JSR-133使用happens-before的概念來闡述操做之間的內存可見性。在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必需要存在happens-before關係。這裏提到的兩個操做既能夠是在一個線程以內,也能夠是在不一樣線程之間。
與程序員密切相關的happens-before規則以下。
·程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
·監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
·volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的
讀。
·傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
兩個操做之間具備happens-before關係,並不意味着前一個操做必需要在後一個操做以前執行!happens-before僅僅要求前一個操做(執行的結果)對後一個操做可見,且前一個操做按順序排在第二個操做以前(the first is visible to and ordered before the second)。happens-before的定義很微妙,後文會具體說明happens-before爲何要這麼定義。
重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段。
as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。
爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。可是,若是操做之間不存在數據依賴關係,這些操做就可能被編譯器和處理器重排序。
根據happens-before的程序順序規則,上面計算圓的面積的示例代碼存在3個happens-before
關係。
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
這裏的第3個happens-before關係,是根據happens-before的傳遞性推導出來的。
如今讓咱們來看看,重排序是否會改變多線程程序的執行結果。請看下面的示例代碼。
public class ReorderExample { int a = 0; boolean flag = false; public void writer(){ a = 1; //1 flag = true; //2 } public void reader(){ if(flag){ //3 int i = a * a; //4 //....... } } }
flag變量是個標記,用來標識變量a是否已被寫入。這裏假設有兩個線程A和B,A首先執行writer()方法,隨後B線程接着執行reader()方法。線程B在執行操做4時,可否看到線程A在操做1對共享變量a的寫入呢?
答案是:不必定能看到。
因爲操做1和操做2沒有數據依賴關係,編譯器和處理器能夠對這兩個操做重排序;一樣,操做3和操做4沒有數據依賴關係,編譯器和處理器也能夠對這兩個操做重排序。
在單線程程序中,對存在控制依賴的操做重排序,不會改變執行結果(這也是as-if-serial語義容許對存在控制依賴的操做作重排序的緣由);但在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。
順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型做爲參照。
當程序未正確同步時,就可能會存在數據競爭。Java內存模型規範對數據競爭的定義以下。
在一個線程中寫一個變量,
在另外一個線程讀同一個變量,
並且寫和讀沒有經過同步來排序。
當代碼中包含數據競爭時,程序的執行每每產生違反直覺的結果(前一章的示例正是如此)。若是一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。JMM對正確同步的多線程程序的內存一致性作了以下保證。
若是程序是正確同步的,程序的執行將具備順序一致性(Sequentially Consistent)——即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。立刻咱們就會看到,這對於程序員來講是一個極強的保證。這裏的同步是指廣義上的同步,包括對經常使用同步原語(synchronized、volatile和final)的正確使用。
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它爲程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性。
1)一個線程中的全部操做必須按照程序的順序來執行。
2)(無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性內存模型中,每一個操做都必須原子執行且馬上對全部線程可見。
在概念上,順序一致性模型有一個單一的全局內存,這個內存經過一個左右擺動的開關能夠鏈接到任意一個線程,同時每個線程必須按照程序的順序來執行內存讀/寫操做。從上面的示意圖能夠看出,在任意時間點最多隻能有一個線程能夠鏈接到內存。當多個線程併發執行時,圖中的開關裝置能把全部線程的全部內存讀/寫操做串行化(即在順序一致性模型中,全部操做之間具備全序關係)。
可是,在JMM中就沒有這個保證。未同步程序在JMM中不但總體的執行順序是無序的,並且全部線程看到的操做執行順序也可能不一致。好比,在當前線程把寫過的數據緩存在本地內存中,在沒有刷新到主內存以前,這個寫操做僅對當前線程可見;從其餘線程的角度來觀察,會認爲這個寫操做根本沒有被當前線程執行。只有當前線程把本地內存中寫過的數據刷新到主內存以後,這個寫操做才能對其餘線程可見。在這種狀況下,當前線程和其餘線程看到的操做執行順序將不一致。
下面,對前面的示例程序ReorderExample用鎖來同步,看看正確同步的程序如何具備順序一致性。
public class SynchronizedExample { int a = 0; boolean flag = false; public synchronized void writer(){//獲取鎖 a = 1; flag = true; } //釋放鎖 public synchronized void reader(){//獲取鎖 if(flag){ int i = a; //.... } //釋放鎖 } }
順序一致性模型中,全部操做徹底按程序的順序串行執行。而在JMM中,臨界區內的代碼能夠重排序(但JMM不容許臨界區內的代碼「逸出」到臨界區以外,那樣會破壞監視器的語義)。JMM會在退出臨界區和進入臨界區這兩個關鍵時間點作一些特別處理,使得線程在這兩個時間點具備與順序一致性模型相同的內存視圖(具體細節後文會說明)。雖然線程A在臨界區內作了重排序,但因爲監視器互斥執行的特性,這裏的線程B根本沒法「觀察」到線程A在臨界區內的重排序。這種重排序既提升了執行效率,又沒有改變程序的執行結果。
從這裏咱們能夠看到,JMM在具體實現上的基本方針爲:在不改變(正確同步的)程序執行結果的前提下,儘量地爲編譯器和處理器的優化打開方便之門。
對於未同步或未正確同步的多線程程序,JMM只提供最小安全性:線程執行時讀取到的值,要麼是以前某個線程寫入的值,要麼是默認值(0,Null,False),JMM保證線程讀操做讀取到的值不會無中生有(Out Of Thin Air)的冒出來。爲了實現最小安全性,JVM在堆上分配對象時,首先會對內存空間進行清零,而後纔會在上面分配對象(JVM內部會同步這兩個操做)。所以,在已清零的內存空間(Pre-zeroed Memory)分配對象時,域的默認初始化已經完成了。
未同步程序在JMM中的執行時,總體上是無序的,其執行結果沒法預知。未同步程序在兩個模型中的執行特性有以下幾個差別。
1)順序一致性模型保證單線程內的操做會按程序的順序執行,而JMM不保證單線程內的操做會按程序的順序執行(好比上面正確同步的多線程程序在臨界區內的重排序)。這一點前面已經講過了,這裏就再也不贅述。
2)順序一致性模型保證全部線程只能看到一致的操做執行順序,而JMM不保證全部線程能看到一致的操做執行順序。這一點前面也已經講過,這裏就再也不贅述。
3)JMM不保證對64位的long型和double型變量的寫操做具備原子性,而順序一致性模型保證對全部的內存讀/寫操做都具備原子性。
第3個差別與處理器總線的工做機制密切相關。在計算機中,數據經過總線在處理器和內存之間傳遞。每次處理器和內存之間的數據傳遞都是經過一系列步驟來完成的,這一系列步驟稱之爲總線事務(Bus Transaction)。總線事務包括讀事務(Read Transaction)和寫事務(WriteTransaction)。讀事務從內存傳送數據處處理器,寫事務從處理器傳送數據到內存,每一個事務會讀/寫內存中一個或多個物理上連續的字。這裏的關鍵是,總線會同步試圖併發使用總線的事務。在一個處理器執行總線事務期間,總線會禁止其餘的處理器和I/O設備執行內存的讀/寫。
總線的這些工做機制能夠把全部處理器對內存的訪問以串行化的方式來執行。在任意時間點,最多隻能有一個處理器能夠訪問內存。這個特性確保了單個總線事務之中的內存讀/寫操做具備原子性。
在一些32位的處理器上,若是要求對64位數據的寫操做具備原子性,會有比較大的開銷。爲了照顧這種處理器,Java語言規範鼓勵但不強求JVM對64位的long型變量和double型變量的寫操做具備原子性。當JVM在這種處理器上運行時,可能會把一個64位long/double型變量的寫操做拆分爲兩個32位的寫操做來執行。這兩個32位的寫操做可能會被分配到不一樣的總線事務中執行,此時對這個64位變量的寫操做將不具備原子性。
注意,在JSR-133以前的舊內存模型中,一個64位long/double型變量的讀/寫操做能夠被拆分爲兩個32位的讀/寫操做來執行。從JSR-133內存模型開始(即從JDK5開始),僅僅只容許把一個64位long/double型變量的寫操做拆分爲兩個32位的寫操做來執行,任意的讀操做在JSR-133中都必須具備原子性(即任意讀操做必需要在單個讀事務中執行)。
理解volatile特性的一個好方法是把對volatile變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。下面經過具體的示例來講明,示例代碼以下。
public class VolatileFeaturesExample { volatile long v1 = 0L; //使用volatile聲明64位的long型變量 public void set(long l){ v1 = 1; // 單個volatile變量的寫 } public void getAndIncrement(){ v1++; //複合(多個)volatitle變量的讀/寫 } public long get(){ return v1; //單個volatile變量的的讀 } }
假設有多個線程分別調用上面程序的3個方法,這個程序在語義上和下面程序等價。
public class VolatileFeaturesExample { long v1 = 0L; //64位的long型普通變量 public synchronized void set(long l){ v1 = 1; // 對單個的普通變量的寫用同一個鎖同步 } public void getAndIncrement(){//普通方法調用 long temp = get(); //調用已同步的讀方法; temp +=1L; //普通寫操做 set(temp); //調用已同步的寫方法 } public synchronized long get(){ return v1; //對單個的普通變量的讀用同一個鎖同步 } }
如上面示例程序所示,一個volatile變量的單個讀/寫操做,與一個普通變量的讀/寫操做都是使用同一個鎖來同步,它們之間的執行效果相同。
鎖的happens-before規則保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這意味着對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
鎖的語義決定了臨界區代碼的執行具備原子性。這意味着,即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。
簡而言之,volatile變量自身具備下列特性。
·可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
·原子性:對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操做不具備原子性。
從JSR-133開始(即從JDK5開始),volatile變量的寫-讀能夠實現線程之間的通訊。
從內存語義的角度來講,volatile的寫-讀與鎖的釋放-獲取有相同的內存效果:volatile寫和鎖的釋放有相同的內存語義;volatile讀與鎖的獲取有相同的內存語義。
public class VolatileExample { int a = 0; volatile boolean flag = false; public void writer(){ a = 1; //1 flag = true; //2 } public void reader(){ if(flag) { //3 int i =a; //4 //...... } } }
假設線程A執行writer()方法以後,線程B執行reader()方法。根據happens-before規則,這個過程創建的happens-before關係能夠分爲3類:
1)根據程序次序規則,1 happens-before 2;3 happens-before 4。
2)根據volatile規則,2 happens-before 3。
3)根據happens-before的傳遞性規則,1 happens-before 4。
這裏A線程寫一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量以前全部可見的共享變量,在B線程讀同一個volatile變量後,將當即變得對B線程可見。
以上面示例程序VolatileExample爲例,假設線程A首先執行writer()方法,隨後線程B執行reader()方法,初始時兩個線程的本地內存中的flag和a都是初始狀態。
線程A在寫flag變量後,本地內存A中被線程A更新過的兩個共享變量的值被刷新到主內存中。此時,本地內存A和主內存中的共享變量的值是一致的。
volatile讀的內存語義以下。
當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。
在讀flag變量後,本地內存B包含的值已經被置爲無效。此時,線程B必須從主內存中讀取共享變量。線程B的讀取操做將致使本地內存B與主內存中的共享變量的值變成一致。
若是咱們把volatile寫和volatile讀兩個步驟綜合起來看的話,在讀線程B讀一個volatile變量後,寫線程A在寫這個volatile變量以前全部可見的共享變量的值都將當即變得對讀線程B可見。
下面對volatile寫和volatile讀的內存語義作個總結。
·線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所作修改的)消息。
·線程B讀一個volatile變量,實質上是線程B接收了以前某個線程發出的(在寫這個volatile變量以前對共享變量所作修改的)消息。
·線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A經過主內存向線程B發送消息。
鎖是Java併發編程中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可讓釋放鎖的線程向獲取同一個鎖的線程發送消息。
class MonitorExample{ int a = 0; public synchronized void writer(){//1 a++; //2 } //3 public synchronized void reader(){//4 int i =a ; //5 //..... } //6 }
假設線程A執行writer()方法,隨後線程B執行reader()方法。根據happens-before規則,這個過程包含的happens-before關係能夠分爲3類。
1)根據程序次序規則,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens-before6。
2)根據監視器鎖規則,3 happens-before 4。
3)根據happens-before的傳遞性,2 happens-before 5。
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。以上面的MonitorExample程序爲例.
當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量.
對比鎖釋放-獲取的內存語義與volatile寫-讀的內存語義能夠看出:鎖釋放與volatile寫有相同的內存語義;鎖獲取與volatile讀有相同的內存語義。
下面對鎖釋放和鎖獲取的內存語義作個總結。
·線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了(線程A對共享變量所作修改的)消息。
·線程B獲取一個鎖,實質上是線程B接收了以前某個線程發出的(在釋放這個鎖以前對共享變量所作修改的)消息。
·線程A釋放鎖,隨後線程B獲取這個鎖,這個過程實質上是線程A經過主內存向線程B發送消息。
本文將藉助ReentrantLock的源代碼,來分析鎖內存語義的具體實現機制。
public class ReentrantLockExample { int a = 0; ReentrantLock lock = new ReentrantLock(); public void writer(){ lock.lock(); try{ a++; }finally{ lock.unlock(); } } public void reader(){ lock.lock(); try{ int i = a; }finally{ lock.unlock(); } } }
ReentrantLock的實現依賴於Java同步器框架AbstractQueuedSynchronizer(本文簡稱之爲AQS)。AQS使用一個整型的volatile變量(命名爲state)來維護同步狀態,立刻咱們會看到,這個volatile變量是ReentrantLock內存語義實現的關鍵。
ReentrantLock分爲公平鎖和非公平鎖,咱們首先分析公平鎖。
1)ReentrantLock:lock()。
2)FairSync:lock()。
3)AbstractQueuedSynchronizer:acquire(int arg)。
4)ReentrantLock:tryAcquire(int acquires)。
在第4步真正開始加鎖,下面是該方法的源代碼。
protected final boolean tryAcquire(int acquires){ final Thread current = Thread.currentThread(); int c = getState(); //獲取鎖的開始,首先讀volatile變量state if(c == 0){ if(isFirst(current) && 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; }
在使用公平鎖時,解鎖方法unlock()調用軌跡以下。
1)ReentrantLock:unlock()。
2)AbstractQueuedSynchronizer:release(int arg)。
3)Sync:tryRelease(int releases)。
在第3步真正開始釋放鎖,下面是該方法的源代碼。
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變量以前可見的共享變量,在獲取鎖的線程讀取同一個volatile變量後將當即變得對獲取鎖的線程可見。
如今咱們來分析非公平鎖的內存語義的實現。非公平鎖的釋放和公平鎖徹底同樣,因此這裏僅僅分析非公平鎖的獲取。使用非公平鎖時,加鎖方法lock()調用軌跡以下。
1)ReentrantLock:lock()。
2)NonfairSync:lock()。
3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
在第3步真正開始加鎖,下面是該方法的源代碼
protected final boolean compareAndSetState(int expect,int update){ return unsafe.compareAndSwapInt(this,stateOffset,expect,update); }
該方法以原子操做的方式更新state變量,本文把Java的compareAndSet()方法調用簡稱爲CAS。JDK文檔對該方法的說明以下:若是當前狀態值等於預期值,則以原子方式將同步狀態設置爲給定的更新值。此操做具備volatile讀和寫的內存語義。
前文咱們提到過,編譯器不會對volatile讀與volatile讀後面的任意內存操做重排序;編譯器不會對volatile寫與volatile寫前面的任意內存操做重排序。組合這兩個條件,意味着爲了同時實現volatile讀和volatile寫的內存語義,編譯器不能對CAS與CAS前面和後面的任意內存操做重排序。
如今對公平鎖和非公平鎖的內存語義作個總結。
·公平鎖和非公平鎖釋放時,最後都要寫一個volatile變量state。
·公平鎖獲取時,首先會去讀volatile變量。
·非公平鎖獲取時,首先會用CAS更新volatile變量,這個操做同時具備volatile讀和volatile寫的內存語義。
從本文對ReentrantLock的分析能夠看出,鎖釋放-獲取的內存語義的實現至少有下面兩種方式。
1)利用volatile變量的寫-讀所具備的內存語義。
2)利用CAS所附帶的volatile讀和volatile寫的內存語義。
因爲Java的CAS同時具備volatile讀和volatile寫的內存語義,所以Java線程之間的通訊如今有了下面4種方式。
1)A線程寫volatile變量,隨後B線程讀這個volatile變量。
2)A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
3)A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
4)A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。
Java的CAS會使用現代處理器上提供的高效機器級別的原子指令,這些原子指令以原子方式對內存執行讀-改-寫操做,這是在多處理器中實現同步的關鍵(從本質上來講,可以支持原子性讀-改-寫指令的計算機,是順序計算圖靈機的異步等價機器,所以任何現代的多處理器都會去支持某種能對內存執行原子性讀-改-寫操做的原子指令)。同時,volatile變量的讀/寫和CAS能夠實現線程之間的通訊。把這些特性整合在一塊兒,就造成了整個concurrent包得以實現的基石。若是咱們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式。
首先,聲明共享變量爲volatile。
而後,使用CAS的原子條件更新來實現線程之間的同步。
同時,配合以volatile的讀/寫和CAS所具備的volatile讀和寫的內存語義來實現線程之間的通訊。
對於final域,編譯器和處理器要遵照兩個重排序規則。
1)在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
2)初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操做之間不能重排序。
下面經過一些示例性的代碼來分別說明這兩個規則。
public class FinalExample{ int i ; //普通變量 final int j; //final變量 static FinalExample obj; public FinalExample (){ //構造函數 i =1; //寫普通域 j =2; //寫final域 } public static void writer(){ // 寫線程A執行 obj = new FinalExample(); } public static void reader(){ //讀線程B執行 FinalExample object = obj; //讀對象引用 int a = object.i; //讀普通域 int b = object.j; //讀final域 } }
這裏假設一個線程A執行writer()方法,隨後另外一個線程B執行reader()方法。下面咱們經過這兩個線程的交互來講明這兩個規則。
寫final域的重排序規則禁止把final域的寫重排序到構造函數以外。這個規則的實現包含下面2個方面。
1)JMM禁止編譯器把final域的寫重排序到構造函數以外。
2)編譯器會在final域的寫以後,構造函數return以前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數以外。
如今讓咱們分析writer()方法。writer()方法只包含一行代碼:finalExample=newFinalExample()。這行代碼包含兩個步驟,以下。
1)構造一個FinalExample類型的對象。
2)把這個對象的引用賦值給引用變量obj。
假設線程B讀對象引用與讀對象的成員域之間沒有重排序(立刻會說明爲何須要這個假
設)
寫普通域的操做被編譯器重排序到了構造函數以外,讀線程B錯誤地讀取了普通變量i初始化以前的值。而寫final域的操做,被寫final域的重排序規則「限定」在了構造函數以內,讀線程B正確地讀取了final變量初始化以後的值。
寫final域的重排序規則能夠確保:在對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,而普通域不具備這個保障。以上圖爲例,在讀線程B「看到」對象引用obj時,極可能obj對象尚未構造完成(對普通域i的寫操做被重排序到構造函數外,此時初始值1尚未寫入普通域i)。
讀final域的重排序規則是,在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操做(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操做的前面插入一個LoadLoad屏障。
初次讀對象引用與初次讀該對象包含的final域,這兩個操做之間存在間接依賴關係。因爲編譯器遵照間接依賴關係,所以編譯器不會重排序這兩個操做。大多數處理器也會遵照間接依賴,也不會重排序這兩個操做。但有少數處理器容許對存在間接依賴關係的操做作重排序(好比alpha處理器),這個規則就是專門用來針對這種處理器的。
reader()方法包含3個操做。
·初次讀引用變量obj。
·初次讀引用變量obj指向對象的普通域j。
·初次讀引用變量obj指向對象的final域i。
讀對象的普通域的操做被處理器重排序到讀對象引用以前。讀普通域時,該域尚未被寫線程A寫入,這是一個錯誤的讀取操做。而讀final域的重排序規則會把讀對象final域的操做「限定」在讀對象引用以後,此時該final域已經被A線程初始化過了,這是一個正確的讀取操做。
讀final域的重排序規則能夠確保:在讀一個對象的final域以前,必定會先讀包含這個final域的對象的引用。在這個示例程序中,若是該引用不爲null,那麼引用對象的final域必定已經被A線程初始化過了。
上面咱們看到的final域是基礎數據類型,若是final域是引用類型,將會有什麼效果?請看下列示例代碼。
public class FinalReferenceExample { final int[] intArray; //final是引用類型 static FinalReferenceExample obj; public FinalReferenceExample() {//構造函數 intArray = new int[1];//1 intArray[0] = 1;//2 } public static void writerOne(){//寫線程A執行 obj = new FinalReferenceExample();//3 } public static void writerTwo(){//寫線程B執行 obj.intArray[0] =2;//3 } public static void reader(){//讀線程C執行 if(obj !=null){//5 int temp1 = obj.intArray[0];//6 } } }
本例final域爲一個引用類型,它引用一個int型的數組對象。對於引用類型,寫final域的重排序規則對編譯器和處理器增長了以下約束:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
對上面的示例程序,假設首先線程A執行writerOne()方法,執行完後線程B執行writerTwo()方法,執行完後線程C執行reader()方法。
1是對final域的寫入,2是對這個final域引用的對象的成員域的寫入,3是把被構造的對象的引用賦值給某個引用變量。這裏除了前面提到的1不能和3重排序外,2和3也不能重排序。
JMM能夠確保讀線程C至少能看到寫線程A在構造函數中對final引用對象的成員域的寫入。即C至少能看到數組下標0的值爲1。而寫線程B對數組元素的寫入,讀線程C可能看獲得,也可能看不到。JMM不保證線程B的寫入對讀線程C可見,由於寫線程B和讀線程C之間存在數據競爭,此時的執行結果不可預知。
若是想要確保讀線程C看到寫線程B對數組元素的寫入,寫線程B和讀線程C之間須要使用同步原語(lock或volatile)來確保內存可見性。
首先,讓咱們來看JMM的設計意圖。從JMM設計者的角度,在設計JMM時,須要考慮兩個關鍵因素。
·程序員對內存模型的使用。程序員但願內存模型易於理解、易於編程。程序員但願基於一個強內存模型來編寫代碼。
·編譯器和處理器對內存模型的實現。編譯器和處理器但願內存模型對它們的束縛越少越好,這樣它們就能夠作儘量多的優化來提升性能。編譯器和處理器但願實現一個弱內存模型。
因爲這兩個因素互相矛盾,因此JSR-133專家組在設計JMM時的核心目標就是找到一個好的平衡點:一方面,要爲程序員提供足夠強的內存可見性保證;另外一方面,對編譯器和處理器的限制要儘量地放鬆。下面讓咱們來看JSR-133是如何實現這一目標的。
double pi = 3.14;//A double r = 1.0;//B double area = pi * r * r;//C
上面計算圓的面積的示例代碼存在3個happens-before關係,以下。
·A happens-before B。
·B happens-before C。
·A happens-before C。
在3個happens-before關係中,2和3是必需的,但1是沒必要要的。所以,JMM把happens-before要求禁止的重排序分爲了下面兩類。
·會改變程序執行結果的重排序。
·不會改變程序執行結果的重排序。
JMM對這兩種不一樣性質的重排序,採起了不一樣的策略,以下。
·對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
·對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不作要求(JMM容許這種重排序)。
JMM向程序員提供的happens-before規則能知足程序員的需求。JMM的happens-before規則不但簡單易懂,並且也向程序員提供了足夠強的內存可見性保證(有些內存可見性保證其實並不必定真實存在,好比上面的A happens-before B)。
JMM對編譯器和處理器的束縛已經儘量少。從上面的分析能夠看出,JMM實際上是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。例如,若是編譯器通過細緻的分析後,認定一個鎖只會被單個線程訪問,那麼這個鎖能夠被消除。再如,若是編譯器通過細緻的分析後,認定一個volatile變量只會被單個線程訪問,那麼編譯器能夠把這個volatile變量看成一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提升程序的執行效率。
JSR-133使用happens-before的概念來指定兩個操做之間的執行順序。因爲這兩個操做能夠在一個線程以內,也能夠是在不一樣線程之間。所以,JMM能夠經過happens-before關係向程序員提供跨線程的內存可見性保證(若是A線程的寫操做a與B線程的讀操做b之間存在happensbefore關係,儘管a操做和b操做在不一樣的線程中執行,但JMM向程序員保證a操做將對b操做可見)。
《JSR-133:Java Memory Model and Thread Specification》對happens-before關係的定義以下。
1)若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。
2)兩個操做之間存在happens-before關係,並不意味着Java平臺的具體實現必需要按照happens-before關係指定的順序來執行。若是重排序以後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM容許這種重排序)。
上面的1)是JMM對程序員的承諾。從程序員的角度來講,能夠這樣理解happens-before關係:若是A happens-before B,那麼Java內存模型將向程序員保證——A操做的結果將對B可見,且A的執行順序排在B以前。注意,這只是Java內存模型向程序員作出的保證!
上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM實際上是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。JMM這麼作的緣由是:程序員對於這兩個操做是否真的被重排序並不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。所以,happens-before關係本質上和as-if-serial語義是一回事。
as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變。
as-if-serial語義給編寫單線程程序的程序員創造了一個幻境:單線程程序是按程序的順序來執行的。happens-before關係給編寫正確同步的多線程程序的程序員創造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執行的。
as-if-serial語義和happens-before這麼作的目的,都是爲了在不改變程序執行結果的前提下,儘量地提升程序執行的並行度。
《JSR-133:Java Memory Model and Thread Specification》定義了以下happens-before規則。
1)程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
3)volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
4)傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
5)start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做。
6)join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。
在Java程序中,有時候可能須要推遲一些高開銷的對象初始化操做,而且只有在使用這些對象時才進行初始化。此時,程序員可能會採用延遲初始化。但要正確實現線程安全的延遲初始化須要一些技巧,不然很容易出現問題。好比,下面是非線程安全的延遲初始化對象的示例代碼。
public class UnsafeLazyInitialization{ private static Instance instance; public static Instance getInstance(){ if(instance == null){ //1.A線程執行 instance = new Instance();//2.B線程執行 } return instance; } }
在UnsafeLazyInitialization類中,假設A線程執行代碼1的同時,B線程執行代碼2。此時,線程A可能會看到instance引用的對象尚未完成初始化
對於UnsafeLazyInitialization類,咱們能夠對getInstance()方法作同步處理來實現線程安全的延遲初始化。示例代碼以下。
public class UnsafeLazyInitialization{ private static Instance instance; public synchronized static Instance getInstance(){ if(instance == null){ //1.A線程執行 instance = new Instance();//2.B線程執行 } return instance; } }
因爲對getInstance()方法作了同步處理,synchronized將致使性能開銷。若是getInstance()方法被多個線程頻繁的調用,將會致使程序執行性能的降低。反之,若是getInstance()方法不會被多個線程頻繁的調用,那麼這個延遲初始化方案將能提供使人滿意的性能。
在早期的JVM中,synchronized(甚至是無競爭的synchronized)存在巨大的性能開銷。所以,人們想出了一個「聰明」的技巧:雙重檢查鎖定(Double-Checked Locking)。人們想經過雙重檢查鎖定來下降同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的示例代碼。
public class DoubleCheckedLocking { // 1 private static Instance instance; // 2 public static Instance getInstance() { // 3 if (instance == null) { // 4:第一次檢查 synchronized (DoubleCheckedLocking.class) { // 5:加鎖 if (instance == null) // 6:第二次檢查 instance = new Instance(); // 7:問題的根源出在這裏 } // 8 } // 9 return instance; // 10 } // 11 }
如上面代碼所示,若是第一次檢查instance不爲null,那麼就不須要執行下面的加鎖和初始化操做。所以,能夠大幅下降synchronized帶來的性能開銷。上面代碼表面上看起來,彷佛一箭雙鵰。
·多個線程試圖在同一時間建立對象時,會經過加鎖來保證只有一個線程能建立對象。
·在對象建立好以後,執行getInstance()方法將不須要獲取鎖,直接返回已建立好的對象。雙重檢查鎖定看起來彷佛很完美,但這是一個錯誤的優化!在線程執行到第4行,代碼讀取到instance不爲null時,instance引用的對象有可能尚未完成初始化。
前面的雙重檢查鎖定示例代碼的第7行(instance=new Singleton();)建立了一個對象。這一行代碼能夠分解爲以下的3行僞代碼。
memory = allocate(); // 1:分配對象的內存空間 ctorInstance(memory); // 2:初始化對象 instance = memory; // 3:設置instance指向剛分配的內存地址
上面3行僞代碼中的2和3之間,可能會被重排序(在一些JIT編譯器上,這種重排序是真實發生的,詳情見參考文獻1的「Out-of-order writes」部分)。2和3之間重排序以後的執行時序以下。
memory = allocate(); // 1:分配對象的內存空間 instance = memory; // 3:設置instance指向剛分配的內存地址 // 注意,此時對象尚未被初始化! ctorInstance(memory); // 2:初始化對象
回到本文的主題,DoubleCheckedLocking示例代碼的第7行(instance=new Singleton();)若是發生重排序,另外一個併發執行的線程B就有可能在第4行判斷instance不爲null。線程B接下來將訪問instance所引用的對象,但此時這個對象可能尚未被A線程初始化!
對於前面的基於雙重檢查鎖定來實現延遲初始化的方案(指DoubleCheckedLocking示例代碼),只須要作一點小的修改(把instance聲明爲volatile型),就能夠實現線程安全的延遲初始化。請看下面的示例代碼。
public class SafeDoubleCheckedLocking{ private volatile static Instance instance; public static Instance getInstance(){ if(instance == null){ synchronized (SafeDoubleCheckedLocking.class){ if(instance == null){ instance = new Instance(); } } } return instance; } }
JVM在類的初始化階段(即在Class被加載後,且被線程使用以前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖能夠同步多個線程對同一個類的初始化。
基於這個特性,能夠實現另外一種線程安全的延遲初始化方案(這個方案被稱之爲Initialization On Demand Holder idiom)。
public class InstanceFactory{ private static class Instanceholder{ public static Instance instance = new Instance(); } public static Instance getInstance()[ return InstanceHolder.instance; //這裏將致使InstanceHolder類被初始化 ] }
經過對比基於volatile的雙重檢查鎖定的方案和基於類初始化的方案,咱們會發現基於類初始化的方案的實現代碼更簡潔。但基於volatile的雙重檢查鎖定的方案有一個額外的優點:除了能夠對靜態字段實現延遲初始化外,還能夠對實例字段實現延遲初始化。
字段延遲初始化下降了初始化類或建立實例的開銷,但增長了訪問被延遲初始化的字段的開銷。在大多數時候,正常的初始化要優於延遲初始化。若是確實須要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化的方案;若是確實須要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化的方案。