Java內存模型(Java併發編程的藝術筆記)

Java併發模型

Java的併發採用的是共享內存模型,在該模型中,線程之間共享程序的公共狀態。而線程之間的通信總是隱式進行。

java內存模型的抽象結構

在Java中,所有實例域,靜態域和數組元素(在本文,共享變量指的是這些元素)都存儲在堆內存中,堆內存在線程之間共享。而局部變量,方法定義參數和異常處理器參數不會在線程間共享。

java線程間的通信有Java內存模型(JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。

由此圖可看出:若線程A要與線程B之間通信的話,必須經過以下步驟:

  1.  線程A把本地內存A中更新過的共享變量 刷新到主內存中。
  2. 線程B到主內存中讀取線程A之前更新過的共享變量。

JMM通過控制主內存和每個線程的本地內存之間的交互,來保證內存可見性。

 

從源代碼到指令序列的重排序

在執行程序時,爲提高性能,編譯器和處理器常常會對指令做重排序。重排序分3中種類型:

  • 編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下,可以重新安排語句 的執行順序。
  • 指令級並行的重排序:現代處理器通過採用該技術,將多條指令重疊執行。若不存在數據依賴性,處理器可以改變語句對應的機器執行的執行順序。
  • 內存系統的重排序:由於處理器採用緩存 和 讀/寫緩存區,使得加載和存儲操作看上去是在亂序執行。

從Java源代碼到最終執行的指令序列,會經歷上面3種重排序。這些重排序會導致多線程程序出現內存可見性問題。

  • 對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有編譯器);
  • 對於處理器重排序,JMM的編譯器重排序規則會要求Java編譯器在生成指令序列時,插入特定的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。

內存屏障:一組處理器指令,實現對內存操作的順序限制。

 

併發編程模型的分類

現代處理器使用寫緩存區臨時保存向內存寫入的數據,而每個處理器上的寫緩存區,僅僅對它所在的處理器可見。這一特性使得處理器對內存的讀/寫操作的執行順序。不一定和內存實際發送的讀/寫操作順序一致。

如下例所示,處理器A和處理器B按程序的順序並行執行內存訪問,本希望得到的結構是x = 2, b = 1,但最終可能得到x = y = 0的結構

具體的原因如下所示,處理器A和處理器B分別執行A1和A2擦着,將變量寫入自己的寫緩存區中;然後從內存中讀取另一個共享變量;最後纔將自己寫緩存區中保存的髒數據刷新到內存中。因此程序會得到x = y = 0的結果。

雖然處理器執行內存操作的順序是A1->A2,但內存操作實際的順序爲A2->A1。此時,處理器A的內存操作順序被重排序了。

 

由於寫緩存區僅對自己的處理器可見,它會導致處理器執行內存操作的順序可能與內存實際操作順序不一致,而現代的處理器都允許對寫讀操作進行重排序。

爲了保證內存可見性,Java編譯器在生成指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分爲4類:

現代的多處理器大多支持StoreLoad Barriers屏障,但執行該屏障開銷很大,因爲當前處理器通常要把寫緩存區裏的數據全部刷新到內存中。

 

happens-before規則簡介

在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。(此處的兩個操作可以是一個或不同的線程之內)。

兩個操作之間具有happens-before關係,並不意味前一個操作必須在後一個操作之前執行。該關係僅要求前一個操作執行的結果對後一個操作可見。

 


重排序

重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段。

數據依賴性

若兩個操作訪問同一變量,且兩個操作中有一個爲寫操作,則這兩個操作存在數據依賴性。

以上3種情況,只要重排序兩個操作的執行順序,程序的執行結果就會被改變。編譯器和處理器在重排序時,會遵 守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。

此處的數據依賴性僅針對單處理器中執行的指令序列和單線程中執行的操作, 不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。

 

as-if-serial語義

該語義的意思是:不管怎麼重排序,(單線程)程序執行的結果不能被改變。爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序。但如果操作之間不存在數據依賴關係,這些操作就可能被編譯器和處理器重排序。

