7 ReentrantReadWriteLock

weixin_33850890發表於2018-06-26

在併發場景中用於解決執行緒安全的問題,我們幾乎會高頻率的使用到獨佔式鎖,通常使用java提供的關鍵字synchronized或者concurrents包中實現了Lock介面的ReentrantLock。它們都是獨佔式獲取鎖,也就是在同一時刻只有一個執行緒能夠獲取鎖。而在一些業務場景中,大部分只是讀資料,寫資料很少,如果僅僅是讀資料的話並不會影響資料正確性(出現髒讀),而如果在這種業務場景下,依然使用獨佔鎖的話,很顯然這將是出現效能瓶頸的地方。
針對這種讀多寫少的情況,java還提供了另外一個實現Lock介面的ReentrantReadWriteLock(讀寫鎖)。讀寫所允許同一時刻被多個讀執行緒訪問,但是在寫執行緒訪問時,所有的讀執行緒和其他的寫執行緒都會被阻塞。
讀寫鎖的主要特性:

  1. 公平性選擇:支援非公平性(預設)和公平的鎖獲取方式,吞吐量還是非公平優於公平;
  2. 重入性:支援重入,讀鎖獲取後能再次獲取,寫鎖獲取之後能夠再次獲取寫鎖,同時也能夠獲取讀鎖;
  3. 鎖降級:遵循獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成為讀鎖

讀寫鎖ReentrantReadWriteLock實現介面ReadWriteLock,該介面維護了一對相關的鎖,一個用於只讀操作,另一個用於寫入操作。只要沒有 writer,讀取鎖可以由多個 reader 執行緒同時保持。寫入鎖是獨佔的。

ReadWriteLock定義了兩個方法。readLock()返回用於讀操作的鎖,writeLock()返回用於寫操作的鎖。

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

再來看看ReadWriteLock的實現類ReentrantReadWriteLock

/** 內部類  讀鎖 */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** 內部類  寫鎖 */
private final ReentrantReadWriteLock.WriteLock writerLock;

final Sync sync;

/** 使用預設(非公平)的排序屬性建立一個新的 ReentrantReadWriteLock */
public ReentrantReadWriteLock() {
    this(false);
}

/** 使用給定的公平策略建立一個新的 ReentrantReadWriteLock */
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

/** 返回用於寫入操作的鎖 */
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
/** 返回用於讀取操作的鎖 */
public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

abstract static class Sync extends AbstractQueuedSynchronizer {
    /**
     * 省略其餘原始碼
     */
}
public static class WriteLock implements Lock, java.io.Serializable{
    /**
     * 省略其餘原始碼
     */
}

public static class ReadLock implements Lock, java.io.Serializable {
    /**
     * 省略其餘原始碼
     */
}

ReentrantReadWriteLock與ReentrantLock一樣,其鎖主體依然是Sync,它的讀鎖、寫鎖都是依靠Sync來實現的。所以ReentrantReadWriteLock實際上只有一個鎖,只是在獲取讀取鎖和寫入鎖的方式上不一樣而已,它的讀寫鎖其實就是兩個類:ReadLock、writeLock,這兩個類都是lock實現。

在ReentrantLock中使用一個int型別的state來表示同步狀態,該值表示鎖被一個執行緒重複獲取的次數。但是讀寫鎖ReentrantReadWriteLock內部維護著兩個一對鎖,需要用一個變數維護多種狀態。所以讀寫鎖採用“按位切割使用”的方式來維護這個變數,將其切分為兩部分,高16為表示讀,低16為表示寫。分割之後,讀寫鎖是如何迅速確定讀鎖和寫鎖的狀態呢?通過為運算。假如當前同步狀態為S,那麼寫狀態等於 S & 0x0000FFFF(將高16位全部抹去),讀狀態等於S >>> 16(無符號補0右移16位)。程式碼如下:

    static final int SHARED_SHIFT   = 16;
    static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
    static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
    static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

    //同步狀態的低16位用來表示寫鎖的獲取次數
    static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
    //同步狀態的高16位用來表示讀鎖被獲取的次數
    static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

當讀鎖已經被讀執行緒獲取或者寫鎖已經被其他寫執行緒獲取,則寫鎖獲取失敗;否則,獲取成功並支援重入,增加寫狀態。

寫鎖

寫鎖就是一個支援可重入的排他鎖。

寫鎖的獲取

ReentrantReadWriteLockWriteLock裡面

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

