Java并發學習筆記 -- Java中的Lock、volatile、同步關鍵字

Java并發

參考資料:
《Java并發編程的藝術》
并發番@Synchronized一文通(1.8版)

一、鎖

1. 偏向鎖

1. 思想背景

  • 來源:HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同 一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。
  • 原理:在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出 同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否 存儲著指向當前線程的偏向鎖。

悟:偏向鎖的來源就是為了節省當同一個線程訪問臨界區資源的時候頻繁CAS加鎖解鎖操作的開支,同樣這也是偏向鎖使用的場景,即只有一個線程訪問臨界區資源的情況

Java對象頭中的Mark Word結構:

image

2. 偏向鎖的加鎖

當一個線程訪問同步塊并 獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出 同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖:

  • 如果當前對象頭的線程ID指向了該線程,那么說明已經獲得了鎖
  • 否則,判斷對象頭中的Mark Word鎖的標識是否是偏向鎖(對應就是鎖標志位為01,是否是偏向鎖為1)
    • 如果是偏向鎖,那么會使用CAS將線程ID指向該線程
    • 否則,則使用CAS競爭鎖(說明此時是更高級的輕量鎖或者重量鎖,需要競爭才行)

最順利的情況就是線程ID指向該線程,這就對應只有一個線程頻繁訪問臨界區資源的情況,此時能夠最大程度的減少CAS操作

3.偏向鎖的撤銷

偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時, 持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正 在執行的字節碼):

  • 首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著
    • 如果不處于活動狀態,則將對象頭設置成無鎖狀態
    • 否則,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word進行更改:
      • 要么重新偏向于其他線程,
      • 要么恢復到無鎖
      • 要么標記對象不適合作為偏向鎖(此時需要升級鎖)
  • 最后喚醒暫停的線程

4. 過程圖

image

2. 輕量鎖

1. 思想背景

  • 起源:由于線程的阻塞/喚醒需要CPU在用戶態和內核態間切換,頻繁的轉換對CPU負擔很重,進而對并發性能帶來很大的影響
  • 原理:在只有兩個線程并發訪問資源區是,一個持有鎖,另一個進行自旋等待

2. 輕量鎖加鎖

  • 線程在執行同步塊之前,JVM會先在當前線程的棧幀中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中(Displaced Mark Word-即被取代的Mark Word)做一份拷貝
  • 拷貝成功后,線程嘗試使用CAS將對象頭的Mark Word替換為指向鎖記錄的指針(將對象頭的Mark Word更新為指向鎖記錄的指針,并將鎖記錄里的Owner指針指向Object Mark Word)
    • 如果更新成功,當前線程獲得鎖,繼續執行同步方法
    • 如果更新失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖,若自旋后沒有獲得鎖,此時輕量級鎖會升級為重量級鎖,當前線程會被阻塞

3. 輕量級鎖解鎖

  • 解鎖時會使用CAS操作將Displaced Mark Word替換回到對象頭
    • 如果解鎖成功,則表示沒有競爭發生
    • 如果解鎖失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖,需要在釋放鎖的同時喚醒被阻塞的線程,之后線程間要根據重量級鎖規則重新競爭重量級鎖

4. 流程圖

image

3. 三種鎖比較

image

二、處理器和Java中原子性操作的實現

1. 處理器原子性操作的實現