以計算圓面積爲例

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面3個操作的數據依賴性如圖所示

A,B分別和C存在數據依賴關係,因此在最終執行的指令序列中,C不能被重排序到A和B的前面;而A和B之間的執行順序可以被重排序。這使得我們看起來單線程程序是按程序的順序執行的。

 

重排序對多線程的影響

來看一下重排序是否會改變對現場程序的執行結果:

class ReorderExample {
    int a = 0;
    boolean flag = false;
    public void writer() {
        a = 1; // 1
        flag = true; // 2
    }
    Public void reader() {
        if (f?lag) { // 3
           int i = a * a; // 4

        }
    }
}

此處flag用於判斷變量是否已被寫入,假設此處有兩個線程A和B,A首先執行write()方法,B線程接着執行reader()方法。當線程B在執行操作4時,是否能看到線程A的操作1呢?

答案是不一定。由於操作1和操作2沒有數據依賴關係,編譯器和處理器可以對這兩個操作重排序。此次,重排序破壞了多線程程序的語義。

而操作3和操作4存在控制依賴關係。編譯器和處理器會採用猜測執行來克服控制關係對並行度的影響。以處理器爲例,執行線程B的處理器可以提前讀取並計算a * a,將其計算結果臨時保存在一個名爲重排序緩存的硬件緩存中。因此在此次,重排序破壞了多線程程序的語義

 


順序一致性

JMM對正確同步的多線程程序的內存一致性做了如下保證:如果程序是正確同步的,程序的執行將具有順序一致性。即程序的執行結果和程序在順序一致性內存模型中的執行結果相同。

順序一致性內存模型

順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型。

假設有兩個線程A和B併發執行且使用監視器鎖來正確同步,A,B線程都有3個操作。A線程的3個操作執行後釋放鎖,B獲取同一個鎖。

下圖是程序在順序一致性模型中的執行效果。

而如果兩個線程沒有同步,則是下圖的執行效果:

 

但在JMM中就沒有這個保證。未同步程序在JMM中不但整體執行順序是無序的,且所有線程看到操作執行順序也可能不一致。比如,當前線程將寫過的數據緩存在本地內存中,在沒有刷新到主內存前,這個寫操作僅對當前可見,而其他線程則會認爲這個寫操作沒有被當前線程執行。只有當 當前線程將本地內存寫過的數據刷新到主內存後,這個寫操作纔對於線程可見。這種情況下,當前線程和其他線程看到的操作執行順序可能不一致。

 

同步程序的順序一致性效果

對之前的例子用鎖同步:

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中,臨界區內的代碼可以重排序,但由於監視器互斥執行的特性,線程B不能觀察到線程A在臨界區內的重排序。

 

未同步程序的執行特性

對於未同步或未正確同步的多線程程序,JMM只提供最小安全性:線程執行時讀取到的值是是之前某個線程寫入的值或默認值(0,null,false)。爲了實現最小安全性,JMM在堆上分配對象時,首先會對內存空間清零,然後才分配對象(JVM內部會同步這兩個操作)。

 


volatile的內存語義

當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存;而當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

對於volatile變量的單個讀/寫,我們可以看出使用同一個鎖對這些單個讀/寫操作做了同步

class VolatileFeaturesExample {
    volatile long vl = 0L; // 使用volatile聲明64位的long型變量
    public void set(long l) {
        vl = l; // 單個volatile變量的寫
    }
    public void getAndIncrement () {
        vl++; // 複合(多個)volatile變量的讀/寫
    }
    public long get() {
        return vl; // 單個volatile變量的讀
    }
}

假設有多個線程分別調用這3個方法,上面的程序與下面的程序在語義上等價

