為什麼有了併發安全的集合還需要讀寫鎖?

三友的java日記發表於2022-05-27

大家好,我是三友,這篇文章想來跟大家來探討一下,在Java中已經提供了併發安全的集合,為什麼有的場景還需要使用讀寫鎖,直接用併發安全的集合難道不行麼?

在java中,併發安全的集合有很多,這裡我就選用常見的CopyOnWriteArrayList為例,來說明一下讀寫鎖的價值到底提現在哪。

CopyOnWriteArrayList核心原始碼分析

接下來我們分析一下CopyOnWriteArrayList核心的增刪改查的方法

成員變數

//獨佔鎖
final transient ReentrantLock lock = new ReentrantLock();
//底層用來存放元素的陣列
private transient volatile Object[] array;

add方法:往集合中新增某個元素

public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

add操作先通過lock加鎖,保證同一時刻最多隻有一個執行緒可以操作。加鎖成功獲取到成員變數的資料,然後拷貝成員變數陣列的元素到新的陣列,再基於新的資料來新增元素,最後將新拷貝的陣列通過setArray來替換舊的成員變數的陣列。

remove方法:移除集合中的某個元素

public E remove(int index) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

remove操作也要先獲取到鎖。它先是取出對應陣列下標的舊元素,然後新建了一個原陣列長度減1的新陣列,將除了被移除的元素之外,剩餘的元素拷貝到新的陣列,最後再通過setArray替換舊的成員變數的陣列。

set方法:將集合中指定位置的元素替換成新的元素

public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

set方法跟add,remove操作一樣得先獲取到鎖才能繼續執行。將原陣列的原有元素拷貝到新的陣列上,在新的陣列完成資料的替換,最後也是通過setArray替換舊的成員變數的陣列。


size方法:獲取集合中元素的個數

public int size() {
        return getArray().length;
}

size方法操作很簡單,就是簡單地返回一下當前陣列的長度。

迭代器的構造

public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
}

構造COWIterator的時候傳入當前陣列的物件,然後基於當前陣列來遍歷,也不需要加鎖。

講完CopyOnWriteArrayList原始碼,我們可以看出CopyOnWriteArrayList的核心原理就是在對陣列進行增刪改的時候全部都是先加獨佔鎖,然後對原有的陣列進行拷貝,然後基於新複製的陣列進行操作,最後將這個新的陣列替換成員變數的陣列;而對於讀的操作來說,都是不加鎖的,是基於當前成員變數的陣列的這一時刻的快照來讀的。其實CopyOnWriteArrayList是基於一種寫時複製的思想,寫的時候基於新拷貝的陣列來操作,之後再賦值給成員變數,讀的時候是原有的陣列,這樣讀寫其實就是不是同一個陣列,這樣就避免了讀寫衝突的情況,這其實也體現了一種讀寫分離的思想,讀寫操作的是不同的陣列。

CopyOnWriteArrayList適用場景

接下來我們來思考一下,CopyOnWriteArrayList適合使用在什麼樣的場景中。通過上面原始碼的分析,我們可以看出,所有的寫操作,包括增刪改都需要加同一把獨佔鎖,所以同時只允許一個執行緒對陣列進行拷貝賦值的操作,多執行緒併發情況下所有的操作都是序列執行的,勢必會導致併發能力降低,同時每次操作都涉及到了陣列的拷貝,效能也不太好;而所有的讀操作都不需要加鎖,所以同一時間可以允許大量的執行緒同時讀,併發效能高。所以綜上我們可以得出一個結論,那就是CopyOnWriteArrayList適合讀多寫少的場景。

CopyOnWriteArrayList的侷限性

說完CopyOnWriteArrayList,我們來想一想它有沒有什麼缺點。看起來CopyOnWriteArrayList除了寫的併發效能差點,好像沒有什麼缺點了。的確,單從效能來看,確實是這種情況,但是,從資料一致性的角度來看,CopyOnWriteArrayList的資料一致效能力較弱,屬於資料弱一致性。所謂的弱一致性,你可以這麼理解,在某一個時刻,讀到的資料並不是當前這一時刻最新的資料。

