深入理解讀寫鎖ReentrantReadWriteLock和併發容器CopyOnWriteArrayList

1.讀寫鎖的介紹

在併發場景中用於解決線程安全的問題,我們幾乎會高頻率的使用到獨佔式鎖,通常使用jvm提供的關鍵字synchronized或者juc中實現了Lock接口的ReentrantLock。它們都是獨佔式獲取鎖,也就是在同一時刻只有一個線程能夠獲取鎖。而在一些業務場景中,大部分只是讀數據,寫數據很少,如果僅僅是讀數據的話並不會影響數據正確性(出現髒讀),而如果在這種業務場景下,依然使用獨佔鎖的話,很顯然這將是出現性能瓶頸的地方。針對這種讀多寫少的情況,java還提供了另外一個實現Lock接口的ReentrantReadWriteLock(讀寫鎖)。讀寫所允許同一時刻被多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他的寫線程都會被阻塞。在分析WirteLock和ReadLock的互斥性時可以按照WriteLock與WriteLock之間,WriteLock與ReadLock之間以及ReadLock與ReadLock之間進行分析。這裏簡要做一個歸納總結:

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

要想能夠徹底的理解讀寫鎖必須能夠理解這樣幾個問題:1. 讀寫鎖是怎樣實現分別記錄讀寫狀態的?2. 寫鎖是怎樣獲取和釋放的?3.讀鎖是怎樣獲取和釋放的?我們帶着這樣的三個問題,再去了解下讀寫鎖。

2.寫鎖詳解

2.1.寫鎖的獲取

同步組件的實現聚合了同步器(AQS),並通過重寫同步器(AQS)中的方法實現同步組件的同步語義。因此,寫鎖的實現依然也是採用這種方式。在同一時刻寫鎖是不能被多個線程所獲取,很顯然寫鎖是獨佔式鎖,而實現寫鎖的同步語義是通過重寫AQS中的tryAcquire方法實現的。源碼爲:

protected final boolean tryAcquire(int acquires) {
    /*
     * Walkthrough:
     * 1. If read count nonzero or write count nonzero
     *    and owner is a different thread, fail.
     * 2. If count would saturate, fail. (This can only
     *    happen if count is already nonzero.)
     * 3. Otherwise, this thread is eligible for lock if
     *    it is either a reentrant acquire or
     *    queue policy allows it. If so, update state
     *    and set owner.
     */
    Thread current = Thread.currentThread();
	// 1. 獲取寫鎖當前的同步狀態
    int c = getState();
	// 2. 獲取寫鎖獲取的次數
    int w = exclusiveCount(c);
    if (c != 0) {
        // (Note: if c != 0 and w == 0 then shared count != 0)
		// 3.1 當讀鎖已被讀線程獲取或者當前線程不是已經獲取寫鎖的線程的話
		// 當前線程獲取寫鎖失敗
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
		// 3.2 當前線程獲取寫鎖,支持可重複加鎖
        setState(c + acquires);
        return true;
    }
	// 3.3 寫鎖未被任何線程獲取,當前線程可獲取寫鎖
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

這段代碼的邏輯請看註釋,這裏有一個地方需要重點關注,exclusiveCount( c )方法,該方法源碼爲:

static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

其中EXCLUSIVE_MASK爲: static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; EXCLUSIVE _MASK爲1左移16位然後減1,即爲0x0000FFFF。而exclusiveCount方法是將同步狀態(state爲int類型)與0x0000FFFF相與,即取同步狀態的低16位。那麼低16位代表什麼呢?根據exclusiveCount方法的註釋爲獨佔式獲取的次數即寫鎖被獲取的次數,現在就可以得出來一個結論同步狀態的低16位用來表示寫鎖的獲取次數。同時還有一個方法值得我們注意:

static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }

該方法是獲取讀鎖被獲取的次數,是將同步狀態(int c)右移16次,即取同步狀態的高16位,現在我們可以得出另外一個結論同步狀態的高16位用來表示讀鎖被獲取的次數。現在還記得我們開篇說的需要弄懂的第一個問題嗎?讀寫鎖是怎樣實現分別記錄讀鎖和寫鎖的狀態的,現在這個問題的答案就已經被我們弄清楚了,其示意圖如下圖所示:
在這裏插入圖片描述

2.2.寫鎖的釋放

寫鎖釋放通過重寫AQS的tryRelease方法,源碼爲:

protected final boolean tryRelease(int releases) {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
	//1. 同步狀態減去寫狀態
    int nextc = getState() - releases;
	//2. 當前寫狀態是否爲0,爲0則釋放寫鎖
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        setExclusiveOwnerThread(null);
	//3. 不爲0則更新同步狀態
    setState(nextc);
    return free;
}

源碼的實現邏輯請看註釋,不難理解與ReentrantLock基本一致,這裏需要注意的是,減少寫狀態 int nextc = getState() - releases;只需要用當前同步狀態直接減去寫狀態的原因正是我們剛纔所說的寫狀態是由同步狀態的低16位表示的。

3.讀鎖詳解

3.1.讀鎖的獲取