class VolatileFeaturesExample {
    long vl = 0L; // 64位的long型普通變量
    public synchronized void set(long l) { // 對單個的普通變量的寫用同一個鎖同步
        vl = l;
    }
    public void getAndIncrement () { // 普通方法調用
        long temp = get(); // 調用已同步的讀方法
        temp += 1L; // 普通寫操作
        set(temp); // 調用已同步的寫方法
    }
    public synchronized long get() { // 對單個的普通變量的讀用同一個鎖同步
        return vl;
    }
}

 

鎖的的happens-before規則意味着獲得鎖和放鎖的兩個線程之間的內存可見性,這意味着對volatile變量的讀,總能看見任意線程對該變量最後的寫入。

對於單個volatile變量的讀/寫具有原子性,但若是多個volatile操作或類似volatile變量++這種複合操作,這些操作整體上不由原子性。

 

volatile寫-讀建立的happens-before關係

以下是使用volatile變量的實例代碼

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1; // 1    線程A修改共享變量
        flag = true; // 2
    }
    public void reader() {
        if (flag) { // 3
            int i = a; // 4  線程B讀共享變量
             ……
        }
    }
}

 

假設線程A執行writer()方法之後,線程B執行reader()方法。根據happens-before規則,這個 過程建立的happens-before關係可以分爲3類:

  • 根據程序次序規則,1 happens-before 2;3 happens-before 4。
  • 根據volatile規則,2 happens-before 3。
  • 根據happens-before的傳遞性規則,1 happens-before 4。

線程A寫一個volatile變量後,B線程讀同一個volatle變量。A線程在寫volatile變量 之前所有可見的共享變量,在B線程讀同一個volatile變量後,立即變得對B線程可見。

 

volatile寫-讀的內存語義

volatile寫的內存語義爲:當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。

volatile讀的內存語義爲:當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量

 

 

volatile的內存語義的實現

爲了實現volatiledi內存語義,JMM會分別限制編譯器排序和處理器重排序

總結來說:

  • 當第一操作是volatile讀時,無論第二個操作是什麼,都不能重排序。它確保了volatiledi讀後的操作都不會被重排序到volatile之前。
  • 當第一個操作是volatilie寫,第二個操作是volatiledi讀時,不能重排序。
  • 當第二個操作是volatile寫時,無論第一個操作是什麼,都不能重排序。該規則確保volatile寫之前的操作不會被重排序到volatiledi寫之後。

爲了實現volatile的內存語義。編譯器會在指令序列中插入內存屏障禁止特定類型的處理器重排序。

  • ·在每個volatile寫操作的前面插入一個StoreStore屏障。 
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。 
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。
  • ·在每個volatile讀操作的後面插入一個LoadStore屏障。

下圖是在保守策略下,volatile寫插入內存屏障後生成的指令序列示意圖,它可以保證在volatile寫前,其前面的所有普通寫操作已經對任意處理器可見,這是因爲爲StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內存。

圖中volatile寫後面的StoreLoad屏障是爲了避免volatile寫與後面可能有的volatile讀/寫操作重排序。

 

下圖是在保守策略下,volatile讀插入內存屏障後生成的指令序列示意圖。

 

在實際執行中,只要不改變volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndWrite() {
        int i = v1; // 第一個volatile讀
        int j = v2; // 第二個volatile讀
        a = i + j; // 普通寫
        v1 = i + 1; // 第一個volatile寫
        v2 = j * 2; // 第二個 volatile寫
    }
        … // 其他方法
}

下圖爲指令序列示意圖。最後的StoreLoad屏障不能省略,因爲第二個volatile寫之後,方法立即return。此時編譯器可能無法準確判定後面是否有volatile讀或寫。

 


鎖的內存語義

鎖的釋放-獲取建立的happens-before關係

鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的線程向獲取同一個鎖的線程發送信息

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 happens-before 2,2 happens-before 3;4 happens-before 5,5 happensbefore 6。
  • 根據監視器鎖規則,3 happens-before 4。
  • 根據happens-before的傳遞性,2 happens-before 5

 

鎖的釋放和獲取的內存語義

