剖析 CopyOnWriteArrayList

壹言發表於2021-02-01

原文連結:https://www.changxuan.top/?p=1252


CopyOnWriteArrayList 是 JUC 中唯一一個支援併發的 List。

CopyOnWriteArrayList 的修改操作都是在底層的一個複製的陣列上進行,即寫時複製策略,從而實現了執行緒安全。其實原理和資料庫的讀寫分離十分相似。

基本構成

底層使用陣列 private transient volatile Object[] array; 來儲存元素,使用 ReentrantLock 獨佔鎖保證相關操作的安全性。

建構函式

public CopyOnWriteArrayList() {
        setArray(new Object[0]);
}
// 將集合 c 內的元素複製到 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);
}
// 建立一個內部元素是 toCopyIn 副本的 list
public CopyOnWriteArrayList(E[] toCopyIn) {
        setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}

新增元素

CopyOnWriteArrayList 中與新增元素相關的方法有以下幾種:

  • add(E e)
  • add(int index, E element)
  • addAll(Collection<? extends E> c)
  • addAll(int index, Collection<? extends E> c)
  • addIfAbsent(E e)
  • addAllAbsent(Collection<? extends E> c)

鑑於原理基本相似,下面只分析 add(E e)addIfAbsent(E e) 方法做為例子。

add(E e)

原始碼

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 方法的執行緒首先會去嘗試獲取獨佔鎖,成功獲取的執行緒會繼續執行後續新增元素邏輯,而未獲取獨佔鎖的執行緒在沒有異常的情況下則會阻塞掛起。等待獨佔鎖被釋放後,再次嘗試獲取。(ps. 在 CopyOnWriteArrayList 中使用的是 ReentrantLock 的非公平鎖模式)

這樣就能保證,同一時間只有一個執行緒進行新增元素。

addIfAbsent(E e)

原始碼

public boolean addIfAbsent(E e) {
    // 獲取當前陣列
    Object[] snapshot = getArray();
    // 呼叫 indexOf 判斷元素是否已存在 (遍歷)
    return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
        addIfAbsent(e, snapshot);
}

private boolean addIfAbsent(E e, Object[] snapshot) {
    // 獲取獨佔鎖
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 再次獲取當前陣列
        Object[] current = getArray();
        int len = current.length;
        // 此處判斷是檢查在當前執行緒判斷元素不存在和獲取獨佔鎖之間的這段時間內是否有其它執行緒對陣列進行了更改操作
        if (snapshot != current) {
            // Optimize for lost race to another addXXX operation
            int common = Math.min(snapshot.length, len);
            for (int i = 0; i < common; i++)
                if (current[i] != snapshot[i] && eq(e, current[i]))
                    return false;
            if (indexOf(e, current, common, len) >= 0)
                    return false;
        }
        // 與 add 方法的邏輯相同
        Object[] newElements = Arrays.copyOf(current, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

如果當前陣列中不存在元素 eaddIfAbsent 則會將 e 新增至陣列中返回 true;如果當前陣列中存在待新增元素,則會返回 false

addIfAbsent 方法中為了提高效能,設計者把判斷“當前陣列是否存在待新增元素”和“新增元素”的操作分開了。由於前一個操作不必要獲取獨佔鎖,在遇到每次待新增的元素都已經存在於陣列的情況時可以高效的返回 false

因為上面提到的兩步操作是非原子性的,所以再第二步操作中還需要再次進行確認之前用來判斷不存在元素 e 的陣列是否被“掉包”了。如果被“掉包”,那麼也不要“嫌棄”。就需要再判斷一下“掉包”後的陣列還能不能接著用。如果不能用直接返回 false,如果發現能用就繼續向下執行,成功後返回 true

這種設計思路,在自己的業務系統中還是比較值的借鑑的。當然上述場景下“壞”的設計,就是會先嚐試獲取獨佔鎖,在獲取獨佔鎖後再進行“判斷元素是否存在和決定是否新增元素的操作”。這樣則會導致大大增加執行緒阻塞掛起機率。相信大多數同學還是能寫出漂亮的程式碼的,不至於犯這種小錯誤。

獲取元素

獲取元素一共涉及到三個方法,原始碼如下:

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];
}

我們看到獲取元素的操作,全程沒有加鎖。並且獲取元素是由兩步操作組合而成的,一獲取當前陣列,二從當前資料中取出所指定的下標位置的元素。一旦在這兩步操作之間,有其它執行緒更改了 index 下標位置的元素。此時,獲取元素的執行緒所使用的陣列則是被廢棄掉的,它接收到的值也不是最新的值。這是寫時複製策略產生的弱一致性問題

修改元素

可以使用 set(int index, E element) 方法修改指定位置的元素。

原始碼

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

可以看到,在程式碼中並沒有顯示的判斷 index 是否合法,如果不合法則會丟擲 IndexOutOfBoundsException 異常。

主要邏輯也是先嚐試獲取獨佔鎖,符合條件則進行修改。需要注意的一點是,如果指定索引處的元素值與新值相等,也會呼叫 setArray(Object[] a) 一次方法,這主要是為了保證 volatile 語義。(執行緒在寫入 volatile 變數時,不會把值快取在暫存器或者其它地方,而是會把值刷回到主記憶體,確保記憶體可見性)

刪除元素

刪除元素的方法包括:

  • E remove(int index)
  • boolean remove(Object o)
  • boolean removeAll(Collection<?> c)

我們來看下 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();
    }
}

其實程式碼邏輯很清楚,獲取鎖後根據情況複製老陣列中的未刪除資料到新陣列即可。

迭代器

不知道大家有沒有在遍歷 ArrayList 變數的過程中想沒想過刪除其中的某個元素?反正我曾經這麼寫過,然後就出現了問題 ... 後來使用了 ArrayList 的迭代器之後就沒有錯誤了。

CopyOnWriteArrayList 中也有迭代器,但是也存在著弱一致性問題
原始碼

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

static final class COWIterator<E> implements ListIterator<E> {
    /** Snapshot of the array 陣列的快照版本 */
    private final Object[] snapshot;
    /** Index of element to be returned by subsequent call to next.  */
    private int cursor;
    // 建構函式
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }
    // 是否結束
    public boolean hasNext() {
        return cursor < snapshot.length;
    }

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

   ... ...

    public int nextIndex() {
        return cursor;
    }

    public int previousIndex() {
        return cursor-1;
    }

    public void remove() {
        throw new UnsupportedOperationException();
    }

    public void set(E e) {
        throw new UnsupportedOperationException();
    }
    
    public void add(E e) {
        throw new UnsupportedOperationException();
    }

    ... ...
}

可以看到,CopyOnWriteArrayList 的迭代器並不支援 remove 操作。在呼叫 iterator() 方法時獲取了一份當前陣列的快照,如果在遍歷期間並沒有其它執行緒對資料做更改操作就不會出現一致性的問題。一旦有其它執行緒對資料更改後,將 CopyOnWriteArrayList 中的陣列更改為了新陣列,此時迭代器所持有的資料就相當於快照了,同時也出現了弱一致性問題。

擴充延申

還記得剛剛提到的 addIfAbsent 方法嗎?看到它你有沒有聯想到什麼東西呢?集合 set?

對的,通過 addIfAbsent 方法也能實現集合的功能,CopyOnWriteArraySet 的底層就是使用 CopyOnWriteArrayList 實現的。(PS. HasSet 的底層依賴 HashMap 。)

相關文章