處理器級別內存操作的原子性保證有兩種機制:總線鎖定和緩存鎖定。(注意:處理器會自動保證基本的內存操作的原子性,即對于內存中某一個地址數據的訪問時保證同一時刻只有一個處理器可以

1. 總線鎖

總線鎖比較簡單粗暴,對于需要限制的共享變量,通過LOCK信號,使得在操作該貢獻變量的時候,一個處理器獨占共享內存

2. 緩存鎖

總線鎖使得只能有一個處理器訪問內存,其他處理器不能夠操作內存里的其他數據,屬于“一刀切”“一棒子打死”的做法,所以開銷比較大。

所謂“緩存鎖定”是指內存區域如果被緩存在處理器的緩存 行中,并且在Lock操作期間被鎖定,那么當它執行鎖操作回寫到內存時,處理器不在總線上聲 言LOCK#信號,而是修改內部的內存地址,并允許它的緩存一致性機制來保證操作的原子 性,因為緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效,從而使他們重新從內存中獲取數據

雖然,緩存鎖更好,但是總線鎖并沒有完全棄用。有些情況,緩存鎖沒有辦法發揮,此時便需要總線鎖了。

2. Java中實現原子操作的原理

不管是加鎖還是其他方法,Java都是使用循環CAS的方式實現原子操作的。

三、Java內存模型

一、內存模型

JMM決定一個線程對共享變量的寫入何時對另一個線程可見。從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存、寫緩沖區、寄存器以及其他的硬件和編譯器優化

1. 指令重排序

在執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排序:

  1. 編譯器優化的重排序:編譯器在不改變串行語義的前提下,可以安排語句的執行順序
  2. 指令級并行的重排序:現代處理器才用了指令級的并行技術(流水線技術)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應 機器指令的執行順序。
  3. 內存系統的重排序:由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上 去可能是在亂序執行。(我們不用關注內存系統的重排序,其不再JMM的覆蓋范圍)

2. 數據依賴性

如果兩個操作中至少有一個操作是寫操作的話,那么就說這兩個操作之間具有數據依賴性

編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。(這里的順序依賴性只針對單線程下的指令),即指令重排序必須保證單線程下的串行語義

3. 順序一致性

當程序未正確同步時,就可能存在數據競爭,所謂數據競爭就是:1. 在一個線程中寫入一個變量 2. 在另一個線程中讀同一個變量 3. 且寫和讀沒有通過同步來排序

而正確同步的線程,不會發生數據競爭。

如果程序是正確同步的,那么程序的執行具有順序一致性

順序一致性模型有兩條規則:

  • 一個線程中的所有操作必須按照程序的順序來執行。
  • (不管程序是否同步)所有線程都只能看到一個單一的操作執行順序。在順序一致性內存模型中,每個操作都必須原子執行且立刻對所有線程可見。

可見,這兩條規則是非常理想化的,如果線程的操作是按照我門程序中寫的順序執行,且不同線程之間的操作立刻可見,那么并發編程會是一件很容易的事情。但是很遺憾JMM一條也沒有遵守順序一致性模型。對于第一條,編譯器和處理器都會對指令進行重排序,所以執行順序和書寫順序并不相同;對于第二條更不可能了。

但是,在Java中同步了的程序具有順序一致性的效果

[圖片上傳失敗...(image-2082d2-1583984997624)]

因此,Java保證只要是同步了的代碼,程序員可以把他當做是在順序一致性模型下運行的一樣

二、Volatile內存語義

volatile變量讀寫的內存語義:

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存(注意:是所有的共享變量,不光是volatile變量)
  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存置為無效。線程接下來將從主內存中讀取共享變量(注意:這里也是所有的共享變量)

關于線程如何確定要將共享變量的值刷新到內存,以及為何要從內存讀取最新的值,涉及到cpu之間的通信、嗅探等,這里不做展開。

悟:volatile變量的內存語義保證了在讀volatile變量之前,內存中所有的共享變量都是最新的,也就是之前執行的的任意線程的寫操作都對本次的讀操作可見;同樣,本次寫操作都對之后任意線程執行的讀操作可見。而且,volatile的讀操作和寫操作都具有原子性

1. volatile讀寫操作對重排序的影響

JMM的內存語義解決了內存可見性可能會引發的問題,但是還有一種問題可能會產生,那就是指令重排序的問題:

volatile a = 0;
int b = 1;

public void A (){
    b = 2;    // 1
    a = 1;      // 2
}

public void B() {
    int c = 0;
    if (a == 1)    // 3
        c = b;      // 4
}  

假如volatile不會對重排序有任何影響的話,那么由于代碼1和代碼2兩處沒有數據依賴性,所以二者是可以重排序的,我們假設代碼2在代碼1之前被執行,此時由于a是volatile變量,所以將a = 1, b = 1刷新進入主內存;如果這時候方法A所在的線程cpu時間片用完了,輪到了方法B在另一個線程中執行,由于a是volatile變量所以代碼3處執行的時候會將b = 1, a = 1從主內存中讀出,此時代碼4再執行的話c會變為1,而不是預想的2(因為按照我們書寫的順序來看,a=1發生在b=2之后)

發生這種錯誤的原因在于:volatile變量寫操作與在其之前的代碼發生了重排序,使得刷新內存的時機提早了,可能會漏掉我們寫在volatile變量賦值操作之前的那些共享變量的修改。所以這就引出了volatile變量對指令重排序的第一個影響:

  • 第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規則確保 volatile寫之前的操作不會被編譯器重排序到volatile寫之后。換句話說,以volatile寫這行代碼為分割線,之前的對共享變量的各種讀寫操作的指令不管如何進行重排序,都不可能跑到volatile寫操作之后執行,確保volatile寫操作刷新內存里共享變量的值時程序員希望發生的變動都能夠正確的刷新到內存中

同理,對應也有volatile變量對指令重排序的另一個影響:

  • 第一個操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規則確保 volatile讀之后的操作不會被編譯器重排序到volatile讀之前。確保volatile讀操作讀取內存里的最新值是程序員希望讀到的、操作的值

另外還有一條影響:

  • 第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序

三、Lock鎖的內存語義

Java中的鎖的內存語義和volatile一模一樣,而ReentrantLock加鎖和釋放鎖的原理就是通過操作一個volatile狀態變量來實現

1. ReentrantLock公平鎖加鎖的實現

ReentrantLock有自己的繼承鏈,但是真正開始加鎖是從自己的tryAcquire方法

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)) {   // -------------  代碼 1------------
            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; 
}  