當線程釋放鎖時,JMM會把線程對於的本地內存中的共享變量刷新到主內存中。以上面的程序爲例,A線程釋放鎖後,共享數據的狀態如下圖所示:

當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

 

鎖內存語義的實現

此處藉助ReentrantLock的類來分析其實現機制

​
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)來維護同步狀態。

 

ReentrantLock分爲公平鎖和非公平鎖。

使用公平鎖時,加鎖方法lock()調用順序如下:

  1. ReentrantLock:lock()
  2. FairSync:lock()
  3. AbstractQueuedSynchronizer:acquire(int arg)
  4. ReentrantLock:tryAcquire(int acquires)

在第4步開始真正加鎖,方法中首先讀volatile變量state

    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)。

而在釋放鎖的最後寫volatile變量state。

    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變量之前可見的共享變量,在獲取鎖的線程讀取同一個volatile後就會對獲取鎖的線程可見。

當使用非公平鎖時,它的釋放和公平鎖一樣,因此此處僅分析非公平鎖的獲取。

加鎖方法lock()的調用順序如下:

  1. ReentrantLock:lock()。
  2. NonfairSync:lock()。
  3. AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。

在第3步開始真正加鎖。首先會用CAS更新volatile變量,這個操作同時具有volatile讀和volatile 寫的內存語義。

protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

 

concurrent包的實現

由於Java的CAS同時具有volatile讀和volatile寫的內存語義,因此Java線程之間的通信有如下方式:

  • A線程寫volatile變量,隨後B線程讀volatile變量
  • A線程寫volatile,隨後B線程用CAS更新volatile變量
  • A線程用CAS更新volatile變量,隨後B線程用CAS更新volatile變量
  • A線程用CAS更新volatile變量,隨後B線程讀volatile變量

CAS使用現代處理器提供的高效機器級別的原子指令(這些原子指令以原子方式對內存執行讀-改-寫操作),同時volatile變量的讀/寫和 CAS可以實現線程之間的通信。這兩者的特性形成了concurrent包實現的基石。通過觀察concurrent包的源碼,可以發現一個通用花的實現模式:

  1. 首先聲明共享變量爲volatile
  2. 使用CAS更新來實現線程之間的同步
  3. 同時,配合volatile的讀/寫以及CAS具有的volatile讀和寫的內存語義來實現線程之間的通信

AQS,原子變量類(java.util.concurrent.atomic包中的類)以及非阻塞數據結構(如ConcurrentLinkedQueue) 都是使用以上模式來實現的,而concurrent包的高層類又是依賴於這些基礎類實現。

 


final域的內存語義

final域的重排序規則

對於final域,編譯器和處理器要遵循兩個重排序規則:

  • 在構造函數內對一個final域的寫入, 與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
  • 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

 

寫final域的重排序規則

  1. JMM禁止編譯器把final域的寫 重排序到構造函數之外;
  2. 編譯器會在final域的寫之後,構造函數return之前,插入一個StroreStore屏障,該屏障禁止處理器把final域的寫 重排序到構造函數之外;

在下面的代碼中,writer()方法包含兩個步驟:

  1. 構造一個FinalExample類型的對象
  2. 把這個對象的引用賦值給引用變量obj。
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()方法。

假設B讀對象引用與讀對象的成員域之間沒有重排序,下圖是一種可能的執行時序,在此圖中,writer()方法中的寫普通域i被重排序到構造函數之外,線程B錯誤的讀取了普通域i初始化前的值。而writer()方法中的寫final域的操作被final域的重排序規則限定在構造函數之內,線程B正確讀取了final變量初始化之後的值。

寫final域重排序規則確保了:在對象引用被任意線程可見之前,對象的final域已被正確初始化,但普通域不具有這樣的保障。如上圖爲例,在線程B看到對象引用obj時,很可能obj對象還沒有構造完成(對普通域i的寫操作被重排序到構造函數之外,此時初始值1還沒有寫入普通域i)。

 

爲什麼final引用不能從構造函數內“溢出”

