Java併發包提供了很多執行緒安全的集合,有了他們的存在,使得我們在多執行緒開發下,可以和單執行緒一樣去編寫程式碼,大大簡化了多執行緒開發的難度,但是如果不知道其中的原理,可能會引發意想不到的問題,所以知道其中的原理還是很有必要的。
今天我們來看下Java併發包中提供的執行緒安全的List,即CopyOnWriteArrayList。
剛接觸CopyOnWriteArrayList的時候,我總感覺這個集合的名稱有點奇怪:在寫的時候複製?後來才知道它就是在寫的時候進行了複製,所以這個命名還是相當嚴謹的。當然,翻譯成 寫時複製 會更好一些。
我們在研究原始碼的時候,可以帶著問題去研究,這樣可能效果會更好,把問題一個一個攻破,也更有成就感,所以在這裡,我先丟擲幾個問題:
- CopyOnWriteArrayList如何保證執行緒安全性的。
- CopyOnWriteArrayList長度有沒有限制。
- 為什麼說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)釋放獨佔鎖。
解析原始碼後,我們明白了
- CopyOnWriteArrayList是如何保證【寫】時執行緒安全的?因為用了ReentrantLock獨佔鎖,保證同時只有一個執行緒對集合進行修改操作。
- 資料是儲存在CopyOnWriteArrayList中的array陣列中的。
- 在新增元素的時候,並不是直接往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方法後:
- 和add方法一樣,先獲取獨佔鎖,同樣的,只有一個執行緒可以獲得獨佔鎖,其他執行緒會被阻塞。
- 獲取到獨佔鎖的執行緒獲得array,並且賦值給elements。
- 根據下標,獲得舊的元素。
- 進行一個對比,檢查舊的元素是否不等於新的元素,如果成立的話,執行5-8,如果不成立的話,執行9。
- 獲得舊陣列的長度。
- 複製出新的陣列。
- 修改新的陣列中指定下標的元素。
- 把舊的陣列替換掉。
- 為了保證volatile語義,即使沒有修改,也要替換成新的陣列。
- 不管是否執行了修改的操作,都會釋放獨佔鎖。
通過原始碼解析,我們應該更有體會:
- 通過獨佔鎖,來保證【寫】的執行緒安全。
- 修改操作,實際上操作的是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());
}
}
}
複製程式碼
執行結果:
程式碼很簡單,這裡就不再解釋了,我們直接來看迭代器的原始碼:
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());
}
}
複製程式碼
執行結果:
這沒問題把,我們先是往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());
}
}
複製程式碼
這次我們改變了程式碼的順序,先是獲取迭代器,然後是執行刪除執行緒的操作,最後遍歷迭代器。 執行結果:
可以看到被刪除的元素,還是列印出來了。如果我們沒有分析原始碼,不知道其中的原理,不知道弱一致性,當在多執行緒中用到CopyOnWriteArrayList的時候,可能會痛不欲生,想砸電腦,不知道為什麼獲取的資料有時候就不是正確的資料,而有時候又是。所以探究原理,還是挺有必要的,不管是通過原始碼分析,還是通過看部落格,甚至是直接看JDK中的註釋,都是可以的。
在Java併發包提供的集合中,CopyOnWriteArrayList應該是最簡單的一個,希望通過原始碼分析,讓大家有一個信心,原來JDK原始碼也是可以讀懂的。