從JVM設計角度解讀Java記憶體模型

xuxh120發表於2022-01-01

第十六章:Java記憶體模型

本文我們將重點放在Java記憶體模型(JMM)的一些高層設計問題,以及JMM的底層需求和所提供的保證,還有一些高層設計原則背後的原理。

例如安全釋出,同步策略的規範以及一致性等。他們的安全性都來自於JMM,並且當你理解了這些機制的工作原理後,就能更容易的使用他們。

 

 

1、什麼是記憶體模型,為什麼要使用它

假設一個執行緒為變數aVar賦值:

a = 3;

 

記憶體模型要解決的問題是:在什麼條件下,讀取a的執行緒可以看到這個值為3。這聽起來似乎是一個愚蠢的問題,但如果缺少同步,那麼會有很多因素導致無法立即、甚至永遠看不到一個執行緒的操作結果。這包括很多因素,如果沒有使用正確的同步,例如:

  • 編譯器中生成的指令順序與原始碼中的順序不同;
  • 編譯器將變數儲存在暫存器而不是記憶體中;
  • 處理器可以亂序或者並行執行指令;
  • 快取可能會改變將寫入變數提交到主記憶體的次序;
  • 處理器中也有本地快取,對其他處理器不可見;

 

在單執行緒中,我們無法看到所有這些底層技術,他們除了提高成勳的執行速度,不會產生其他影響。Java語言規範要求JVM線上程中維護一種類似序列的語義:只要程式的最終結果與在嚴格環境中的執行結果相同,那麼上述操作都是允許的。

這確實是一件好事情,因為在近幾年中,計算效能的提升在很大的程度上要歸功於:

  • 重新排序措施;
  • 時脈頻率的提升;
  • 不斷提升的並行性;
  • 採用流水線的超標量執行單元,動態指令調整, 猜測執行以及完備的多級快取等;

隨著處理器越來越強大,編譯器也在不斷的改進,通過指令重排序實現優化執行,以及使用成熟的全域性暫存器分配演算法。由於時脈頻率越來越難以提高,因此許多處理器生產商都開始轉而生產多核處理器,因為能夠提高的只有硬體的並行性。

 

在多執行緒環境中,維護程式的序列性將導致很大的效能開銷,併發程式中的執行緒,大多數時間裡都執行各自的任務,因此執行緒之間協調操作只會降低應用程式的執行速度,不會帶來任何好處。只有當多個執行緒要共享資料時,才必須協調他們之間的操作,並且JVM依賴程式通過同步操作找出這些協調操作將何時發生。

 

JMM規定了JVM必須遵循一組最小的保證,這組保證規定了對變數的寫入操作在何時將對其他執行緒可見。

JMM在設計時就在可預測性與易於開發性之間進行了權衡,從而在各種主流的處理器體系架構上能實現高效能的JVM。如果你不瞭解在現代處理器和編譯器中使用的程式效能提升措施,那麼在剛剛接觸JMM的某些方面時會感到困惑。

  

1.1 平臺的記憶體模型

在共享記憶體的多處理器體系架構中,每個處理器擁有自己的快取,並且定期的與主記憶體進行協調。在不同的處理器架構中提供了不同級別的快取一致性(cache coherence)。其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個儲存位置上看到不同的值。作業系統、編譯器以及runtime執行時(有時甚至包括應用程式)需要彌補這種硬體能力與執行緒安全需求之間的差異。

 

要確保每個處理器在任意時刻都知道其他處理器在進行的工作,這將開銷巨大。多數情況下,這完全沒必要,可隨意放寬儲存一致性,換取效能的提升。

在架構定義的記憶體模型中將告訴應用程式可以從記憶體系統中獲取怎樣的保證,此外還定義了一些特殊的指令(稱為記憶體柵欄),當需要共享資料時,這些指令就能實現額外的儲存協調保證。為了使Java開發人員無須關心不同架構上記憶體模型之間的差異,Java還提供了自己的記憶體模型JMM,並且JVM通過在適當的位置上插入記憶體柵欄來遮蔽JMM與底層平臺記憶體模型之間的差異。

 