就拿CopyOnWriteArrayList舉例來說,當有個執行緒A正在呼叫add方法來新增元素,此時已經完成了陣列的拷貝,並且也將元素新增到陣列中,但是還沒有將新的陣列賦值給成員變數,此時,另一個執行緒B來呼叫CopyOnWriteArrayList的size方法,來讀取集合中元素的個數,那麼此時讀到的元素個數其實是不包括執行緒A要新增的元素,因為執行緒A並沒有將新的陣列賦值給成員變數,這就導致了執行緒B讀到的資料不是最新的資料,也就是跟實際的資料不一致。

所以,從上面我們可以看出,CopyOnWriteArrayList對於資料一致性的保證,還是比較弱的。其實不光是CopyOnWriteArrayList,其實Java中的很多集合,佇列的實現對於資料一致性的保證都比較弱。

如何來保證資料的強一致性

那麼有什麼好的辦法可以保證資料的強一致性麼?當然,保證併發安全,加鎖就可以完成,但是加什麼鎖可以保證資料讀寫安全和資料一致性,其實最簡單粗暴的方法就是對所有的讀寫都加上同一把獨佔鎖,這樣保證所有的讀寫操作都是序列執行,那麼讀的時候,其他執行緒一定不能寫,那麼讀的一定是最新的資料。

如果真的這麼去加獨佔鎖,的確能夠保證讀寫安全,但是效能卻會很差,這也是為什麼CopyOnWriteArrayList的讀不加鎖的原因,其實CopyOnWriteArrayList在設計的時候,就是降低資料一致性來換取讀的效能。

那有沒有什麼折中的方法,既能保證讀的效能不差,又能保證資料強一致性呢。這時就可以用讀寫鎖來實現。所謂的讀寫鎖,就是寫的時候,其他執行緒不能寫也不能讀,讀的時候,其他執行緒能讀,但是不能寫。也就是寫寫、讀寫互斥,但是讀讀不互斥。基於這種方式,就能保證讀的時候,一定沒有人在寫,這樣讀到的資料就一定是最新的,同時也能保證其他執行緒也能讀,不會出現上面舉例的那種情況了,也就能保證資料的強一致性。讀寫鎖相比獨佔鎖而言,大大提高了讀的併發能力,但是寫的時候不能讀,相比於CopyOnWriteArrayList而言,讀的併發能力有所降低,這可能就是魚(併發效能)和熊掌(資料一致性)不可兼得吧。

Java中也提供了讀寫鎖的實現,ReentrantReadWriteLock,底層是基於AQS來實現的。有興趣的小夥伴可以翻一下原始碼,看看是如何實現的,這裡就不再剖析原始碼了。

總結

好了,通過這篇文章,想必大家知道為什麼有併發安全的集合之後,還需要讀寫鎖的原因,因為很多併發安全的集合對於資料一致性的保證是比較弱的,一旦遇到對於資料一致性要求比較高的場景,一些併發安全的集合就不適用了;同時為了避免獨佔鎖帶來的效能問題,可以選擇讀寫鎖來保證讀的併發能力。小夥伴們在實際應用中需要根據應用場景來靈活地選擇使用併發安全的集合、讀寫鎖或者是獨佔鎖,其實永遠沒有最好的選擇,只有更好的選擇。

以上就是本篇文章的全部內容,如果你有什麼不懂或者想要交流的地方,歡迎關注我的個人的微信公眾號 三友的java日記 ,我們下篇文章再見。

如果覺得這篇文章對你有所幫助,還請幫忙點贊、在看、轉發一下,碼字不易,非常感謝!

 

最近花了一個月的時間,整理了這套併發程式設計系列的知識點。涵蓋了 volitile、synchronized、CAS、AQS、鎖優化策略、同步元件、資料結構、執行緒池、Thread、ThreadLocal,幾乎覆蓋了所有的學習和麵試場景,如圖。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

文件獲取方式:掃描二維碼或者搜一搜關注微信公眾號 三友的java日記 ,回覆 併發  就能獲取了。

 

相關文章