CopyOnWriteArrayList源碼解析

Java併發包提供了很多線程安全的集合,有了他們的存在,使得我們在多線程開發下,可以和單線程一樣去編寫代碼,大大簡化了多線程開發的難度,但是如果不知道其中的原理,可能會引發意想不到的問題,所以知道其中的原理還是很有必要的。

今天我們來看下Java併發包中提供的線程安全的List,即CopyOnWriteArrayList。

剛接觸CopyOnWriteArrayList的時候,我總感覺這個集合的名稱有點奇怪:在寫的時候複製?後來才知道它就是在寫的時候進行了複製,所以這個命名還是相當嚴謹的。當然,翻譯成 寫時複製 會更好一些。

我們在研究源碼的時候,可以帶着問題去研究,這樣可能效果會更好,把問題一個一個攻破,也更有成就感,所以在這裏,我先拋出幾個問題:

  1. CopyOnWriteArrayList如何保證線程安全性的。
  2. CopyOnWriteArrayList長度有沒有限制。
  3. 爲什麼說CopyOnWriteArrayList是一個寫時複製集合。

我們先來看下CopyOnWriteArrayList的UML圖:


主要方法源碼解析

add

我們可以通過add方法添加一個元素

    public boolean add(E e) {
        //1.獲得獨佔鎖
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();//2.獲得Object[]
            int len = elements.length;//3.獲得elements的長度
            Object[] newElements = Arrays.copyOf(elements, len + 1);//4.複製到新的數組
            newElements[len] = e;//5.將add的元素添加到新元素
            setArray(newElements);//6.替換之前的數據
            return true;
        } finally {
            lock.unlock();//7.釋放獨佔鎖
        }
    }

    final Object[] getArray() {
        return array;
    }

當調用add方法,代碼會跑到(1)去獲得獨佔鎖,因爲獨佔鎖的特性,導致如果有多個線程同時跑到(1),只能有一個線程成功獲得獨佔鎖,並且執行下面的代碼,其餘的線程只能在外面等着,直到獨佔鎖被釋放。

線程獲得到獨佔鎖後,執行(2),獲得array,並且賦值給elements ,(3)獲得elements的長度,並且賦值給len,(4)複製elements數組,在此基礎上長度+1,賦值給newElements,(5)將我們需要新增的元素添加到newElements,(6)替換之前的數組,最後跑到(7)釋放獨佔鎖。

解析源碼後,我們明白了

  1. CopyOnWriteArrayList是如何保證【寫】時線程安全的?因爲用了ReentrantLock獨佔鎖,保證同時只有一個線程對集合進行修改操作。
  2. 數據是存儲在CopyOnWriteArrayList中的array數組中的。
  3. 在添加元素的時候,並不是直接往array裏面add元素,而是複製出來了一個新的數組,並且複製出來的數組的長度是 【舊數組的長度+1】,再把舊的數組替換成新的數組,這是尤其需要注意的。

get

    public E get(int index) {
        return get(getArray(), index);
    }
    final Object[] getArray() {
        return array;
    }

我們可以通過調用get方法,來獲得指定下標的元素。

首先獲得array,然後獲得指定下標的元素,看起來沒有任何問題,但是其實這是存在問題的。別忘了,我們現在是多線程的開發環境,不然也沒有必要去使用JUC下面的東西了。

試想這樣的場景,當我們獲得了array後,把array捧在手心裏,如獲珍寶。。。由於整個get方法沒有獨佔鎖,所以另外一個線程還可以繼續執行修改的操作,比如執行了remove的操作,remove和add一樣,也會申請獨佔鎖,並且複製出新的數組,刪除元素後,替換掉舊的數組。而這一切get方法是不知道的,它不知道array數組已經發生了天翻地覆的變化,它還是傻乎乎的,看着捧在手心裏的array。。。這就是弱一致性

就像微信一樣,雖然對方已經把你給刪了,但是你不知道,你還是每天打開和她的聊天框,準備說些什麼。。。

set

我們可以通過set方法修改指定下標元素的值。

    public E set(int index, E element) {
        //(1)獲得獨佔鎖
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();//(2)獲得array
            E oldValue = get(elements, index);//(3)根據下標,獲得舊的元素

            if (oldValue != element) {//(4)如果舊的元素不等於新的元素
                int len = elements.length;//(5)獲得舊數組的長度
                Object[] newElements = Arrays.copyOf(elements, len);//(6)複製出新的數組
                newElements[index] = element;//(7)修改
                setArray(newElements);//(8)替換
            } else {
                //(9)爲了保證volatile 語義,即使沒有修改,也要替換成新的數組
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();//(10)釋放獨佔鎖
        }
    }

當我們調用set方法後:

  1. 和add方法一樣,先獲取獨佔鎖,同樣的,只有一個線程可以獲得獨佔鎖,其他線程會被阻塞。
  2. 獲取到獨佔鎖的線程獲得array,並且賦值給elements。
  3. 根據下標,獲得舊的元素。
  4. 進行一個對比,檢查舊的元素是否不等於新的元素,如果成立的話,執行5-8,如果不成立的話,執行9。
  5. 獲得舊數組的長度。
  6. 複製出新的數組。
  7. 修改新的數組中指定下標的元素。
  8. 把舊的數組替換掉。
  9. 爲了保證volatile語義,即使沒有修改,也要替換成新的數組。
  10. 不管是否執行了修改的操作,都會釋放獨佔鎖。