程式執行一種簡單的假設:想象在程式中之存在唯一的操作執行順序,而不考慮這些操作在何種處理器上執行,並且在每次讀取變數時,都能獲得在執行序列中最近一次寫入該變數的值。這種樂觀的模型被稱為序列一致性。軟體開發人員經常會錯誤地假設存在序列一致性。但是在任何一款現代多處理器架構中都不會提供這種序列一致性,JMM也是如此。馮諾依曼模型這種經典的穿行計算模型,只能近似描述現代多處理器的行為。

在現在支援共享記憶體的多處理和編譯器中,當跨執行緒共享資料時,會出現一些奇怪的情況,除非通過使用記憶體柵欄來防止這種情況的發生。幸運的是,Java程式不需要制定記憶體柵欄的位置,只需要通過正確地使用同步就可以。

 

1.2 重排序

程式清單16-1 如果沒有包含足夠的同步,將產生奇怪的結果
public class ReorderingDemo {
    static int x = 0, y = 0;
    static int a = 0, b = 0;
 
    public static void main(String[] args) throws Exception {
            x = y = a = b = 0;
            Thread one = new Thread() {
                public void run() {
                    a = 1;
                    x = b;
                }
            };

            Thread two = new Thread() {
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            one.start();
            two.start();
            one.join();
            two.join();
          System.out.println(x + ", " + y);
    }

程式清單16-1 ReorderingDemo 說明了在沒有正確的同步情況下,即使要推斷最簡單的併發程式的行為也很難。圖16-1給出了一種可能由於不同執行順序而輸出的結果。 

 

 

 

這種各種使操作延遲或者看似混亂執行的不同原因,都可以歸為重排序。

ReorderingDemo很簡單,但是要列舉出他所有可能的結果卻非常困難。記憶體級別的重排序會使程式的行為不可預測。如果沒有同步,那麼推斷出執行順序將是非常困難的,而要確保在程式中正確地使用同步卻是非常容易的。同步將限制編譯器、執行時和硬體對記憶體操作的重排序的方式,從而在實施重排序時不會破壞JMM提供的可見性保證。

注:在大多數主流的處理器架構中,記憶體模型都非常強大,使得讀取volatile變數的效能與讀取非volatile變數的效能大致相當。

 

1.3 Java記憶體模型簡介

JMM是通過各種操作來定義,包括對變數的讀寫操作,監視器monitor的加鎖和釋放操作,以及執行緒的啟動和合並操作,JMM為程式中所有的操作定義了一個偏序關係,稱為Happens-before,要想保證執行操作B的執行緒看到A的結果(無論AB是否在同一個執行緒中執行),那麼AB之間必須滿足Happens-before關係。如果沒有這個關係,那麼JVM可以對他們任意的重排序。

 

當一個變數被多個執行緒讀取並且至少被一個執行緒寫入時,如果在讀操作和寫操作之間沒有依照Happens-before來排序,那麼就會產生資料競爭的問題。在正確使用同步的程式中不存在資料競爭,並會表現出序列一致性,這意味著程式中的所有操作都會按照一種固定的和全域性的順序執行。

 

16-2給出了當兩個執行緒使用同一個鎖進行同步時,在他們之間的Happens-before關係。線上程A內部的所有操作都按照他們在源程式中的先後順序來排序,線上程B內部的操作也是如此。由於A釋放了鎖M,並且B隨後獲得了鎖M,因此A中所有在釋放鎖之前的操作,也就位於B中請求鎖之後的所有操作之前。如果這兩個執行緒是在不同的鎖上進行同步的,那麼就不能推斷他們之間的動作順序,因為他們之間不存在Happens-before關係。 

 

 

 

 

1.4 藉助同步

由於Happens-Before的排序功能很強大,因此有時候可以藉助(Piggyback現有同步機制的可見性屬性。這需要將Happens-Before的程式規則與其他某個順序規則(通常是監視器鎖規則或者volatile變數規則)結合起來,從而對某個未被鎖保護的變數的訪問操作進行排序。這項技術由於對語句的順序非常敏感,因此很容易出錯。他是一項高階技術,並且只有當需要最大限度地提升某些類(例如ReentrantLock)的效能時,才應該使用這項技術。同時,因為在使用中很容易出錯,因此也要謹慎使用。

 

FutureTask的保護方法AbstractQueuedSynchronizer中說明了如何使用這種藉助技術。

AQS維護了一個標識同步器狀態的整數,FutureTask用這個整數來儲存任務的狀態:正在執行、已完成和已取消。但FutureTask還維護了其他一些變數,例如計算的結果。當一個執行緒呼叫set方來儲存結果並且另一執行緒呼叫get來獲取該結果時,這兩個執行緒最好按照Happens-Before進行排序。這可以通過將執行結果的引用宣告為volatile型別來實現,但利用現在的同步機制可以更容易地實現相同的功能。

程式清單16-2 說明如何藉助同步的FutureTask的內部類

FutureTask在設計時能夠確保,在呼叫 tryAccquireShared 之前總能成功呼叫 tryReleaseShard tryReleaseShard會寫入一個volatile型別的變數,而tryAccquireShard將讀取這個變數。程式清單16-2給出了innerGetinnerSet等方法,在儲存和獲取result時將呼叫這些方法。由於innerSet將在呼叫releaseShared(這又將呼叫tryReleaseShard)之前寫入result,並且innerGet將在呼叫acquireShared(這又將呼叫tryAccquireShared)之後讀取result,因此將程式順訊規則與volatile變數規則結合在一起,就可以確保innerSet中的寫入操作在innerGer之前之前。

 

之所以將這項技術稱為藉助,是因為它使用了一種現有的Happens- Before順序來確保物件X的可見性,而不是專門為了釋出X而建立一種Happens-Before順序。在類庫中提供的其他Happens-Before排序包括:

  • 將一個元素放入一個執行緒安全容器的操作將在另一個執行緒從該容器中獲得這個元素的操作之前執行
  • CountDownLatch上的倒數操作將線上程從閉鎖上的await方法返回之前執行
  • 釋放Semaphore許可的操作將在從該Semaphore上獲得一個許可之前執行
  • Future表示的任務的所有操作將在從Future.get中返回之前執行
  • Executor提交一個RunnableCallable的操作將在任務開始執行之前執行
  • 一個執行緒到達CyclicBarrierExchange的操作將在其他到達該柵欄或交換點的執行緒被釋放之前執行。如果CyclicBarrier使用一個柵欄操作,那麼到達柵欄的操作將在柵欄操作之前執行,而柵欄操作又會線上程從柵欄中釋放之前執行。

 

 

2、釋出

第三章介紹瞭如何安全的或者不正確的釋出一個物件,其中介紹的各種技術都依賴JMM的保證,而造成釋出不正確的原因就是在釋出一個共享物件另外一個執行緒訪問該物件之間缺少一種happens-before關係。

 

2.1 不安全的釋出

當缺少happens-before關係時,就可能會發生重排序,這就解釋了為什麼在沒有充分同步的情況下釋出一個物件會導致另一個執行緒看到一個只被部分構造的物件。假入初始化一個物件時需要寫入多個變數(多個域),在釋出該物件時,則可能出現如下情況,導致釋出了一個被部分構造的物件:

init field a

init field b

釋出ref

init field c

錯誤的延遲初始化將導致不正確的釋出,如下程式清單16-3

  注:除了不可變物件以外,使用被另一個執行緒初始化的物件通常都是不安全的,除非物件的釋出操作是在使用該物件的執行緒開始使用之前執行

程式清單16-3 不安全的延遲初始化
public class UnsafeLazyInitialization {
    private static Object resource;

    public static Object getInstance(){
        if (resource == null){
            resource = new Object(); //不安全的釋出
        }
        return resource;
    }
}

  

2.2 安全釋出

 藉助於類庫中現在的同步容器、使用鎖保護共享變數、或都使用共享的volatile型別變數,都可以保證對該變數的讀取和寫入是按照happens-before關係來排序。

  注:happens-before事實上可以比安全釋出承諾更強的可見性與排序性

 

2.3 安全初始化模式

方式一:加鎖保證可見性與排序性

getInstance的程式碼路徑很短,只包括一個判斷預見和一個預測分支,因此如果在沒有被多個執行緒頻繁呼叫或者在不會出現激烈競爭的情況下,可以提供較為滿意的效能。

 

程式清單16-4 執行緒安全的延遲初始化
public class SafeLazyInitialization {
    private static Object resource;

 
    public synchronized static Object getInstance(){
        if (resource == null){
            resource = new Object();
        }
        return resource;
    }
}

 

方式二:提前初始化

在初始化器中採用了特殊的方式來處理靜態域(或者在靜態初始化程式碼塊中初始化的值),並提供了額外的執行緒安全性保證。靜態初始化是由JVM在類的初始化階段執行,即在類被載入後並且被執行緒使用之前。由於JVM將在初始化期間獲得一個鎖,並且每個執行緒都至少獲取一次這個鎖以確保這個類已經載入,因此在靜態初始化期間,記憶體寫入操作將自動對所有執行緒可見。

因此,無論是在被構造期間還是被引用時,靜態初始化的物件都不需要顯示的同步。

程式清單16-5 提前初始化
public class EagerInitialization {
    private static Object resource = new Object();

    public static Object getInstance(){
        return resource;
    }
}

 

方式三:延遲初始化展位模式,建議

通過靜態初始化和JVM的延遲載入機制結合起來可以形成一種延遲初始化的技術,從而在常見的程式碼路徑中不需要同步。

程式清單16-6 掩藏初始化佔位類模式
public class ResourceFactory {

    private static class ResourceHolder{
        public static Object resource = new Object();
    }

    public static Object getInstance(){
        return ResourceHolder.resource;
    }
}

 

方式四:DCL雙重加鎖機制,注意保證volatile型別,否則出現一致性問題(jdk5.0+

DCL實際是一種糟糕的方式,是一種anti-pattern,它只在JAVA1.4時代好用,因為早期同步的效能開銷較大,用來避免不必要的開銷或者降低程式的啟動時間,但是目前DCL已經被廣泛的廢棄不用,因為促使該模式出現的驅動力已經不在(無競爭同步的執行速度很慢,以及jvm啟動時很慢),他不是一個高效的優化措施。

程式清單16-7 雙重加鎖
public class DoubleCheckedLocking {
    private static volatile Object resource;

    public static Object getInstance(){
        if (resource == null){
            synchronized (DoubleCheckedLocking.class){
                if (resource == null){
                    resource = new Object();
                }
            }
        }
        return resource;
    }
}

 

3、初始化過程中的安全性

final不會被重排序。

  • 程式清單16-8中states因為是final的所以可以被安全的釋出。即使沒有volatile,沒有鎖。但是,如果除了建構函式外其他方法也能修改states。如果類中還有其他非final域,那麼其他執行緒仍然可能看到這些域上不正確的值。也導致了構造過程中的escape

 

final的重排規則:

  • JMM禁止編譯器把final域的寫重排序到建構函式之外。 
  • 編譯器會在final域的寫之後,建構函式return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到建構函式之外。也就是說:寫final域的重排序規則可以確保:在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化過了。

 

final的重排規則:

  • 在一個執行緒中,初次讀物件引用與初次讀該物件包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。也就是說:讀final域的重排序規則可以確保:在讀一個物件的final域之前,一定會先讀包含這個final域的物件的引用。

 

如果final域是引用型別,那麼增加如下約束:

  • 在建構函式內對一個final引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。
程式清單16-8 不可變物件的初始化安全性
@ThreadSafepublic
class SafeStates {
    private final Map<String, String> states;

    public SafeStates() {
        states = new HashMap<String, String>();
        states.put("alaska", "AK");
        states.put("alabama", "AL");    
        states.put("wyoming", "WY");
    }

    public String getAbbreviation(String s) {
        return states.get(s);
    }
}

 

 


瞭解更多知識,關注我。  ???

相關文章