面試必備:Java volatile的內存語義與AQS鎖內存可見性[精品長文]

願我所遇之人,所歷之事,哪怕由於我有一點點變好,我就心滿意足了。git

提到volatile首先想到就是:程序員

  • 保證此變量對全部線程的可見性,這裏的 「可見性」是指當一條線程修改了這個變量的值,新值對於其餘線程來講是能夠當即得知的。
  • 禁止指令重排序優化。

到這裏你們感受本身對volatile理解了嗎?github

若是理解了,你們考慮這麼一個問題:ReentrantLock(或者其它基於AQS實現的鎖)是如何保證代碼段中變量(變量主要是指共享變量,存在競爭問題的變量)的可見性?面試

private static ReentrantLock reentrantLock = new ReentrantLock();
private static intcount = 0;
//...
// 多線程 run 以下代碼
reentrantLock.lock();
try
{
    count++;
} 
finally
{
    reentrantLock.unlock();
}
//...
複製代碼

既然提到了可見性,那就先熟悉幾個概念:編程

一、JMM

JMM:Java Memory Model 即 Java 內存模型緩存

The Java Memory Model describes what behaviors are legal in multithreaded code, and how threads may interact through memory.bash

It describes the relationship between variables in a program and the low-level details of storing and retrieving them to and from memory or registers in a real computer system.微信

It does this in a way that can be implemented correctly using a wide variety of hardware and a wide variety of compiler optimizations.多線程

Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量主要是指共享變量,存在競爭問題的變量。Java內存模型規定全部的變量都存儲在主內存中,而每條線程還有本身的工做內存,線程的工做內存中保存了該線程使用到的變量的主內存副本拷貝,線程對變量的全部操做(讀取、賦值等)都必須在工做內存中進行,而不能直接讀寫主內存中的變量(根據Java虛擬機規範的規定,volatile變量依然有共享內存的拷貝,可是因爲它特殊的操做順序性規定——從工做內存中讀寫數據前,必須先將主內存中的數據同步到工做內存中,全部看起來如同直接在主內存中讀寫訪問通常,所以這裏的描述對於volatile也不例外)。不一樣線程之間也沒法直接訪問對方工做內存中的變量,線程間變量值得傳遞均須要經過主內存來完成。併發

二、重排序

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

  • 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序。
  • 內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序:

對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是全部的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,經過內存屏障指令來禁止特定類型的處理器重排序。

JMM屬於語言級的內存模型,它確保在不一樣的編譯器和不一樣的處理器平臺之上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

三、happens-before

  • 程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。(對一個volatile變量的讀,老是能看到【任意線程】對這個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)。

四、內存屏障

  • 硬件層的內存屏障分爲兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。
  • 對於Load Barrier來講,在指令前插入Load Barrier,可讓高速緩存中的數據失效,強制重新從主內存加載數據;
  • 對於Store Barrier來講,在指令後插入Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其餘線程可見。
  • 內存屏障有兩個做用:
    • 阻止屏障兩側的指令重排序;
    • 強制把寫緩衝區/高速緩存中的數據等寫回主內存,讓緩存中相應的數據失效。

五、volatile的內存語義

從JSR-133開始(即從JDK5開始),volatile變量的寫-讀能夠實現線程之間的通訊。

從內存語義的角度來講,volatile的寫-讀與鎖的釋放-獲取有相同的內存效果

  • volatile寫和鎖的釋放有相同的內存語義;
  • volatile讀與鎖的獲取有相同的內存語義。

volatile僅僅保證對單個volatile變量的讀/寫具備原子性,而鎖的互斥執行的特性能夠確保對整個臨界區代碼的執行具備原子性。在功能上,鎖比volatile更強大;在可伸縮性和執行性能上,volatile更有優點。

volatile變量自身具備下列特性:

  • 可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性:對任意單個volatile變量的讀/寫具備原子性,即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。

volatile寫和volatile讀的內存語義:

  • 線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所作修改的)消息。
  • 線程B讀一個volatile變量,實質上是線程B接收了以前某個線程發出的(在寫這個volatile變量以前對共享變量所作修改的)消息。
  • 線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A經過主內存向線程B發送消息。