上面我們提到,寫final域的重排序規則可以確保:在引用變量爲任意線程可見之前,該引用變量指向的對象的final域已經在構造函數中被正確初始化過了。要得到這樣的結果,還需要一個保證:在構造函數內部,不能讓這個被構造對象的引用爲其他線程所見,也就是對象引用不能在構造函數中“逸出”。

如下面代碼爲例,線程A執行writer()方法,另一個線程B執行reader()方法。此處操作1和操作2可能被重排序。使得操作2使得對象還未完成構造前,就被線程B可見,而B所見的final域則是初始化前的值。

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;
    public FinalReferenceEscapeExample () {
        i = 1;                 // 1寫final域
        obj = this;            // 2 this引用在此"逸出"
    }
    public static void writer() {    //線程A
        new FinalReferenceEscapeExample ();
    }
    public static void reader() {    //線程B
        if (obj != null) {     // 3
            int temp = obj.i; // 4
        }
    }
}

實際的執行時序可能爲:

因此,在構造函數返回前,被構造對象的引用不能被其他線程所見,因爲此時的final域可能還沒有被初始化。

 

讀final域的重排序規則

在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作(該規則僅爭對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。

初次讀對象引用和初次讀該對象包含的final域,這兩個操作存在間接依賴關係。由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,不會重排序這兩個操作。而這個規則則是爭奪那些少數處理器。

在上例代碼中,reader()方法包含3個操作:

  1. 初次讀引用變量obj
  2. 初次讀引用變量obj指向對象的普通域j
  3. 初次讀引用變量obj指向對象的final域i

現在假設寫線程A沒有發送任何重排序,同時程序在不遵守間接依賴的處理器上執行。下圖是一種可能執行時序,圖中讀對象的普通域被重排序到讀對象引用之前,這是一個錯誤的讀取操作;而讀final域的重排序規則會把讀final域操作限制在讀對象引用之後,這是正確的操作。

因此,讀final域的重排序規則可以確保:在讀一個對象的final域之前,一定會先讀包含這個final 域的對象的引用。

 

final域爲引用類型

對於引用類型,寫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; // 4
    }
    public static void reader () { // 讀線程C執行
        if (obj != null) { // 5
            int temp1 = obj.intArray[0]; // 6
        }
    }
}

 

下圖是對應的可能的線程執行時序,操作1是對final域的寫入,2是對這個final域引用的對象的成員域的寫入,3是把被構造對象的對象的引用賦值給某個對象引用。上面提到的規則 則說明1不能和3重排序。此外,2和3也不能重排序。

JMM可以確保線程C至少能看到線程A在構造函數中對final引用對象的成員域的寫入,即C至少能看到數組下標0的值爲1;而寫線程B對數組元素的寫入,C不確定是否看得到,因爲寫線程B和讀線程C之間存在數據競爭,執行結果不可知。若想確保C能看到B的操作,則B和C之間需要使用同步原語(lock或volatile)來確保內存可見性。

 


happens-before

JMM的設計意圖

JMM對於兩者不同性質的重排序,採取了不同策略:

  • 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止。
  • 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求(允許這種重排序)。

JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序), 編譯器和處理器怎麼優化都行。例如:

  • 如果編譯器經過細緻的分析後,認定一個鎖只會被單個 線程訪問,那麼這個鎖可以被消除。
  • 如果編譯器經過細緻的分析後,認定一個volatile變量只會被單個線程訪問,那麼編譯器可以把這個volatile變量當作一個普通變量來對待。

這些優化既不會改變程序的執行結果,又能提高程序的執行效率。

 

happens-before的定義

happens-befor定義爲:

  • 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作 可見,而且第一個操作的執行順序排在第二個操作之前。
  • 兩個操作之間存在happens-before關係,並不意味着Java平臺的具體實現必須要按照 happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

happens-before的規則

  • 程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  • 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  • start()規則:如果線程A執行操作ThreadB.start()(啓動線程B),那麼A線程的 ThreadB.start()操作happens-before於線程B中的任意操作。
  • 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;
    }
}

