Java併發編程系列---可重入鎖和讀寫鎖的實現原理、源碼分析

一、重入鎖

1.1 什麼是重入鎖

重入鎖ReentrantLock,就是支持重進入的鎖,它表示該鎖能夠支持一個線程對資源的重複加鎖。除此之外,該鎖的還支持獲取鎖時的公平和非公平性選擇。

1.2 重入鎖有什麼用

例子:之前AQS的一個自己實現的鎖

package com.example.demo.thread;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * @author : pengweiwei
 * @date : 2020/2/9 6:51 下午
 */
public class Mutex implements Lock {

    // 靜態內部類,自定義同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 是否處於佔用狀態
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 當狀態爲0的時候獲取鎖
        public boolean tryAcquire(int acquires) {
            //如果狀態從 0 變成1 成功 則表示獲取到了鎖
            if (compareAndSetState(0, 1)) {
                //設置當前線程擁有獨佔訪問權
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 釋放鎖,將狀態設置爲0
        protected boolean tryRelease(int releases) {
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // 返回一個Condition,每個condition都包含了一個condition隊列
        Condition newCondition() {
            return new ConditionObject();
        }
    }

    private final Sync sync = new Sync();

    @Override
    public void lock() {
        sync.acquire(1);
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }

    @Override
    public void unlock() {
        sync.release(1);
    }

    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

問題:
當一個線程調用Mutex 的lock()方法獲取鎖之後,如果再次調用lock()方法,則該線程將會被自己所阻塞,原因是 Mutex在實現tryAcquire(int acquires)方法時沒有考慮佔有鎖的線程再次獲取鎖的場景,而在調用tryAcquire(int acquires)方法時返回了false,導致該線程被阻塞。簡單地說,Mutex 是一個不支持重進入的鎖。而synchronized關鍵字隱式的支持重進入,比如一個 synchronized修飾的遞歸方法,在方法執行時,執行線程在獲取了鎖之後仍能連續多次地 獲得該鎖,而不像Mutex由於獲取了鎖,而在下一次獲取鎖時出現阻塞自己的情況。

ReentrantLock雖然沒能像synchronized關鍵字一樣支持隱式的重進入,但是在調用 lock()方法時,已經獲取到鎖的線程,能夠再次調用lock()方法獲取鎖而不被阻塞。

1.3 什麼是公平鎖和非公平鎖

如果在絕對時間上,先對鎖進行獲取的請求一定 先被滿足,那麼這個鎖是公平的,反之,是不公平的。

公平的獲取鎖,也就是等待時間最 長的線程最優先獲取鎖,也可以說鎖獲取是順序的。ReentrantLock提供了一個構造函數, 能夠控制鎖是否是公平的。

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     * 創建一個給定公平策略的讀寫鎖實例。
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

公平的鎖機制往往沒有非公平的效率高。

1.4 ReentrantLock怎麼實現重進入

重進入是指任意線程在獲取到鎖之後能夠再次獲取該鎖而不會被鎖所阻塞,該特性的實現需要解決以下兩個問題。

  1. 線程再次獲取鎖。鎖需要去識別獲取鎖的線程是否爲當前佔據鎖的線程,如果是,則再次成功獲取。
  2. 鎖的最終釋放。線程重複n次獲取了鎖,隨後在第n次釋放該鎖後,其他線程能夠獲取到該鎖。鎖的最終釋放要求鎖對於獲取進行計數自增,計數表示當前鎖被重複獲取的次數,而鎖被釋放時,計數自減,當計數等於0時表示鎖已經成功釋放。

1.4.1 ReentrantLock的nonfairTryAcquire方法

ReentrantLock是通過組合自定義同步器來實現鎖的獲取與釋放,以非公平性(默認 的)實現爲例。源碼如下。

        /**
         * 不公平的嘗試獲取鎖方法。
         */
        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState(); //拿到當前的獲取鎖的次數
            if (c == 0) {
                if (compareAndSetState(0, acquires)) { //修改爲1,acquires傳入1
                    setExclusiveOwnerThread(current); //設置當前獨佔線程爲當前線程
                    return true;
                }
            }
            //c != 0時 說明有對象獲得鎖
            else if (current == getExclusiveOwnerThread()) { //如果獲得鎖的線程時當前線程
                int nextc = c + acquires; //將獲取次數加1
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc); //然後設置獲取次數
                return true;
            }
            return false;
        }

該方法增加了再次獲取同步狀態的處理邏輯:通過判斷當前線程是否爲獲取鎖的線程 來決定獲取操作是否成功,如果是獲取鎖的線程再次請求,則將同步狀態值進行增加並返 回true,表示獲取同步狀態成功。

1.4.2 ReentrantLock的tryRelease方法

成功獲取鎖的線程再次獲取鎖,只是增加了同步狀態值,這也就要求ReentrantLock在 釋放同步狀態時減少同步狀態值。源碼如下:

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases; //獲取鎖次數減去需要釋放的次數(一般是1)
            if (Thread.currentThread() != getExclusiveOwnerThread()) //保證是當前獨佔鎖的線程來進行釋放鎖
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) { //如果c == 0 表示完全釋放了鎖
                free = true;
                setExclusiveOwnerThread(null); //當前獨佔鎖的線程設置爲null
            }
            setState(c); //設置獲取鎖次數
            return free;
        }