看完了寫鎖,現在來看看讀鎖,讀鎖不是獨佔式鎖,即同一時刻該鎖可以被多個讀線程獲取也就是一種共享式鎖。按照之前對AQS介紹,實現共享式同步組件的同步語義需要通過重寫AQS的tryAcquireShared方法和tryReleaseShared方法。讀鎖的獲取實現方法爲:

protected final int tryAcquireShared(int unused) {
    /*
     * Walkthrough:
     * 1. If write lock held by another thread, fail.
     * 2. Otherwise, this thread is eligible for
     *    lock wrt state, so ask if it should block
     *    because of queue policy. If not, try
     *    to grant by CASing state and updating count.
     *    Note that step does not check for reentrant
     *    acquires, which is postponed to full version
     *    to avoid having to check hold count in
     *    the more typical non-reentrant case.
     * 3. If step 2 fails either because thread
     *    apparently not eligible or CAS fails or count
     *    saturated, chain to version with full retry loop.
     */
    Thread current = Thread.currentThread();
    int c = getState();
	//1. 如果寫鎖已經被獲取並且獲取寫鎖的線程不是當前線程的話,當前
	// 線程獲取讀鎖失敗返回-1
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    int r = sharedCount(c);
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
		//2. 當前線程獲取讀鎖
        compareAndSetState(c, c + SHARED_UNIT)) {
		//3. 下面的代碼主要是新增的一些功能,比如getReadHoldCount()方法
		//返回當前獲取讀鎖的次數
        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;
    }
	//4. 處理在第二步中CAS操作失敗的自旋已經實現重入性
    return fullTryAcquireShared(current);
}

代碼的邏輯請看註釋,需要注意的是 當寫鎖被其他線程獲取後,讀鎖獲取失敗,否則獲取成功利用CAS更新同步狀態。另外,當前同步狀態需要加上SHARED_UNIT((1 << SHARED_SHIFT)即0x00010000)的原因這是我們在上面所說的同步狀態的高16位用來表示讀鎖被獲取的次數。如果CAS失敗或者已經獲取讀鎖的線程再次獲取讀鎖時,是靠fullTryAcquireShared方法實現的,這段代碼就不展開說了,有興趣可以看看。

3.2.讀鎖的釋放

讀鎖釋放的實現主要通過方法tryReleaseShared,源碼如下,主要邏輯請看註釋:

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
	// 前面還是爲了實現getReadHoldCount等新功能
    if (firstReader == current) {
        // assert firstReaderHoldCount > 0;
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    } 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;
    }
    for (;;) {
        int c = getState();
		// 讀鎖釋放 將同步狀態減去讀狀態即可
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            // Releasing the read lock has no effect on readers,
            // but it may allow waiting writers to proceed if
            // both read and write locks are now free.
            return nextc == 0;
    }
}

4.鎖降級

讀寫鎖支持鎖降級,遵循按照獲取寫鎖,獲取讀鎖再釋放寫鎖的次序,寫鎖能夠降級成爲讀鎖,不支持鎖升級,關於鎖降級下面的示例代碼摘自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();
      }
    }
}

5. CopyOnWriteArrayList的簡介

java學習者都清楚ArrayList並不是線程安全的,在讀線程在讀取ArrayList的時候如果有寫線程在寫數據的時候,基於fast-fail機制,會拋出ConcurrentModificationException異常,也就是說ArrayList並不是一個線程安全的容器,當然您可以用Vector,或者使用Collections的靜態方法將ArrayList包裝成一個線程安全的類,但是這些方式都是採用java關鍵字synchronzied對方法進行修飾,利用獨佔式鎖來保證線程安全的。但是,由於獨佔式鎖在同一時刻只有一個線程能夠獲取到對象監視器,很顯然這種方式效率並不是太高。

回到業務場景中,有很多業務往往是讀多寫少的,比如系統配置的信息,除了在初始進行系統配置的時候需要寫入數據,其他大部分時刻其他模塊之後對系統信息只需要進行讀取,又比如白名單,黑名單等配置,只需要讀取名單配置然後檢測當前用戶是否在該配置範圍以內。類似的還有很多業務場景,它們都是屬於讀多寫少的場景。如果在這種情況用到上述的方法,使用Vector,Collections轉換的這些方式是不合理的,因爲儘管多個讀線程從同一個數據容器中讀取數據,但是讀線程對數據容器的數據並不會發生發生修改。聯繫上文我們講過的讀寫鎖ReenTrantReadWriteLock,通過讀寫分離的思想,使得讀讀之間不會阻塞,無疑如果一個list能夠做到被多個讀線程讀取的話,性能會大大提升不少。但是,如果僅僅是將list通過讀寫鎖(ReentrantReadWriteLock)進行再一次封裝的話,由於讀寫鎖的特性,當寫鎖被寫線程獲取後,讀寫線程都會被阻塞。如果僅僅使用讀寫鎖對list進行封裝的話,這裏仍然存在讀線程在寫數據的時候被阻塞的情況,如果想list的讀效率更高的話,這裏就是我們的突破口,如果我們保證讀線程無論什麼時候都不被阻塞,效率豈不是會更高?