重點在代碼1,注意:這里的條件中有一個CAS操作,也就是說多個競爭鎖的線程中可能都能夠執行到這里,但是只有一個能夠使得該條件返回true,其他都返回false。執行CAS返回true的線程通過setExclusiveOwnerThread進行獨占式綁定。而,CAS返回false的線程最終會直接從該加鎖方法中返回false,意味著加鎖失敗。

2. ReentrantLock公平鎖釋放鎖的實現

ReentrantLock公平鎖的釋放最終的實現是在其父類中實現的,開始釋放鎖是在tryRelease方法中:

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; 
}  

由于線程已經得到了鎖,所以釋放鎖的方法中沒有任何CAS操作API的調用,釋放鎖的邏輯是:判斷c的值,如果c為0了,說明要釋放鎖,此時需要解除線程對該鎖的綁定;如果c沒有變為0,只是簡單的更新state,永遠不要忘了state是一個volatile變量。

四、final的內存語義

五、再理解happen-before原則

happen-before原則是JMM對程序員的一種保證,即一個操作A happen-before另一個操作B,意味著:

  • A的結果對B可見,且A的執行順序在B之前
  • 并不意味著Java具體實現必須要按照happen-before指定的順序來執行,如果重排序后執行結果與按照happen-before關系來執行的結果一致,那么也是允許的

也就是說,JMM的happen-before只是保證了串行語義的順序和正確同步的順序。也就是說,A happen-before B如果A與B之間有依賴關系、同步關系的話,那么A確實在B之前執行;如果A與B之間沒有依賴、沒有同步關系的話,那么A與B之間的執行順序是可以改變的,但是對于程序員來說,結果都一樣,沒所謂,所以認為A執行在B之前也是可以的。

三、線程

四、Java中的Lock

使用Lock比使用synchronized有很多優點:

  • Lock更靈活,當一個線程同時涉及到多個鎖的時候,使用synchronized就顯得很麻煩了,比如在獲取B鎖之后要釋放A鎖,在獲取C鎖之后要放棄B鎖
  • Lock對加鎖有更多的選擇:Lock支持非阻塞地獲取鎖能被中斷地獲取鎖(當獲取到鎖的線程被中斷時,中斷異常將會被拋出,同時釋放鎖)超時獲取鎖