如果該鎖被獲取了n次,那麼前(n-1)次tryRelease(int releases)方法必須返回false,而只 有同步狀態完全釋放了,才能返回true。可以看到,該方法將同步狀態是否爲0作爲最終 釋放的條件,當同步狀態爲0時,將佔有線程設置爲null,並返回true,表示釋放成功。

1.5 公平與非公平獲取鎖的區別

公平性與否是針對獲取鎖而言的,如果一個鎖是公平的,那麼鎖的獲取順序就應該符合請求的絕對時間順序,也就是FIFO。

1.5.1 ReentrantLock的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);
                return true;
            }
            return false;
        }
    }
    public final boolean hasQueuedPredecessors() {
        // The correctness of this depends on head being initialized
        // before tail and on head.next being accurate if the current
        // thread is first in queue.
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

該方法與nonfairTryAcquire(int acquires)比較,唯一不同的位置爲判斷條件多了 hasQueuedPredecessors()方法,即加入了同步隊列中當前節點是否有前驅節點的判斷,如果該方法返回true,則表示有線程比當前線程更早地請求獲取鎖,因此需要等待前驅線程 獲取並釋放鎖之後才能繼續獲取鎖。

公平性鎖保證了鎖的獲取按照FIFO原則,而代價是進行大量的線程切換。非 公平性鎖雖然可能造成線程“飢餓”,但極少的線程切換,保證了其更大的吞吐量。

二、讀寫鎖

2.1 什麼是讀寫鎖

之前提到鎖(如Mutex和ReentrantLock)基本都是排他鎖,這些鎖在同一時刻只允許 一個線程進行訪問,而讀寫鎖在同一時刻可以允許多個讀線程訪問,但是在寫線程訪問 時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖, 通過分離讀鎖和寫鎖,使得併發性相比一般的排他鎖有了很大提升。

2.2 讀寫鎖的作用

除了保證寫操作對讀操作的可見性以及併發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。假設在程序中定義一個共享的用作緩存數據結構,它大部分時間提供讀 服務(例如查詢和搜索),而寫操作佔有的時間很少,但是寫操作完成之後的更新需要對 後續的讀服務可見。

在沒有讀寫鎖支持的(Java 5之前)時候,如果需要完成上述工作就要使用Java的等 待通知機制,就是當寫操作開始時,所有晚於寫操作的讀操作均會進入等待狀態,只有寫 操作完成並進行通知之後,所有等待的讀操作才能繼續執行(寫操作之間依靠 synchronized關鍵進行同步),這樣做的目的是使讀操作能讀取到正確的數據,不會出現 髒讀。改用讀寫鎖實現上述功能,只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可。 當寫鎖被獲取到時,後續(非當前寫操作線程)的讀寫操作都會被阻塞,寫鎖釋放之後, 所有操作繼續執行,編程方式相對於使用等待通知機制的實現方式而言,變得簡單明瞭。

一般情況下,讀寫鎖的性能都會比排它鎖好,因爲大多數場景讀是多於寫的。在讀多於寫的情況下,讀寫鎖能夠提供比排它鎖更好的併發性和吞吐量。

Java併發包提供讀寫鎖 的實現是ReentrantReadWriteLock。

2.3 讀寫鎖(ReentrantReadWriteLock)的特性

  1. 公平式選擇:支持非公平(默認)和公平的鎖獲取方式,吞吐量還是非公平優於公平
  2. 重進入:該鎖支持重進入,以讀寫線程爲例:讀線程在獲取了讀鎖之後,能夠再次獲取讀鎖。而寫線程在獲取了寫鎖之後能夠再次獲取寫鎖,同時也可以獲取讀鎖
  3. 鎖降級:遵循獲取寫鎖、獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成爲讀鎖。

2.4 ReentrantReadWriteLock使用示例

package com.example.demo.Lock;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @author : pengweiwei
 * @date : 2020/2/11 5:52 下午
 */
public class ReentrantReadWriteLockDemo {

    static Map<String, Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock r = rwl.readLock();
    static Lock w = rwl.writeLock();

    // 獲取一個key對應的value
    public static Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }

    // 設置key對應的value,並返回舊的value
    public static  Object put(String key, Object value) {
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }

    // 清空所有的內容
    public static  void clear() {
        w.lock();
        try {
            map.clear();
        } finally {
            w.unlock();
        }
    }

}