通過源碼解析,我們應該更有體會:

  1. 通過獨佔鎖,來保證【寫】的線程安全。
  2. 修改操作,實際上操作的是array的一個副本,最後才把array給替換掉。

remove

我們可以通過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方法和add,set方法是一樣的,第一步還是先獲取獨佔鎖,來保證線程安全性,如果要刪除的元素是最後一個,則複製出一個長度爲【舊數組的長度-1】的新數組,隨之替換,這樣就巧妙的把最後一個元素給刪除了,如果要刪除的元素不是最後一個,則分兩次複製,隨之替換。

迭代器

在解析源碼前,我們先看下迭代器的基本使用:

public class Main {public static void main(String[] args) {
        CopyOnWriteArrayList<String> copyOnWriteArrayList=new CopyOnWriteArrayList<>();
        copyOnWriteArrayList.add("Hello");
        copyOnWriteArrayList.add("copyOnWriteArrayList");
        Iterator<String>iterator=copyOnWriteArrayList.iterator();
        while (iterator.hasNext()){
            System.out.println(iterator.next());
        }
    }
}

運行結果:


代碼很簡單,這裏就不再解釋了,我們直接來看迭代器的源碼:

    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }
        static final class COWIterator<E> implements ListIterator<E> {
    
        private final Object[] snapshot;
     
        private int cursor;

        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }
        
        // 判斷是否還有下一個元素
        public boolean hasNext() {
            return cursor < snapshot.length;
        }
        
        //獲取下個元素
        @SuppressWarnings("unchecked")
        public E next() {
            if (! hasNext())
                throw new NoSuchElementException();
            return (E) snapshot[cursor++];
        }

當我們調用iterator方法獲取迭代器,內部會調用COWIterator的構造方法,此構造方法有兩個參數,第一個參數就是array數組,第二個參數是下標,就是0。隨後構造方法中會把array數組賦值給snapshot變量。
snapshot是“快照”的意思,如果Java基礎尚可的話,應該知道數組是引用類型,傳遞的是指針,如果有其他地方修改了數組,這裏應該馬上就可以反應出來,那爲什麼又會是snapshot這樣的命名呢?沒錯,如果其他線程沒有對CopyOnWriteArrayList進行增刪改的操作,那麼snapshot就是本身的array,但是如果其他線程對CopyOnWriteArrayList進行了增刪改的操作,舊的數組會被新的數組給替換掉,但是snapshot還是原來舊的數組的引用。也就是說 當我們使用迭代器便利CopyOnWriteArrayList的時候,不能保證拿到的數據是最新的,這也是一致性問題。

什麼?你不信?那我們通過一個demo來證實下:

  public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArrayList<String> copyOnWriteArrayList=new CopyOnWriteArrayList<>();
        copyOnWriteArrayList.add("Hello");
        copyOnWriteArrayList.add("CopyOnWriteArrayList");
        copyOnWriteArrayList.add("2019");
        copyOnWriteArrayList.add("good good study");
        copyOnWriteArrayList.add("day day up");
        new Thread(()->{
            copyOnWriteArrayList.remove(1);
            copyOnWriteArrayList.remove(3);
        }).start();
        TimeUnit.SECONDS.sleep(3);
        Iterator<String> iterator = copyOnWriteArrayList.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }

運行結果:



這沒問題把,我們先是往list裏面add了點數據,然後開一個線程,在線程裏面刪除一些元素,睡3秒是爲了保證線程運行完畢。然後獲取迭代器,遍歷元素,發現被remove的元素沒有被打印出來。

然後我們換一種寫法:

   public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArrayList<String> copyOnWriteArrayList=new CopyOnWriteArrayList<>();
        copyOnWriteArrayList.add("Hello");
        copyOnWriteArrayList.add("CopyOnWriteArrayList");
        copyOnWriteArrayList.add("2019");
        copyOnWriteArrayList.add("good good study");
        copyOnWriteArrayList.add("day day up");
        Iterator<String> iterator = copyOnWriteArrayList.iterator();
        new Thread(()->{
            copyOnWriteArrayList.remove(1);
            copyOnWriteArrayList.remove(3);
        }).start();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }

這次我們改變了代碼的順序,先是獲取迭代器,然後是執行刪除線程的操作,最後遍歷迭代器。
運行結果:



可以看到被刪除的元素,還是打印出來了。

如果我們沒有分析源碼,不知道其中的原理,不知道弱一致性,當在多線程中用到CopyOnWriteArrayList的時候,可能會痛不欲生,想砸電腦,不知道爲什麼獲取的數據有時候就不是正確的數據,而有時候又是。所以探究原理,還是挺有必要的,不管是通過源碼分析,還是通過看博客,甚至是直接看JDK中的註釋,都是可以的。

在Java併發包提供的集合中,CopyOnWriteArrayList應該是最簡單的一個,希望通過源碼分析,讓大家有一個信心,原來JDK源碼也是可以讀懂的。

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