最終也走到tryAcquire(int arg),該方法在內部類Sync中實現:

    protected final boolean tryAcquire(int var1) {
        Thread var2 = Thread.currentThread();
        int var3 = this.getState();
        int var4 = exclusiveCount(var3);
        if (var3 != 0) {
            //c != 0 && var4  == 0 表示存在讀鎖
            //當前執行緒不是已經獲取寫鎖的執行緒
            if (var4 != 0 && var2 == this.getExclusiveOwnerThread()) {
                if (var4 + exclusiveCount(var1) > 65535) {
                    throw new Error("Maximum lock count exceeded");
                } else {
                    this.setState(var3 + var1);
                    return true;
                }
            } else {
                return false;
            }
        } else if (!this.writerShouldBlock() && this.compareAndSetState(var3, var3 + var1)) {
            this.setExclusiveOwnerThread(var2);
            return true;
        } else {
            return false;
        }
    }

該方法和ReentrantLock的tryAcquire(int arg)大致一樣,在判斷重入時增加了一項條件:讀鎖是否存在。因為要確保寫鎖的操作對讀鎖是可見的,如果在存在讀鎖的情況下允許獲取寫鎖,那麼那些已經獲取讀鎖的其他執行緒可能就無法感知當前寫執行緒的操作。因此只有等讀鎖完全釋放後,寫鎖才能夠被當前執行緒所獲取,一旦寫鎖獲取了,所有其他讀、寫執行緒均會被阻塞。

寫鎖的釋放

獲取了寫鎖用完了則需要釋放,WriteLock提供了unlock()方法釋放寫鎖:

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

寫鎖的釋放最終還是會呼叫AQS的模板方法release(int arg)方法,該方法首先呼叫tryRelease(int arg)方法嘗試釋放鎖,tryRelease(int arg)方法為讀寫鎖內部類Sync中定義了,如下:

protected final boolean tryRelease(int releases) {
    //釋放的執行緒不為鎖的持有者
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    int nextc = getState() - releases;
    //若寫鎖的新執行緒數為0,則將鎖的持有者設定為null
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        setExclusiveOwnerThread(null);
    setState(nextc);
    return free;
}

寫鎖釋放鎖的整個過程和獨佔鎖ReentrantLock相似,每次釋放均是減少寫狀態,當寫狀態為0時表示 寫鎖已經完全釋放了,從而等待的其他執行緒可以繼續訪問讀寫鎖,獲取同步狀態,同時此次寫執行緒的修改對後續的執行緒可見。

讀鎖

讀鎖為一個可重入的共享鎖,它能夠被多個執行緒同時持有,在沒有其他寫執行緒訪問時,讀鎖總是或獲取成功。

讀鎖的獲取

讀鎖的獲取可以通過ReadLock的lock()方法:

    public void lock() {
        sync.acquireShared(1);
    }

Sync的acquireShared(int arg)定義在AQS中:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

tryAcqurireShared(int arg)嘗試獲取讀同步狀態,該方法主要用於獲取共享式同步狀態,獲取成功返回 >= 0的返回結果,否則返回 < 0 的返回結果。

protected final int tryAcquireShared(int unused) {
    //當前執行緒
    Thread current = Thread.currentThread();
    int c = getState();
    //exclusiveCount(c)計算寫鎖
    //如果存在寫鎖,且鎖的持有者不是當前執行緒,直接返回-1
    //存在鎖降級問題,後續闡述
    if (exclusiveCount(c) != 0 &&
            getExclusiveOwnerThread() != current)
        return -1;
    //讀鎖
    int r = sharedCount(c);

    /*
     * readerShouldBlock():讀鎖是否需要等待(公平鎖原則)
     * r < MAX_COUNT:持有執行緒小於最大數(65535)
     * compareAndSetState(c, c + SHARED_UNIT):設定讀取鎖狀態
     */
    if (!readerShouldBlock() &&
            r < MAX_COUNT &&
            compareAndSetState(c, c + SHARED_UNIT)) {
        /*
         * holdCount部分後面講解
         */
        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);
}

