Java JUC CopyOnWriteArrayList 解析

神祕傑克發表於2022-01-12

CopyOnWriteArrayList 原理解析

介紹

在 Java 併發包中的併發 List 只有 CopyOnWriteArrayList,CopyOnWriteArrayList 是一個執行緒安全的 ArrayList,對其進行的修改操作都是在底層的一個複製的陣列(快照)上進行的,也就是使用了寫時複製策略。

類圖

在 CopyOnWriteArrayList 的類圖中,每個 CopyOnWriteArrayList 物件裡面有一個 array 陣列用來存放具體的元素ReentrantLock獨佔鎖來保證同時只有一個執行緒對 array 進行修改。

如果讓我們自己做一個寫時複製的執行緒安全的 list 我們會怎麼做,有哪些點需要考慮?

  • 何時初始化 list,初始化的 list 元素個數為多少,list 是有限大小嗎?
  • 如何保證執行緒安全,比如多個執行緒進行讀寫時如何保證是執行緒安全的?
  • 如何保證使用迭代器遍歷 list 時的資料一致性?

下面我們看一下 CopyOnWriteArrayList 是如何實現的。

主要方法解析

初始化

在無參建構函式中,預設建立大小為 0 的 Object 陣列作為初始值。

public CopyOnWriteArrayList() {
        setArray(new Object[0]);
}

有參建構函式:

//傳入的toCopyIn的副本
public CopyOnWriteArrayList(E[] toCopyIn) {
    setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
//入參為集合,複製到list中
public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] elements;
        if (c.getClass() == CopyOnWriteArrayList.class)
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elements.getClass() != Object[].class)
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        setArray(elements);
}

新增元素

CopyOnWriteArrayList 中用來新增元素的函式有:

  • add(E e)
  • add(int index,E e)
  • addIfAbsent(E e)
  • addAllAbsent(Collection<? extents E> c)等

這些函式原理類似,我們以 add(E e)為例來解析。

public boolean add(E e) {
        // 獲取獨佔鎖
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            // 獲取array
            Object[] elements = getArray();
            int len = elements.length;
            //複製array到新陣列並且新增新元素到新陣列
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            // 使用新陣列替換舊的陣列
            setArray(newElements);
            return true;
        } finally {
            //釋放獨佔鎖
            lock.unlock();
        }
}

在上述程式碼中,首先會獲取獨佔鎖,如果有多個執行緒同時呼叫 add 方法則只有一個執行緒能獲取到該鎖,其它執行緒會被阻塞直到鎖被釋放。

之後使用新陣列替換原陣列,並釋放鎖,需要注意的就是在新增元素時,首先複製了一個快照,然後在快照上進行新增,而不是直接在原來陣列上進行

獲取指定位置元素

使用 get(int index)方法獲取下標為 index 的元素,如果元素不存在則丟擲 IndexOutOfBoundsException 異常。

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

上述程式碼中,當某個執行緒呼叫 get 方法獲取指定位置元素時,首先獲取 array 陣列,然後通過下標獲取指定位置元素,這是兩步操作,但是在整個過程中沒有進行加鎖同步

假設 array 裡面有元素 1,2,3。

array內容

由於第一步獲取 array 和第二步根據下標訪問指定位置元素沒有枷鎖,這就可能導致執行緒 x 在執行第一步後第二步前,另外一個執行緒 y 進行了 remove 操作,假設刪除1,remove 操作首先會獲取獨佔鎖,進行寫時複製,也就是複製一份當前 array 陣列然後在複製後的陣列裡刪除執行緒 x 通過 get 方法訪問的元素1,之後讓 array 指向新的陣列。

而這時候 array 之前指向的陣列的引用計數為 1 而不是 0,因為執行緒 x 還在使用它,這時執行緒 x 開始執行第二步,操作的陣列是執行緒 y 刪除元素之前的陣列。

弱一致性

總結:雖然執行緒 y 已經刪除了 index 處的元素,但是執行緒 x 的第二步還是會返回 index 處的元素,這其實就是寫時複製策略產生的弱一致性問題

修改指定元素

使用 set(int index,E element)修改 list 中指定元素的值,如果指定元素的元素不存在則丟擲 IndexOutOfBoundsException 異常。

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

該方法也是先獲取獨佔鎖,隨後獲取當前陣列,並呼叫 get 方法後去指定位置元素,如果指定位置元素不等於新值則建立新陣列並複製元素到新的陣列中。

如果指定位置元素和新值一樣,則為了保證 volatile 語義,還是需要重新設定 array,雖然 array 內容並沒有變化。

該目的就是重新整理一下快取,通知其他執行緒,也就是所謂的操作結果可見。

刪除元素

刪除 list 中指定元素,可以使用如下方法。

  • E remove(int index)
  • boolean remove(Object o)
  • Boolean remove(Object o,Object[] snapshot,int index)等

原理大致類似,這裡講解 remove(int index)方法。

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

首先獲取獨佔鎖以保證刪除資料期間其他執行緒不能對 array 進行修改,然後獲取陣列中要被刪除的元素,並把剩餘的元素複製到新陣列,之後使用新陣列替換原來的陣列,最後在返回前釋放鎖。

迭代器

下面來看 CopyOnWriteArrayList 中迭代器的弱一致性是怎麼回事,所謂弱一致性是指返回迭代器後,其他執行緒對 list 的增刪改對迭代器是不可見的,下面看看這是如何做到的。

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
    //array的快照
    private final Object[] snapshot;
    //陣列下標
    private int cursor;

    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    //是否遍歷結束
    public boolean hasNext() {
        return cursor < snapshot.length;
    }

    //獲取元素
    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

}

當呼叫 iterator 方法獲取迭代器時實際上會返回一個COWIterator物件,COWIterator 物件的 snapshot 變數儲存了當前 list 的內容,cursor 是遍歷 list 時資料的下標。

為什麼說 snapshot 是 list 的快照呢?明明是指標傳遞的引用,而不是副本。

如果在該執行緒使用返回的迭代器遍歷元素的過程中,其他執行緒沒有對 list 進行增刪改,那麼 snapshot 本身就是 list 的 array,因為它們是引用關係。

但是如果在遍歷期間其他執行緒對該 list 進行了增刪改,那麼 snapshot 就是快照了,因為增刪改後 list 裡面的陣列被新陣列替換了,這時候老陣列被snapshot引用。這也說明獲取迭代器後,使用該迭代器元素時,其他執行緒對該 list 進行的增刪改不可見,因為它們操作的是兩個不同的陣列,這就是弱一致性

總結

CopyOnWriteArrayList 使用寫時複製的策略來保證 list 的一致性,而獲取修改寫入三步操作並不是原子性的,所以在增刪改的過程中都使用了獨佔鎖,來保證在某個時間只有一個執行緒能對 list 陣列進行修改。

另外 CopyOnWriteArrayList 提供了弱一致性的迭代器,從而保證在獲取迭代器後,其他執行緒對 list 的修改是不可見的,迭代器遍歷的陣列是一個快照。

CopyOnWrite 併發容器用於讀多寫少的併發場景,缺點:記憶體佔用問題資料一致性問題(只能保證資料的最終一致性,不能保證資料的實時一致性)。

相關文章