在讀操作get(String key)方法中,需要獲取讀 鎖,這使得併發訪問該方法時不會被阻塞。寫操作put(String key,Object value)方法和 clear()方法,在更新HashMap時必須提前獲取寫鎖,當獲取寫鎖後,其他線程對於讀鎖和 寫鎖的獲取均被阻塞,而只有寫鎖被釋放之後,其他讀寫操作才能繼續。使用讀寫鎖提升讀操作的併發性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了編程方式。

2.5 ReentrantReadWriteLock的實現分析

ReentrantReadWriteLock的實現,主要包括:讀寫狀態的設計、寫鎖的獲取與釋放、讀鎖的獲取與釋放以及鎖降級

2.5.1 讀寫狀態的設計

讀寫鎖的自定義同步器需要在同步狀態(一個整型變量)上維護多個讀線程和一個 寫線程的狀態,使得該狀態的設計成爲讀寫鎖實現的關鍵。
如果在一個整型變量上維護多種狀態,就一定需要“按位切割使用”這個變量,讀寫鎖 將變量切分成了兩個部分,高16位表示讀,低16位表示寫,劃分方式如下圖所示。
在這裏插入圖片描述
當前同步狀態表示一個線程已經獲取了寫鎖,且重進入了兩次,同時也連續獲取了兩 次讀鎖。讀寫鎖是如何迅速確定讀和寫各自的狀態呢?答案是通過位運算。假設當前同步 狀態值爲S,寫狀態等於S&0x0000FFFF(將高16位全部抹去),讀狀態等於S>>>16(無 符號補0右移16位)。當寫狀態增加1時,等於S+1,當讀狀態增加1時,等於S+(1<<16), 也就是S+0x00010000。
根據狀態的劃分能得出一個推論:S不等於0時,當寫狀態(S&0x0000FFFF)等於0 時,則讀狀態(S>>>16)大於0,即讀鎖已被獲取。

2.5.2 寫鎖的獲取與釋放

寫鎖是一個支持重進入的排它鎖。如果當前線程已經獲取了寫鎖,則增加寫狀態。如果當前線程在獲取寫鎖時,讀鎖已經被獲取(讀狀態不爲0)或者該線程不是已經獲取寫鎖的線程,則當前線程進入等待狀態。

源碼:

        protected final boolean tryAcquire(int acquires) {
            /*
             * 註釋:
             * 1. 如果讀計數非零或寫計數非零並且同步狀態所有者不是當前線程,失敗
             * 2. 如果計數飽和,則失敗。(只有當count已經非零時纔會發生這種情況)
             * 3. 否則,如果是可重入獲取或隊列策略允許,則此線程有資格獲得鎖。如果是,則更新狀態並設置所有者。
             */
            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c);
            if (c != 0) {
                // (Note: if c != 0 and w == 0 then shared count != 0)
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                setState(c + acquires);
                return true;
            }
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }

該方法除了重入條件(當前線程爲獲取了寫鎖的線程)之外,增加了一個讀鎖是否存 在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在於:讀寫鎖要確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那麼正在運行的其他讀線程就 無法感知到當前寫線程的操作。因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當 前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的後續訪問均被阻塞。

寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態 爲0時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續讀寫線程可見。

源碼:


        protected final boolean tryRelease(int releases) {
            if (!isHeldExclusively()) //如果當前線程不是獨佔線程
                throw new IllegalMonitorStateException();
            int nextc = getState() - releases;
            boolean free = exclusiveCount(nextc) == 0;
            if (free)
                setExclusiveOwnerThread(null);
            setState(nextc);
            return free;
        }

2.5.3 讀鎖的獲取與釋放

讀鎖是一個支持重進入的共享鎖,它能夠被多個線程同時獲取,在沒有其他寫線程訪 問(或者寫狀態爲0)時,讀鎖總會被成功地獲取,而所做的也只是(線程安全的)增加 讀狀態。如果當前線程已經獲取了讀鎖,則增加讀狀態。如果當前線程在獲取讀鎖時,寫 鎖已被其他線程獲取,則進入等待狀態。獲取讀鎖的實現從Java 5到Java 6變得複雜許 多,主要原因是新增了一些功能,例如getReadHoldCount()方法,作用是返回當前線程獲 取讀鎖的次數。讀狀態是所有線程獲取讀鎖次數的總和,而每個線程各自獲取讀鎖的次數 只能選擇保存在ThreadLocal中,由線程自身維護,這使獲取讀鎖的實現變得複雜。

源碼:

        protected final int tryAcquireShared(int unused) {
            /*
             * Walkthrough:
             * 1. 如果另一個線程持有寫鎖,則失敗
             * 2. 否則,這個線程有資格進入lock wrt狀態,所以詢問它是否應該因爲隊列策略而阻塞。如果不是,嘗試授予
             * 通過CAS狀態和更新計數。注意,步驟不檢查重入獲取,它被推遲到完整版本,以避免在更典型的不可重入情況下
             * 檢查持有計數。
             * 3. 如果第2步失敗,要麼是因爲線程顯然不符合條件,要麼是CAS失敗,要麼是count飽和,
             * 那麼用完整的重試循環鏈接到版本
             */
            Thread current = Thread.currentThread();
            int c = getState();
            if (exclusiveCount(c) != 0 &&
                getExclusiveOwnerThread() != current)
                return -1;
            int r = sharedCount(c);
            if (!readerShouldBlock() &&
                r < MAX_COUNT &&
                compareAndSetState(c, c + SHARED_UNIT)) {
                if (r == 0) {
                    firstReader = current;
                    firstReaderHoldCount = 1;
                } else if (firstReader == current) {
                    firstReaderHoldCount++;
                } else {
                    HoldCounter rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current))
                        cachedHoldCounter = rh = readHolds.get();
                    else if (rh.count == 0)
                        readHolds.set(rh);
                    rh.count++;
                }
                return 1;
            }
            return fullTryAcquireShared(current);
        }

在tryAcquireShared(int unused)方法中,如果其他線程已經獲取了寫鎖,則當前線程獲 取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程 (線程安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。
讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態,減 少的值是(1<<16)。

2.6 鎖降級

鎖降級指的是寫鎖降級成爲讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲 取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程。

鎖降級中讀鎖的獲取是否必要呢?答案是必要的。主要是爲了保證數據的可見性,如 果當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程(記作線程T)獲取了寫

鎖並修改了數據,那麼當前線程無法感知線程T的數據更新。如果當前線程獲取讀鎖,即 遵循鎖降級的步驟,則線程T將會被阻塞,直到當前線程使用數據並釋放讀鎖之後,線程 T才能獲取寫鎖進行數據更新。
RentrantReadWriteLock不支持鎖升級(把持讀鎖、獲取寫鎖,最後釋放讀鎖的過 程)。目的也是保證數據可見性,如果讀鎖已被多個線程獲取,其中任意線程成功獲取了 寫鎖並更新了數據,則其更新對其他獲取到讀鎖的線程是不可見的。

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