讀鎖獲取過程:

  1. 因為存在鎖降級情況,如果存在寫鎖且鎖的持有者不是當前執行緒則直接返回失敗,否則繼續

  2. 依據公平性原則,判斷讀鎖是否需要阻塞,讀鎖持有執行緒數小於最大值(65535),且設定鎖狀態成功,執行以下程式碼(對於HoldCounter下面再闡述),並返回1。如果不滿足改條件,執行fullTryAcquireShared()。

    final int fullTryAcquireShared(Thread current) {
     HoldCounter rh = null;
     for (;;) {
         int c = getState();
         //鎖降級
         if (exclusiveCount(c) != 0) {
             if (getExclusiveOwnerThread() != current)
                 return -1;
         }
         //讀鎖需要阻塞
         else if (readerShouldBlock()) {
             //列頭為當前執行緒
             if (firstReader == current) {
             }
             //HoldCounter後面講解
             else {
                 if (rh == null) {
                     rh = cachedHoldCounter;
                     if (rh == null || rh.tid != getThreadId(current)) {
                         rh = readHolds.get();
                         if (rh.count == 0)
                             readHolds.remove();
                     }
                 }
                 if (rh.count == 0)
                     return -1;
             }
         }
         //讀鎖超出最大範圍
         if (sharedCount(c) == MAX_COUNT)
             throw new Error("Maximum lock count exceeded");
         //CAS設定讀鎖成功
         if (compareAndSetState(c, c + SHARED_UNIT)) {
             //如果是第1次獲取“讀取鎖”,則更新firstReader和firstReaderHoldCount
             if (sharedCount(c) == 0) {
                 firstReader = current;
                 firstReaderHoldCount = 1;
             }
             //如果想要獲取鎖的執行緒(current)是第1個獲取鎖(firstReader)的執行緒,則將firstReaderHoldCount+1
             else if (firstReader == current) {
                 firstReaderHoldCount++;
             } else {
                 if (rh == null)
                     rh = cachedHoldCounter;
                 if (rh == null || rh.tid != getThreadId(current))
                     rh = readHolds.get();
                 else if (rh.count == 0)
                     readHolds.set(rh);
                 //更新執行緒的獲取“讀取鎖”的共享計數
                 rh.count++;
                 cachedHoldCounter = rh; // cache for release
             }
             return 1;
         }
     }
    }
    

fullTryAcquireShared(Thread current)會根據“是否需要阻塞等待”,“讀取鎖的共享計數是否超過限制”等等進行處理。如果不需要阻塞等待,並且鎖的共享計數沒有超過限制,則通過CAS嘗試獲取鎖,並返回1。

讀鎖的釋放

與寫鎖相同,讀鎖也提供了unlock()釋放讀鎖:

    public void unlock() {
        sync.releaseShared(1);
    }

unlcok()方法內部使用Sync的releaseShared(int arg)方法,該方法定義在AQS中:

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

呼叫tryReleaseShared(int arg)嘗試釋放讀鎖,該方法定義在讀寫鎖的Sync內部類中:

   protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    //如果想要釋放鎖的執行緒為第一個獲取鎖的執行緒
    if (firstReader == current) {
        //僅獲取了一次,則需要將firstReader 設定null,否則 firstReaderHoldCount - 1
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    }
    //獲取rh物件,並更新“當前執行緒獲取鎖的資訊”
    else {
        HoldCounter rh = cachedHoldCounter;
        if (rh == null || rh.tid != getThreadId(current))
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        --rh.count;
    }
    //CAS更新同步狀態
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

HoldCounter

在讀鎖獲取鎖和釋放鎖的過程中,我們一直都可以看到一個變數rh (HoldCounter ),該變數在讀鎖中扮演著非常重要的作用。

我們瞭解讀鎖的內在機制其實就是一個共享鎖,為了更好理解HoldCounter ,我們暫且認為它不是一個鎖的概率,而相當於一個計數器。一次共享鎖的操作就相當於在該計數器的操作。獲取共享鎖,則該計數器 + 1,釋放共享鎖,該計數器 - 1。只有當執行緒獲取共享鎖後才能對共享鎖進行釋放、重入操作。所以HoldCounter的作用就是當前執行緒持有共享鎖的數量,這個數量必須要與執行緒繫結在一起,否則操作其他執行緒鎖就會丟擲異常。我們先看HoldCounter的定義:

    static final class HoldCounter {
        int count = 0;
        final long tid = getThreadId(Thread.currentThread());
    }

HoldCounter 定義非常簡單,就是一個計數器count 和執行緒 id tid 兩個變數。按照這個意思我們看到HoldCounter 是需要和某給執行緒進行繫結了,我們知道如果要將一個物件和執行緒繫結僅僅有tid是不夠的,而且從上面的程式碼我們可以看到HoldCounter 僅僅只是記錄了tid,根本起不到繫結執行緒的作用。那麼怎麼實現呢?答案是ThreadLocal,定義如下:

    static final class ThreadLocalHoldCounter
        extends ThreadLocal<HoldCounter> {
        public HoldCounter initialValue() {
            return new HoldCounter();
        }
    }