思考如果簡單的使用讀寫鎖的話,在寫鎖被獲取之後,讀寫線程被阻塞,只有當寫鎖被釋放後讀線程纔有機會獲取到鎖從而讀到最新的數據,站在讀線程的角度來看,即讀線程任何時候都是獲取到最新的數據,滿足數據實時性。既然我們說到要進行優化,必然有trade-off,我們就可以犧牲數據實時性滿足數據的最終一致性即可。而CopyOnWriteArrayList就是通過Copy-On-Write(COW),即寫時複製的思想來通過延時更新的策略來實現數據的最終一致性,並且能夠保證讀線程間不阻塞

COW通俗的理解是當我們往一個容器添加元素的時候,不直接往當前容器添加,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裏添加元素,添加完元素之後,再將原容器的引用指向新的容器。對CopyOnWrite容器進行併發的讀的時候,不需要加鎖,因爲當前容器不會添加任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,延時更新的策略是通過在寫的時候針對的是不同的數據容器來實現的,放棄數據實時性達到數據的最終一致性。

6. CopyOnWriteArrayList的實現原理

現在我們來通過看源碼的方式來理解CopyOnWriteArrayList,實際上CopyOnWriteArrayList內部維護的就是一個數組

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

並且該數組引用是被volatile修飾,注意這裏僅僅是修飾的是數組引用,其中另有玄機,稍後揭曉。關於volatile很重要的一條性質是它能夠夠保證可見性。對list來說,我們自然而然最關心的就是讀寫的時候,分別爲get和add方法的實現。

6.1 get方法實現原理

get方法的源碼爲:

public E get(int index) {
    return get(getArray(), index);
}
/**
 * Gets the array.  Non-private so as to also be accessible
 * from CopyOnWriteArraySet class.
 */
final Object[] getArray() {
    return array;
}
private E get(Object[] a, int index) {
    return (E) a[index];
}

可以看出來get方法實現非常簡單,幾乎就是一個“單線程”程序,沒有對多線程添加任何的線程安全控制,也沒有加鎖也沒有CAS操作等等,原因是,所有的讀線程只是會讀取數據容器中的數據,並不會進行修改。

6.2 add方法實現原理

再來看下如何進行添加數據的?add方法的源碼爲:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
	//1. 使用Lock,保證寫線程在同一時刻只有一個
    lock.lock();
    try {
		//2. 獲取舊數組引用
        Object[] elements = getArray();
        int len = elements.length;
		//3. 創建新的數組,並將舊數組的數據複製到新數組中
        Object[] newElements = Arrays.copyOf(elements, len + 1);
		//4. 往新數組中添加新的數據	        
		newElements[len] = e;
		//5. 將舊數組引用指向新的數組
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

add方法的邏輯也比較容易理解,請看上面的註釋。需要注意這麼幾點:

  • 採用ReentrantLock,保證同一時刻只有一個寫線程正在進行數組的複製,否則的話內存中會有多份被複制的數據;
  • 前面說過數組引用是volatile修飾的,因此將舊的數組引用指向新的數組,根據volatile的happens-before規則,寫線程對數組引用的修改對讀線程是可見的。
  • 由於在寫數據的時候,是在新的數組中插入數據的,從而保證讀寫實在兩個不同的數據容器中進行操作。

7. 總結

我們知道COW和讀寫鎖都是通過讀寫分離的思想實現的,但兩者還是有些不同,可以進行比較:

COW vs 讀寫鎖

相同點:1. 兩者都是通過讀寫分離的思想實現;2.讀線程間是互不阻塞的

不同點:對讀線程而言,爲了實現數據實時性,在寫鎖被獲取後,讀線程會等待或者當讀鎖被獲取後,寫線程會等待,從而解決“髒讀”等問題。也就是說如果使用讀寫鎖依然會出現讀線程阻塞等待的情況。而COW則完全放開了犧牲數據實時性而保證數據最終一致性,即讀線程對數據的更新是延時感知的,因此讀線程不會存在等待的情況。

前面我們對比讀寫鎖 說明了CopyOnWrite的優點,當然他也有自己的缺點:即內存佔用問題和數據一致性問題。所以在開發的時候需要注意一下。

  • 內存佔用問題:因爲CopyOnWrite的寫時複製機制,所以在進行寫操作的時候,內存裏會同時駐紮兩個對 象的內存,舊的對象和新寫入的對象(注意:在複製的時候只是複製容器裏的引用,只是在寫的時候會創建新對 象添加到新容器裏,而舊容器的對象還在使用,所以有兩份對象內存)。如果這些對象佔用的內存比較大,比 如說200M左右,那麼再寫入100M數據進去,內存就會佔用300M,那麼這個時候很有可能造成頻繁的minor GC和major GC。
  • 數據一致性問題:CopyOnWrite容器只能保證數據的最終一致性,不能保證數據的實時一致性。所以如果你希望寫入的的數據,馬上能讀到,請不要使用CopyOnWrite容器。
發佈了65 篇原創文章 · 獲贊 20 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章