Java 源碼剖析(06)--synchronized 和 ReentrantLock 的實現原理


在 JDK 1.5 之前共享對象的協調機制只有 synchronized 和 volatile,在 JDK 1.5 中增加了新的機制 ReentrantLock,該機制的誕生並不是爲了替代 synchronized,而是在 synchronized 不適用的情況下,提供一種可以選擇的高級功能。synchronized 和 ReentrantLock 是如何實現的?它們有什麼區別?

1)synchronized 和 ReentrantLock的區別

synchronized 屬於獨佔式悲觀鎖,是通過 JVM 隱式實現的,synchronized 只允許同一時刻只有一個線程操作資源

在 Java 中每個對象都隱式包含一個 monitor(監視器)對象,加鎖的過程其實就是競爭 monitor 的過程,當線程進入字節碼 monitorenter 指令之後,線程將持有 monitor 對象,執行 monitorexit 時釋放 monitor 對象,當其他線程沒有拿到 monitor 對象時,則需要阻塞等待獲取該對象。

ReentrantLock 是 Lock 的默認實現方式之一,它是基於 AQS(Abstract Queued Synchronizer,隊列同步器)實現的,它默認是通過非公平鎖實現的,在它的內部有一個 state 的狀態字段用於表示鎖是否被佔用,如果是 0 則表示鎖未被佔用,此時線程就可以把 state 改爲 1,併成功獲得鎖,而其他未獲得鎖的線程只能去排隊等待獲取鎖資源。

synchronized 和 ReentrantLock 都提供了鎖的功能,具備互斥性和不可見性。在 JDK 1.5 中 synchronized 的性能遠遠低於 ReentrantLock,但在 JDK 1.6 之後 synchronized 的性能略低於 ReentrantLock,它的區別如下:

  • synchronized 是 JVM 隱式實現的,而 ReentrantLock 是 Java 語言提供的 API;
  • ReentrantLock 可設置爲公平鎖,而 synchronized 卻不行;
  • ReentrantLock 只能修飾代碼塊,而 synchronized 可以用於修飾方法、修飾代碼塊等;
  • ReentrantLock 需要手動加鎖和釋放鎖,如果忘記釋放鎖,則會造成資源被永久佔用,而 synchronized 無需手動釋放鎖;
  • ReentrantLock 可以知道是否成功獲得了鎖,而 synchronized 卻不行。

2)知識擴展

2.1)ReentrantLock 源碼分析

從源碼出發來解密 ReentrantLock 的具體實現細節,首先來看 ReentrantLock 的兩個構造函數:

public ReentrantLock() {
    sync = new NonfairSync(); // 非公平鎖
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

無參的構造函數創建了一個非公平鎖,用戶也可以根據第二個構造函數,設置一個 boolean 類型的值,來決定是否使用公平鎖來實現線程的調度。

2.2)公平鎖 VS 非公平鎖

公平鎖的含義是線程需要按照請求的順序來獲得鎖;而非公平鎖則允許“插隊”的情況存在,所謂的“插隊”指的是,線程在發送請求的同時該鎖的狀態恰好變成了可用,那麼此線程就可以跳過隊列中所有排隊的線程直接擁有該鎖。

而公平鎖由於有掛起和恢復所以存在一定的開銷,因此性能不如非公平鎖,所以 ReentrantLock 和 synchronized 默認都是非公平鎖的實現方式。

ReentrantLock 是通過 lock() 來獲取鎖,並通過 unlock() 釋放鎖,使用代碼如下:

Lock lock = new ReentrantLock();
try {
    // 加鎖
    lock.lock();
    //......業務處理
} finally {
    // 釋放鎖
    lock.unlock();
}

ReentrantLock 中的 lock() 是通過 sync.lock() 實現的,但 Sync 類中的 lock() 是一個抽象方法,需要子類 NonfairSync 或 FairSync 去實現,NonfairSync 中的 lock() 源碼如下:

final void lock() {
    if (compareAndSetState(0, 1))
        // 將當前線程設置爲此鎖的持有者
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

FairSync 中的 lock() 源碼如下:

final void lock() {
    acquire(1);
}

可以看出非公平鎖比公平鎖只是多了一行 compareAndSetState 方法,該方法是嘗試將 state 值由 0 置換爲 1,如果設置成功的話,則說明當前沒有其他線程持有該鎖,不用再去排隊了,可直接佔用該鎖,否則,則需要通過 acquire 方法去排隊。acquire 源碼如下:

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

tryAcquire 方法嘗試獲取鎖,如果獲取鎖失敗,則把它加入到阻塞隊列中,來看 tryAcquire 的源碼:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 公平鎖比非公平鎖多了一行代碼 !hasQueuedPredecessors() 
        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); // set state=state+1
        return true;
    }
    return false;
}

對於此方法來說,公平鎖比非公平鎖只多一行代碼 !hasQueuedPredecessors(),它用來查看隊列中是否有比它等待時間更久的線程,如果沒有,就嘗試一下是否能獲取到鎖,如果獲取成功,則標記爲已經被佔用。

如果獲取鎖失敗,則調用 addWaiter 方法把線程包裝成 Node 對象,同時放入到隊列中,但 addWaiter 方法並不會嘗試獲取鎖,acquireQueued 方法纔會嘗試獲取鎖,如果獲取失敗,則此節點會被掛起,源碼如下:

/**
 * 隊列中的線程嘗試獲取鎖,失敗則會被掛起
 */
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); // 獲取成功,將當前節點設置爲 head 節點
                p.next = null; // 原 head 節點出隊,等待被 GC
                failed = false; // 獲取成功
                return interrupted;
            }
            // 判斷獲取鎖失敗後是否可以掛起
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                // 線程若被中斷,返回 true
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

該方法會使用 for( ;; ) 無限循環的方式來嘗試獲取鎖,若獲取失敗,則調用 shouldParkAfterFailedAcquire 方法,嘗試掛起當前線程,源碼如下:

/**
 * 判斷線程是否可以被掛起
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 獲得前驅節點的狀態
    int ws = pred.waitStatus;
    // 前驅節點的狀態爲 SIGNAL,當前線程可以被掛起(阻塞)
    if (ws == Node.SIGNAL)
        return true;
    if (ws > 0) { 
        do {
        // 若前驅節點狀態爲 CANCELLED,那就一直往前找,直到找到一個正常等待的狀態爲止
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        // 並將當前節點排在它後邊
        pred.next = node;
    } else {
        // 把前驅節點的狀態修改爲 SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

線程入列被掛起的前提條件是,前驅節點的狀態爲 SIGNAL,SIGNAL 狀態的含義是後繼節點處於等待狀態,當前節點釋放鎖後將會喚醒後繼節點。所以在上面這段代碼中,會先判斷前驅節點的狀態,如果爲 SIGNAL,則當前線程可以被掛起並返回 true;如果前驅節點的狀態 >0,則表示前驅節點取消了,這時候需要一直往前找,直到找到最近一個正常等待的前驅節點,然後把它作爲自己的前驅節點;如果前驅節點正常(未取消),則修改前驅節點狀態爲 SIGNAL。

到這裏整個加鎖的流程就已經走完了,最後的情況是,沒有拿到鎖的線程會在隊列中被掛起,直到擁有鎖的線程釋放鎖之後,纔會去喚醒其他的線程去獲取鎖資源,整個運行流程如下圖所示:
在這裏插入圖片描述
unlock 相比於 lock 來說就簡單很多了,源碼如下:

public void unlock() {
    sync.release(1);
}
public final boolean release(int arg) {
    // 嘗試釋放鎖
    if (tryRelease(arg)) {
        // 釋放成功
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

鎖的釋放流程爲,先調用 tryRelease 方法嘗試釋放鎖,如果釋放成功,則查看頭結點的狀態是否爲 SIGNAL,如果是,則喚醒頭結點的下個節點關聯的線程;如果釋放鎖失敗,則返回 false。
tryRelease 源碼如下:

/**
 * 嘗試釋放當前線程佔有的鎖
 */