通過上面程式碼HoldCounter就可以與執行緒進行繫結了。故而,HoldCounter應該就是繫結執行緒上的一個計數器,而ThradLocalHoldCounter則是執行緒繫結的ThreadLocal。從上面我們可以看到ThreadLocal將HoldCounter繫結到當前執行緒上,同時HoldCounter也持有執行緒Id,這樣在釋放鎖的時候才能知道ReadWriteLock裡面快取的上一個讀取執行緒(cachedHoldCounter)是否是當前執行緒。這樣做的好處是可以減少ThreadLocal.get()的次數,因為這也是一個耗時操作。需要說明的是這樣HoldCounter繫結執行緒id而不繫結執行緒物件的原因是避免HoldCounter和ThreadLocal互相繫結而GC難以釋放它們(儘管GC能夠智慧的發現這種引用而回收它們,但是這需要一定的代價),所以其實這樣做只是為了幫助GC快速回收物件而已。

看到這裡我們明白了HoldCounter作用了,我們在看一個獲取讀鎖的程式碼段:

            else if (firstReader == current) {
                firstReaderHoldCount++;
            } else {
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                cachedHoldCounter = rh; // cache for release
            }

這段程式碼涉及了幾個變數:firstReader 、firstReaderHoldCount、cachedHoldCounter 。我們先理清楚這幾個變數:

 private transient Thread firstReader = null;
 private transient int firstReaderHoldCount;
 private transient HoldCounter cachedHoldCounter;

firstReader 看名字就明白了為第一個獲取讀鎖的執行緒,firstReaderHoldCount為第一個獲取讀鎖的重入數,cachedHoldCounter為HoldCounter的快取。

理清楚上面所有的變數了,HoldCounter也明白了,我們就來給上面那段程式碼標明註釋,如下:

//如果獲取讀鎖的執行緒為第一次獲取讀鎖的執行緒,則firstReaderHoldCount重入數 + 1
else if (firstReader == current) {
    firstReaderHoldCount++;
} else {
    //非firstReader計數
    if (rh == null)
        rh = cachedHoldCounter;
    //rh == null 或者 rh.tid != current.getId(),需要獲取rh
    if (rh == null || rh.tid != getThreadId(current))
        rh = readHolds.get();
        //加入到readHolds中
    else if (rh.count == 0)
        readHolds.set(rh);
    //計數+1
    rh.count++;
    cachedHoldCounter = rh; // cache for release
}

這裡解釋下為何要引入firstRead、firstReaderHoldCount。這是為了一個效率問題,firstReader是不會放入到readHolds中的,如果讀鎖僅有一個的情況下就會避免查詢readHolds。

鎖降級

讀寫鎖支援鎖降級,遵循按照獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成為讀鎖,不支援鎖升級,關於鎖降級下面的示例程式碼摘自ReentrantWriteReadLock原始碼中:

void processCachedData() {
    rwl.readLock().lock();
    if (!cacheValid) {
        // Must release read lock before acquiring write lock
        rwl.readLock().unlock();
        rwl.writeLock().lock();
        try {
            // Recheck state because another thread might have
            // acquired write lock and changed state before we did.
            if (!cacheValid) {
                data = ...
        cacheValid = true;
      }
      // Downgrade by acquiring read lock before releasing write lock
      rwl.readLock().lock();
    } finally {
      rwl.writeLock().unlock(); // Unlock write, still hold read
    }
  }

  try {
    use(data);
  } finally {
    rwl.readLock().unlock();
  }
}
}

鎖降級中讀鎖的獲取釋放為必要?肯定是必要的。試想,假如當前執行緒A不獲取讀鎖而是直接釋放了寫鎖,這個時候另外一個執行緒B獲取了寫鎖,那麼這個執行緒B對資料的修改是不會對當前執行緒A可見的。如果獲取了讀鎖,則執行緒B在獲取寫鎖過程中判斷如果有讀鎖還沒有釋放則會被阻塞,只有當前執行緒A釋放讀鎖後,執行緒B才會獲取寫鎖成功。

讀寫鎖的應用場景

在多執行緒的環境下,對同一份資料進行讀寫,會涉及到執行緒安全的問題。比如在一個執行緒讀取資料的時候,另外一個執行緒在寫資料,而導致前後資料的不一致性;一個執行緒在寫資料的時候,另一個執行緒也在寫,同樣也會導致執行緒前後看到的資料的不一致性。
這時候可以在讀寫方法中加入互斥鎖,任何時候只能允許一個執行緒的一個讀或寫操作,而不允許其他執行緒的讀或寫操作,這樣是可以解決這樣以上的問題,但是效率卻大打折扣了。因為在真實的業務場景中,一份資料,讀取資料的操作次數通常高於寫入資料的操作,而執行緒與執行緒間的讀讀操作是不涉及到執行緒安全的問題,沒有必要加入互斥鎖,只要在讀-寫,寫-寫期間上鎖就行了。

相關文章