33-CopyOnWriteArrayList 有什麼特點?
刪除線格式 故事要從誕生 CopyOnWriteArrayList 之前說起。其實在 CopyOnWriteArrayList 出現之前,我們已經有了 ArrayList 和 LinkedList 作為 List 的陣列和連結串列的實現,而且也有了執行緒安全的 Vector 和 Collections.synchronizedList() 可以使用。所以首先就讓我們來看下執行緒安全的 Vector 的 size 和 get 方法的程式碼:
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 實現的。所以今天我們以 CopyOnWriteArrayList 為突破口,來看一下 CopyOnWrite 容器的特點。
適用場景
-
讀操作可以儘可能的快,而寫即使慢一些也沒關係
在很多應用場景中,讀操作可能會遠遠多於寫操作。比如,有些系統級別的資訊,往往只需要載入或者修改很少的次數,但是會被系統內所有模組頻繁的訪問。對於這種場景,我們最希望看到的就是讀操作可以儘可能的快,而寫即使慢一些也沒關係。 -
讀多寫少
黑名單是最典型的場景,假如我們有一個搜尋網站,使用者在這個網站的搜尋框中,輸入關鍵字搜尋內容,但是某些關鍵字不允許被搜尋。這些不能被搜尋的關鍵字會被放在一個黑名單中,黑名單並不需要實時更新,可能每天晚上更新一次就可以了。當使用者搜尋時,會檢查當前關鍵字在不在黑名單中,如果在,則提示不能搜尋。這種讀多寫少的場景也很適合使用 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 既不會感知到,也不會受影響,執行迭代操作不需要加鎖,也不會因此丟擲異常。迭代器返回的結果,和建立迭代器的時候的內容一致。
以上我們對 CopyOnWriteArrayList 進行了介紹。我們分別介紹了在它誕生之前的 Vector 和 Collections.synchronizedList() 的特點,CopyOnWriteArrayList 的適用場景、讀寫規則,還介紹了它的兩個特點,分別是寫時複製和迭代期間允許修改集合內容。我們還介紹了它的三個缺點,分別是記憶體佔用問題,在元素較多或者複雜的情況下複製的開銷大問題,以及資料一致性問題。最後我們對於它的重要原始碼進行了解析。
相關文章
- Java語言有什麼特點Java
- 獨享IP有什麼特點?
- flask-wtf有什麼特點Flask
- Python集合有什麼特點Python
- python列表有什麼特點Python
- WebSocket有什麼優勢?有哪些特點?Web
- CSS浮動元素特點有什麼CSS
- 資料中心代理有什麼特點?
- 分析好用的CRM有什麼特點?
- 與HTML相比XHTML有什麼特點?HTML
- 國密瀏覽器是什麼?有哪些?有什麼特點?瀏覽器
- Linux是什麼意思?Linux有什麼特點?Linux
- 開箱即用的模型叫什麼模型?有什麼特點模型
- Altair SimSolid軟體有什麼特點AISolid
- 大資料技術有什麼特點大資料
- BI報表系統有什麼特點
- 與HTML相比XHTML有什麼特點?(轉)HTML
- 什麼是響應式網頁?有什麼特點呢?網頁
- 什麼是Go語言?Go語言有什麼特點?Go
- 網路安全中蜜罐是什麼意思?有什麼特點?
- Linux有什麼特點?體系結構有哪些?Linux
- Docker有哪些特點?與Linux有什麼區別?DockerLinux
- Python能代替shell嗎?有什麼特點?Python
- 什麼是雲解析?雲解析有哪些特點?
- python中物件導向有什麼特點Python物件
- 高安全等級網路是什麼意思?有什麼特點?
- 【知識分享】web伺服器是什麼有什麼特點Web伺服器
- Linux學習教程之什麼是Redis?Redis有什麼特點?LinuxRedis
- 什麼是Linux系統?Linux系統有什麼特點?Linux
- 企業微信scrm管理系統是什麼意思?有什麼特點?
- 雲端計算有什麼特點或優勢呢?
- 資料視覺化軟體有什麼特點視覺化
- Linux有什麼特點?入行門檻高嗎?Linux
- 什麼是Linux?Linux主要特點有哪些?Linux
- 什麼是DNS雲解析?雲解析有哪些特點?DNS
- Linux有什麼特點?為何受關注?Linux
- Linux有什麼特點呢?Linux學習Linux
- 什麼是資源子網和通訊子網有什麼特點