protected final boolean tryRelease(int releases) {
    int c = getState() - releases; // 釋放鎖後的狀態,0 表示釋放鎖成功
    // 如果擁有鎖的線程不是當前線程的話拋出異常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) { // 鎖被成功釋放
        free = true;
        setExclusiveOwnerThread(null); // 清空獨佔線程
    }
    setState(c); // 更新 state 值,0 表示爲釋放鎖成功
    return free;
}

在 tryRelease 方法中,會先判斷當前的線程是不是佔用鎖的線程,如果不是的話,則會拋出異常;如果是的話,則先計算鎖的狀態值 getState() - releases 是否爲 0,如果爲 0,則表示可以正常的釋放鎖,然後清空獨佔的線程,最後會更新鎖的狀態並返回執行結果。

3)JDK 1.6 鎖優化

3.1)自適應自旋鎖

JDK 1.5 在升級爲 JDK 1.6 時,HotSpot 虛擬機團隊在鎖的優化上下了很大功夫,比如實現了自適應式自旋鎖、鎖升級等。
JDK 1.6 引入了自適應式自旋鎖意味着自旋的時間不再是固定的時間了,比如在同一個鎖對象上,如果通過自旋等待成功獲取了鎖,那麼虛擬機就會認爲,它下一次很有可能也會成功 (通過自旋獲取到鎖),因此允許自旋等待的時間會相對的比較長,而當某個鎖通過自旋很少成功獲得過鎖,那麼以後在獲取該鎖時,可能會直接忽略掉自旋的過程,以避免浪費 CPU 的資源,這就是自適應自旋鎖的功能。

3.2)鎖升級

鎖升級其實就是從偏向鎖到輕量級鎖再到重量級鎖升級的過程,這是 JDK 1.6 提供的優化功能,也稱之爲鎖膨脹。

偏向鎖是指在無競爭的情況下設置的一種鎖狀態。偏向鎖的意思是它會偏向於第一個獲取它的線程,當鎖對象第一次被獲取到之後,會在此對象頭中設置標示爲“01”,表示偏向鎖的模式,並且在對象頭中記錄此線程的 ID,這種情況下,如果是持有偏向鎖的線程每次在進入的話,不再進行任何同步操作,如 Locking、Unlocking 等,直到另一個線程嘗試獲取此鎖的時候,偏向鎖模式纔會結束,偏向鎖可以提高帶有同步但無競爭的程序性能。但如果在多數鎖總會被不同的線程訪問時,偏向鎖模式就比較多餘了,此時可以通過 -XX:-UseBiasedLocking 來禁用偏向鎖以提高性能。

輕量鎖是相對於重量鎖而言的,在 JDK 1.6 之前,synchronized 是通過操作系統的互斥量(mutex lock)來實現的,這種實現方式需要在用戶態和核心態之間做轉換,有很大的性能消耗,這種傳統實現鎖的方式被稱之爲重量鎖。

而輕量鎖是通過比較並交換(CAS,Compare and Swap)來實現的,它對比的是線程和對象的 Mark Word(對象頭中的一個區域),如果更新成功則表示當前線程成功擁有此鎖;如果失敗,虛擬機會先檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是,則說明當前線程已經擁有此鎖,否則,則說明此鎖已經被其他線程佔用了。當兩個以上的線程爭搶此鎖時,輕量級鎖就膨脹爲重量級鎖,這就是鎖升級的過程,也是 JDK 1.6 鎖優化的內容。

4)小結

本文主要講了 synchronized 和 ReentrantLock 的實現過程,然後講了 synchronized 和 ReentrantLock 的區別,最後通過源碼的方式講了 ReentrantLock 加鎖和解鎖的執行流程。接着又講了 JDK 1.6 中的鎖優化,包括自適應式自旋鎖的實現過程,以及 synchronized 的三種鎖狀態和鎖升級的執行流程。

synchronized 剛開始爲偏向鎖,隨着鎖競爭越來越激烈,會升級爲輕量級鎖和重量級鎖。如果大多數鎖被不同的線程所爭搶就不建議使用偏向鎖了。

——————————————————————————————————————————————
關注公衆號,回覆 【算法】,獲取高清算法書!
在這裏插入圖片描述

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