JMM針對編譯器制定的volatile重排序規則表

  • 當第二個操做是volatile寫時,無論第一個操做是什麼,都不能重排序。這個規則確保volatile寫以前的操做不會被編譯器重排序到volatile寫以後。
  • 當第一個操做是volatile讀時,無論第二個操做是什麼,都不能重排序。這個規則確保volatile讀以後的操做不會被編譯器重排序到volatile讀以前。
  • 當第一個操做是volatile寫,第二個操做是volatile讀時,不能重排序。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來講,發現一個最優佈置來最小化插入屏障幾乎是不可能的。爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。

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

LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操做要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。

StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操做執行前,保證Store1的寫入操做對其它處理器可見。 LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操做被刷出前,保證Load1要讀取的數據被讀取完畢。

StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續全部讀取操做執行前,保證Store1的寫入對全部處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能。 上述內存屏障插入策略很是保守,但它能夠保證在任意處理器平臺,任意的程序中都能獲得正確的volatile內存語義。

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

圖中的StoreStore屏障能夠保證在volatile寫以前,其前面的全部普通寫操做已經對任意處理器可見了。這是由於StoreStore屏障將保障上面全部的普通寫在volatile寫以前刷新到主內存。

這裏比較有意思的是,volatile寫後面的StoreLoad屏障。此屏障的做用是避免volatile寫與後面可能有的volatile讀/寫操做重排序。由於編譯器經常沒法準確判斷在一個volatile寫的後面是否須要插入一個StoreLoad屏障(好比,一個volatile寫以後方法當即return)。爲了保證能正確實現volatile的內存語義,JMM在採起了保守策略:在每一個volatile寫的後面,或者在每一個volatile讀的前面插入一個StoreLoad屏障。從總體執行效率的角度考慮,JMM最終選擇了在每一個volatile寫的後面插入一個StoreLoad屏障。由於volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫以後插入StoreLoad屏障將帶來可觀的執行效率的提高。從這裏能夠看到JMM在實現上的一個特色:首先確保正確性,而後再去追求執行效率。

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

圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

上述volatile寫和volatile讀的內存屏障插入策略很是保守。在實際執行時,只要不改變volatile寫-讀的內存語義,編譯器能夠根據具體狀況省略沒必要要的屏障。

六、AQS

對於AQS須要瞭解這麼幾點:

  • 鎖的狀態經過volatile int state來表示。
  • 獲取不到鎖的線程會進入AQS的隊列等待。
  • 子類須要重寫tryAcquire、tryRelease等方法。

AQS 詳解參見:面試必備:Java AQS 實現原理(圖文)分析

七、ReentrantLock

以公平鎖爲例,看看 ReentrantLock 獲取鎖 & 釋放鎖的關鍵代碼:

/**
 * The synchronization state.
 */
private volatile int state;
/**
 * Returns the current value of synchronization state.
 * This operation has memory semantics of a {@code volatile} read.
 * @return current state value
 */
protected final int getState() {
    return 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;
}
 protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();// 獲取鎖的開始,首先讀volatile變量state
        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,在獲取鎖時首先讀這個volatile變量。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變量以前可見的共享變量,在獲取鎖的線程讀取同一個volatile變量後將當即變得對獲取鎖的線程可見。從而保證了代碼段中變量(變量主要是指共享變量,存在競爭問題的變量)的可見性。

八、小結

若是咱們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式。

  • 首先,聲明共享變量爲volatile。
  • 而後,使用CAS的原子條件更新來實現線程之間的同步。
  • 同時,配合以volatile的讀/寫和CAS所具備的volatile讀和寫的內存語義來實現線程之間的通訊。

前文咱們提到過,編譯器不會對volatile讀與volatile讀後面的任意內存操做重排序;編譯器不會對volatile寫與volatile寫前面的任意內存操做重排序。組合這兩個條件,意味着爲了同時實現volatile讀和volatile寫的內存語義,編譯器不能對CAS與CAS前面和後面的任意內存操做重排序。

本文參考:

一、《Java併發編程的藝術》 方騰飛 魏鵬 程曉明 著

二、Java 可重入鎖內存可見性分析

九、博主信息

我的微信公衆號:

我的博客

我的github

我的掘金博客

我的CSDN博客

相關文章
相關標籤/搜索