我們可以通過加上synchronized來做同步處理實現線程安全。但在早期的JVM中,synchronized存在巨大的性能開銷,於是那時的人們想出了一個技巧:雙重檢查鎖定,以此來降低同步的開銷:

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,那麼就不需要執行下面的加鎖和初始化操作。

但這仍然存在問題:

 instance = new Instance(); 這一行代碼可以分解爲如下3行僞代碼:

  • memory = allocate();        // 1:分配對象的內存空間
  • ctorInstance(memory);     // 2:初始化對象
  • instance = memory;          // 3:設置instance指向剛分配的內存地址

而2和3可能會被重排序,即如下:

  • memory = allocate();        // 1:分配對象的內存空間
  • instance = memory;          // 3:設置instance指向剛分配的內存地址,此時對象還未被初始化
  • ctorInstance(memory);     // 2:初始化對象

Java允許在單線程內,不會改變單線程程序執行結果的重排序的發生,在下圖中,此處雖然2和3互換位置了,但2仍舊確保在4之前,因此此處的重排序被允許。

於是,上面的線程A與線程B執行時序圖可能如下所示,當A執行完設置instance指向內存空間這一步時,突然輪到B進入該方法,B判斷是否空,不爲空,接着初次訪問instance對象,但此時的instance對象還未被初始化。

 

基於volatile的解決方案

基於上面出現的問題,我們可以將instance聲明爲volatile,上面提到的操作2和3之間的重排序就會被禁止。

 

基於類初始化的解決方案(類的初始化過程)

JVM在類的初始化階段(在Class被加載後,被線程使用之前),會執行類的初始化。在執行期間,JVM會去獲取一個鎖。該鎖可以同步多個線程對同一個類的初始化。

基於此特性,可以去實現另一種線程安全的方案:

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
        return InstanceHolder.instance ; // 這裏將導致InstanceHolder類被初始化
    }
}

如下圖所示,假設兩個線程併發執行getInstance()方法。

這種解決方案的實質是:允許操作2和3重排序,但不允許其他線程看到這個重排序。

初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。根據Java規範,在首次發生下列任意一種情況下,一個類或接口T將立即被初始化

  • T是一個類,而且一個T類型的實例被創建。
  • T是一個類,且T中聲明的一個靜態方法被調用。
  • T中聲明的一個靜態字段被賦值。
  • T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。

在上面的代碼例子中,首次執行getInstance()方法符合情況4。

由於Java語言是多線程,多個線程可能嘗試去初始化同一個類或接口,因此Java規定,對於每一個類或接口C,都有一個唯一的初始化鎖LC與之對應。。JVM在類初始化期間會獲取這個初始化鎖,並且 每個線程至少獲取一次鎖來確保這個類已經被初始化過了。

類的初始化過程如下:

(1)獲取Class對象的初始化鎖,來控制類或接口的初始化(通過判斷Class對象的初始化狀態state來判斷是否被初始化,未被初始化則值爲state=noInitialization)。這個獲取鎖的線程會一直等待,直至當前線程能獲取該鎖。

(2)假設有兩個線程A和B,而A拿到了鎖,正在執行類的初始化,類的初始化可分爲前面提到的3步,而其他線程是不會看到其中的步驟的重排序的;同時B在初始化鎖對於的condition上等待。

(3)線程A設置state=initialized,然後喚醒在condition中等待的所有線程。

注:此處的condition和state標記是本文虛構出來的。Java語言規範並沒有硬性規定一 定要使用condition和state標記。JVM的具體實現只要實現類似功能即可。

總結:

字段延遲化雖然降低了初始化類或創建實例的開銷,但增加了訪問 被延遲初始化的字段的開銷。大多情況下,正常的初始化要優於於延遲初始化。如果確實需要對實例字段使用線程 安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化的方案;如果確實需要對靜 態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化的方案。

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章