Java中Lock中常見的API方法

image

隊列同步器

Java中的隊列同步器AbstractQueuedSynchronizer是用來構建其他同步組件基礎框架。它使用一個int成員變量表示同步狀態,并且提供了CAS方法,通過內置的FIFO隊列來完成資源獲取線程的排隊工作。

Java中的鎖和其他的同步組件一般都是在內部定義一個同步器的實現子類,通過同步器定義的方法來管理同步狀態。鎖是面向程序員的,而其內部同步器的實現是面向設計者的。

同步器提供給同步組件的方法主要就是上文Java鎖語義中提到的:

  • getState
  • setState
  • compareAndSetState

三個方法。

一、隊列同步器的實現原理

Java中隊列同步器內部維護了一個雙向的隊列,隊列的作用就是將沒有競爭到鎖的線程包裝成一個Node節點插入到隊列中,其中頭結點代表持有鎖的線程。當然,有一些具體的信息都包括在Node節點中:

image

而線程之所以表現為一種阻塞狀態,是因為獲取不到鎖的線程會陷入一個自旋的循環中不斷地嘗試獲取鎖而導致線程卡在lock()方法處,具體細節下面再說。需要注意的是,AQS中將競爭鎖失敗的線層包裝成節點插入到隊列的尾部是一個CAS操作,這保證了隊列插入順序在多線程下的正確性。

二、獨占式同步狀態獲取與釋放

上面說了,Lock的實現基本上是委托于內部AQS的子類,以ReenTrantLock為例,其內部有一系列Sync的內部類是AQS的具體實現類,而ReentrantLock的lock方法內部調用了sync的acquire(int arg):

public final void acquire(int arg) { 
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  selfInterrupt(); 
}  

其中的tryAcquire(arg)方法的實現在上文中展示過了,tryAcquire方法是多個線程競爭鎖發生的地方,只有一個鎖能夠返回true并且獨占鎖,其他的都會返回false

而其他的tryAcquire返回false的線程會調用acquireQueued實現將線程包裝成雙向隊列的Node并加入的同步隊列中。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

重點關注方法內部的for循環,通過這個循環我們知道只有頭結點的后繼節點會在自旋中調用tryAcquire方法嘗試獲取鎖,而其他的后繼節點只是在空自旋;然后,當頭結點代表的線程釋放鎖之后,將其移除雙向隊列的地方在后繼節點代替它成為頭結點的地方,看到了將被阻塞的節點加入到隊列中的操作,接下來該看看是如何把一個阻塞的線程包裝成雙向鏈表的節點的:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}  

對于獨占式獲取鎖的過程,該方法傳遞的參數是Node.EXCLUSIVE,代表一個正阻塞在獨占狀態下的節點。該方法實現的前半部分負責調用CAS方法將該節點加入到雙向隊列的尾部,而最后有調用了一次enq(Node)方法:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}  

Q:這里點疑惑好像又加入了一次尾部,是不是有點重復了?

小結

以ReentrantLock為例,獨占式鎖的加鎖流程:

  1. ReentrantLock的lock方法的實現委托給了ReentrantLock內部的AQS的實現類Sync的acquire(int arg)方法,而Sync并沒有重寫該方法,acquire的具體實現在抽象父類AbstractQueuedSynchronizer
    1. acquire方法會調用tryAcquire方法嘗試獲取鎖,這里是發生多個線程競爭鎖的地方;其中只有一個線程能夠通過CAS方法獲取到鎖返回true,競爭失敗的線程都會返回false
    2. 那些競爭失敗的線程首先會被addWaiter方法包裝成一個雙向隊列的節點并且加入到雙向隊列的尾部
    3. 之后會在acquireQueued方法進行自旋操作,但是只有頭結點的后繼節點才會調用tryAcquire方法嘗試獲取鎖,其他的后繼節點只會空自旋
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容