public synchronized int size() { return elementCount; } public synchronized E get(int index) { if (index >= elementCount) throw new ArrayIndexOutOfBoundsException(index); return elementData(index); }
可以看出,Vector 內部是使用 synchronized 來保證線程安全的,並且鎖的粒度比較大,都是方法級別的鎖,在並發量高的時候,很容易發生競爭,並發效率相對比較低。在這一點上,Vector 和 Hashtable 很類似。
並且,前面這幾種 List 在迭代期間不允許編輯,如果在迭代期間進行添加或刪除元素等操作,則會拋出 ConcurrentModificationException 異常,這樣的特點也在很多情況下給使用者帶來了麻煩。
所以從 JDK1.5 開始,Java 並發包里提供了使用 CopyOnWrite 機制實現的並發容器 CopyOnWriteArrayList 作為主要的並發 List,CopyOnWrite 的並發集合還包括 CopyOnWriteArraySet,其底層正是利用 CopyOnWriteArrayList 實現的。
適用場景
讀操作可以盡可能的快,而寫即使慢一些也沒關系
在很多應用場景中,讀操作可能會遠遠多於寫操作。比如,有些系統級別的信息,往往只需要加載或者修改很少的次數,但是會被系統內所有模塊頻繁的訪問。對於這種場景,我們最希望看到的就是讀操作可以盡可能的快,而寫即使慢一些也沒關系。
讀多寫少
黑名單是最典型的場景,假如我們有一個搜索網站,用戶在這個網站的搜索框中,輸入關鍵字搜索內容,但是某些關鍵字不允許被搜索。這些不能被搜索的關鍵字會被放在一個黑名單中,黑名單並不需要實時更新,可能每天晚上更新一次就可以了。當用戶搜索時,會檢查當前關鍵字在不在黑名單中,如果在,則提示不能搜索。這種讀多寫少的場景也很適合使用 CopyOnWrite 集合。
讀寫規則
讀寫鎖的規則
讀寫鎖的思想是:讀讀共享、其他都互斥(寫寫互斥、讀寫互斥、寫讀互斥),原因是由於讀操作不會修改原有的數據,因此並發讀並不會有安全問題;而寫操作是危險的,所以當寫操作發生時,不允許有讀操作加入,也不允許第二個寫線程加入。
對讀寫鎖規則的升級
CopyOnWriteArrayList 的思想比讀寫鎖的思想又更進一步。為了將讀取的性能發揮到極致,CopyOnWriteArrayList 讀取是完全不用加鎖的,更厲害的是,寫入也不會阻塞讀取操作,也就是說你可以在寫入的同時進行讀取,只有寫入和寫入之間需要進行同步,也就是不允許多個寫入同時發生,但是在寫入發生時允許讀取同時發生。這樣一來,讀操作的性能就會大幅度提升。
特點
CopyOnWrite的含義
從 CopyOnWriteArrayList 的名字就能看出它是滿足 CopyOnWrite 的 ArrayList,CopyOnWrite 的意思是說,當容器需要被修改的時候,不直接修改當前容器,而是先將當前容器進行 Copy,復制出一個新的容器,然后修改新的容器,完成修改之后,再將原容器的引用指向新的容器。這樣就完成了整個修改過程。
這樣做的好處是,CopyOnWriteArrayList 利用了“不變性”原理,因為容器每次修改都是創建新副本,所以對於舊容器來說,其實是不可變的,也是線程安全的,無需進一步的同步操作。我們可以對 CopyOnWrite 容器進行並發的讀,而不需要加鎖,因為當前容器不會添加任何元素,也不會有修改。
CopyOnWriteArrayList 的所有修改操作(add,set等)都是通過創建底層數組的新副本來實現的,所以 CopyOnWrite 容器也是一種讀寫分離的思想體現,讀和寫使用不同的容器。
迭代期間允許修改集合內容
我們知道 ArrayList 在迭代期間如果修改集合的內容,會拋出 ConcurrentModificationException 異常。讓我們來分析一下 ArrayList 會拋出異常的原因。
在 ArrayList 源碼里的 ListItr 的 next 方法中有一個 checkForComodification 方法,代碼如下:
final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }
這里會首先檢查 modCount 是否等於 expectedModCount。modCount 是保存修改次數,每次我們調用 add、remove 或 trimToSize 等方法時它會增加,expectedModCount 是迭代器的變量,當我們創建迭代器時會初始化並記錄當時的 modCount。后面迭代期間如果發現 modCount 和 expectedModCount 不一致,就說明有人修改了集合的內容,就會拋出異常。
和 ArrayList 不同的是,CopyOnWriteArrayList 的迭代器在迭代的時候,如果數組內容被修改了,CopyOnWriteArrayList 不會報 ConcurrentModificationException 的異常,因為迭代器使用的依然是舊數組,只不過迭代的內容可能已經過時了。演示代碼如下:
/** * 描述: 演示CopyOnWriteArrayList迭代期間可以修改集合的內容 */ public class CopyOnWriteArrayListDemo { public static void main(String[] args) { CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3}); System.out.println(list); //[1, 2, 3] //Get iterator 1 Iterator<Integer> itr1 = list.iterator(); //Add one element and verify list is updated list.add(4); System.out.println(list); //[1, 2, 3, 4] //Get iterator 2 Iterator<Integer> itr2 = list.iterator(); System.out.println("====Verify Iterator 1 content===="); itr1.forEachRemaining(System.out::println); //1,2,3 System.out.println("====Verify Iterator 2 content===="); itr2.forEachRemaining(System.out::println); //1,2,3,4 } }
這段代碼會首先創建一個 CopyOnWriteArrayList,並且初始值被賦為 [1, 2, 3],此時打印出來的結果很明顯就是 [1, 2, 3]。然后我們創建一個叫作 itr1 的迭代器,創建之后再添加一個新的元素,利用 list.add() 方法把元素 4 添加進去,此時我們打印出 List 自然是 [1, 2, 3, 4]。我們再創建一個叫作 itr2 的迭代器,在下方把兩個迭代器迭代產生的內容打印出來,這段代碼的運行結果是:
[1, 2, 3] [1, 2, 3, 4] ====Verify Iterator 1 content==== 1 2 3 ====Verify Iterator 2 content==== 1 2 3 4
可以看出,這兩個迭代器打印出來的內容是不一樣的。第一個迭代器打印出來的是 [1, 2, 3],而第二個打印出來的是 [1, 2, 3, 4]。雖然它們的打印時機都發生在第四個元素被添加之后,但它們的創建時機是不同的。由於迭代器 1 被創建時的 List 里面只有三個元素,后續無論 List 有什么修改,對它來說都是無感知的。
以上這個結果說明了,CopyOnWriteArrayList 的迭代器一旦被建立之后,如果往之前的 CopyOnWriteArrayList 對象中去新增元素,在迭代器中既不會顯示出元素的變更情況,同時也不會報錯,這一點和 ArrayList 是有很大區別的。
缺點
這些缺點不僅是針對 CopyOnWriteArrayList,其實同樣也適用於其他的 CopyOnWrite 容器:
內存占用問題
因為 CopyOnWrite 的寫時復制機制,所以在進行寫操作的時候,內存里會同時駐扎兩個對象的內存,這一點會占用額外的內存空間。
在元素較多或者復雜的情況下,復制的開銷很大
復制過程不僅會占用雙倍內存,還需要消耗 CPU 等資源,會降低整體性能。
數據一致性問題
由於 CopyOnWrite 容器的修改是先修改副本,所以這次修改對於其他線程來說,並不是實時能看到的,只有在修改完之后才能體現出來。如果你希望寫入的的數據馬上能被其他線程看到,CopyOnWrite 容器是不適用的。
源碼分析
數據結構
/** 可重入鎖對象 */ final transient ReentrantLock lock = new ReentrantLock(); /** CopyOnWriteArrayList底層由數組實現,volatile修飾,保證數組的可見性 */ private transient volatile Object[] array; /** 得到數組 */ final Object[] getArray() { return array; } /** 設置數組 */ final void setArray(Object[] a) { array = a; } /** 初始化CopyOnWriteArrayList相當於初始化數組 */ public CopyOnWriteArrayList() { setArray(new Object[0]); }
在這個類中首先會有一個 ReentrantLock 鎖,用來保證修改操作的線程安全。下面被命名為 array 的 Object[] 數組是被 volatile 修飾的,可以保證數組的可見性,這正是存儲元素的數組,同樣,我們可以從 getArray()、setArray 以及它的構造方法看出,CopyOnWriteArrayList 的底層正是利用數組實現的,這也符合它的名字。
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; // 將volatile Object[] array 的指向替換成新數組 setArray(newElements); return true; } finally { lock.unlock(); } }
add 方法的作用是往 CopyOnWriteArrayList 中添加元素,是一種修改操作。首先需要利用 ReentrantLock 的 lock 方法進行加鎖,獲取鎖之后,得到原數組的長度和元素,也就是利用 getArray 方法得到 elements 並且保存 length。之后利用 Arrays.copyOf 方法復制出一個新的數組,得到一個和原數組內容相同的新數組,並且把新元素添加到新數組中。完成添加動作后,需要轉換引用所指向的對象,利用 setArray(newElements) 操作就可以把 volatile Object[] array 的指向替換成新數組,最后在 finally 中把鎖解除。
總結流程
在添加的時候首先上鎖,並復制一個新數組,增加操作在新數組上完成,然后將 array 指向到新數組,最后解鎖。
上面的步驟實現了 CopyOnWrite 的思想:寫操作是在原來容器的拷貝上進行的,並且在讀取數據的時候不會鎖住 list。而且可以看到,如果對容器拷貝操作的過程中有新的讀線程進來,那么讀到的還是舊的數據,因為在那個時候對象的引用還沒有被更改。
下面我們來分析一下讀操作的代碼,也就是和 get 相關的三個方法,分別是 get 方法的兩個重載和 getArray 方法,代碼如下:
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 相關的操作沒有加鎖,保證了讀取操作的高速。
迭代器 COWIterator 類
這個迭代器有兩個重要的屬性,分別是 Object[] snapshot 和 int cursor。其中 snapshot 代表數組的快照,也就是創建迭代器那個時刻的數組情況,而 cursor 則是迭代器的游標。迭代器的構造方法如下:
private COWIterator(Object[] elements, int initialCursor) { cursor = initialCursor; snapshot = elements; }
可以看出,迭代器在被構建的時候,會把當時的 elements 賦值給 snapshot,而之后的迭代器所有的操作都基於 snapshot 數組進行的,比如:
public E next() { if (! hasNext()) throw new NoSuchElementException(); return (E) snapshot[cursor++]; }
在 next 方法中可以看到,返回的內容是 snapshot 對象,所以,后續就算原數組被修改,這個 snapshot 既不會感知到,也不會受影響,執行迭代操作不需要加鎖,也不會因此拋出異常。迭代器返回的結果,和創建迭代器的時候的內容一致。
