CopyOnWriteArrayList原始碼解析

CoderBear發表於2019-03-17

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());
        }
    }
}
複製程式碼

執行結果:

image.png

程式碼很簡單,這裡就不再解釋了,我們直接來看迭代器的原始碼:

    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());
        }
    }
複製程式碼

執行結果:

image.png
這沒問題把,我們先是往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());
        }
    }
複製程式碼

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

image.png
可以看到被刪除的元素,還是列印出來了。

如果我們沒有分析原始碼,不知道其中的原理,不知道弱一致性,當在多執行緒中用到CopyOnWriteArrayList的時候,可能會痛不欲生,想砸電腦,不知道為什麼獲取的資料有時候就不是正確的資料,而有時候又是。所以探究原理,還是挺有必要的,不管是通過原始碼分析,還是通過看部落格,甚至是直接看JDK中的註釋,都是可以的。

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

相關文章