面試必問之 CopyOnWriteArrayList,你瞭解多少?

程式設計師志哥發表於2022-01-14

一、摘要

在介紹 CopyOnWriteArrayList 之前,我們一起先來看看如下方法執行結果,程式碼內容如下:

public static void main(String[] args) {
    List<String> list = new ArrayList<String>();
    list.add("1");
    list.add("2");
    list.add("1");
    System.out.println("原始list元素:"+ list.toString());
    //通過物件移除等於內容為1的元素
    for (String item : list) {
        if("1".equals(item)) {
            list.remove(item);
        }
    }
    System.out.println("通過物件移除後的list元素:"+ list.toString());
}

執行結果內容如下:

原始list元素:[1, 2, 1]
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
	at java.util.ArrayList$Itr.next(ArrayList.java:859)
	at com.example.container.a.TestList.main(TestList.java:16)

很遺憾,結果並沒有達到我們想要的預期效果,執行之後直接報錯!拋ConcurrentModificationException異常!

為啥會拋這個異常呢?

我們一起來看看,foreach 寫法實際上是對List.iterator() 迭代器的一種簡寫,因此我們可以從分析List.iterator() 迭代器進行入手,看看為啥會拋這個異常。

ArrayList類中的Iterator迭代器實現,原始碼內容:

通過程式碼我們發現 ItrArrayList 中定義的一個私有內部類,每次呼叫nextremove方法時,都會呼叫checkForComodification方法,原始碼如下:

/**修改次數檢查*/
final void checkForComodification() {
	//檢查List中的修改次數是否與迭代器類中的修改次數相等
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

checkForComodification方法,實際上是用來檢查List中的修改次數modCount是否與迭代器類中的修改次數expectedModCount相等,如果不相等,就會丟擲ConcurrentModificationException異常!

那麼問題基本上已經清晰了,上面的執行結果之所以會丟擲這個異常,就是因為List中的修改次數modCount與迭代器類中的修改次數expectedModCount不相同造成的!

閱讀過集合原始碼的朋友,可能想起Vector這個類,它不是 JDK 中 ArrayList 執行緒安全的一個版本麼?

好的,為了眼見為實,我們把ArrayList換成Vector來測試一下,程式碼如下:

public static void main(String[] args) {
    Vector<String> list = new Vector<String>();
    //模擬10個執行緒向list中新增內容,並且讀取內容
    for (int i = 0; i < 5; i++) {
        final int j = i;
        new Thread(new Runnable() {
            @Override
            public void run() {
                //新增內容
                list.add(j + "-j");

                //讀取內容
                for (String str : list) {
                    System.out.println("內容:" + str);
                }
            }
        }).start();
    }
}

執行程式,執行結果如下:

還是一樣的結果,拋異常了Vector雖然執行緒安全,只不過是加了synchronized關鍵字,但是迭代問題完全沒有解決!

繼續回到本文要介紹的 CopyOnWriteArrayList 類,我們把上面的例子,換成CopyOnWriteArrayList類來試試,原始碼內容如下:

public static void main(String[] args) {
    //將ArrayList換成CopyOnWriteArrayList
    CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    list.add("1");
    list.add("2");
    list.add("1");
    System.out.println("原始list元素:"+ list.toString());

    //通過物件移除等於11的元素
    for (String item : list) {
        if("1".equals(item)) {
            list.remove(item);
        }
    }
    System.out.println("通過物件移除後的list元素:"+ list.toString());
}

執行結果如下:

原始list元素:[1, 2, 1]
通過物件移除後的list元素:[2]

呃呵,執行成功了,沒有報錯!是不是很神奇~~

當然,類似上面這樣的例子有很多,比如寫10個執行緒向list中新增元素讀取內容,也會丟擲上面那個異常,操作如下:

public static void main(String[] args) {
    final List<String> list = new ArrayList<>();
    //模擬10個執行緒向list中新增內容,並且讀取內容
    for (int i = 0; i < 10; i++) {
        final int j = i;
        new Thread(new Runnable() {
            @Override
            public void run() {
                //新增內容
                list.add(j + "-j");

                //讀取內容
                for (String str : list) {
                    System.out.println("內容:" + str);
                }
            }
        }).start();
    }
}

類似的操作例子就非常多了,這裡就不一一舉例了。

CopyOnWriteArrayList 實際上是 ArrayList 一個執行緒安全的操作類!

從它的名字可以看出,CopyOnWrite 是在寫入的時候,不修改原內容,而是將原來的內容複製一份到新的陣列,然後向新陣列寫完資料之後,再移動記憶體指標,將目標指向最新的位置。

二、簡介

從 JDK1.5 開始 Java 併發包裡提供了兩個使用CopyOnWrite 機制實現的併發容器,分別是CopyOnWriteArrayListCopyOnWriteArraySet

從名字上看,CopyOnWriteArrayList主要針對動態陣列,一個執行緒安全版本的 ArrayList !

CopyOnWriteArraySet主要針對集,CopyOnWriteArraySet可以理解為HashSet執行緒安全的操作類,我們都知道HashSet基於雜湊表HashMap實現,但是CopyOnWriteArraySet並不是基於雜湊表實現,而是基於CopyOnWriteArrayList動態陣列實現!

關於這一點,我們可以從它的原始碼中得出結論,部分原始碼內容:

從原始碼上可以看出,CopyOnWriteArraySet預設初始化的時候,例項化了CopyOnWriteArrayList類,CopyOnWriteArraySet的大部分方法,例如addremove等方法都基於CopyOnWriteArraySet實現!

兩者最大的不同點是,CopyOnWriteArrayList可以允許元素重複,而CopyOnWriteArraySet不允許有重複的元素!

好了,繼續來 BB 本文要介紹的CopyOnWriteArrayList類~~

開啟CopyOnWriteArrayList類的原始碼,內容如下:

可以看到 CopyOnWriteArrayList 的儲存元素的陣列array變數,使用了volatile關鍵字保證的多執行緒下資料可見行;同時,使用了ReentrantLock可重入鎖物件,保證執行緒操作安全。

在初始化階段,CopyOnWriteArrayList預設給陣列初始化了一個物件,當然,初始化方法還有很多,比如如下我們經常會用到的一個初始化方法,原始碼內容如下:

這個方法,表示如果我們傳入的是一個 ArrayList陣列物件,會將物件內容複製一份到新的陣列中,然後初始化進去,操作如下:

List<String> list = new ArrayList<>();
...
//CopyOnWriteArrayList將list內容複製出來,並建立一個新的陣列
CopyOnWriteArrayList<String> copyList = new CopyOnWriteArrayList<>(list);

CopyOnWriteArrayList是對原陣列內容進行復制再寫入,那麼是不是也存在多執行緒下操作也會發生衝突呢?

下面我們再一起來看看它的方法實現!

三、常用方法

3.1、新增元素

add()方法是CopyOnWriteArrayList的新增元素的入口!

CopyOnWriteArrayList之所以能保證多執行緒下安全操作, add()方法功不可沒,原始碼如下:

操作步驟如下:

  • 1、獲得物件鎖;
  • 2、獲取陣列內容;
  • 3、將原陣列內容複製到新陣列;
  • 4、寫入資料;
  • 5、將array陣列變數地址指向新陣列;
  • 6、釋放物件鎖;

在 Java 中,獨佔鎖方面,有2種方式可以保證執行緒操作安全,一種是使用虛擬機器提供的synchronized 來保證併發安全,另一種是使用JUC包下的ReentrantLock可重入鎖來保證執行緒操作安全。

CopyOnWriteArrayList使用了ReentrantLock這種可重入鎖,保證了執行緒操作安全,同時陣列變數array使用volatile保證多執行緒下資料的可見行!

其他的,還有指定下標進行新增的方法,如add(int index, E element),操作類似,先找到需要新增的位置,如果是中間位置,則以新增位置為分界點,分兩次進行復制,最後寫入資料!

3.2、移除元素

remove()方法是CopyOnWriteArrayList的移除元素的入口!

原始碼如下:

操作類似新增方法,步驟如下:

  • 1、獲得物件鎖;
  • 2、獲取陣列內容;
  • 3、判斷移除的元素是否為陣列最後的元素,如果是最後的元素,直接將舊元素內容複製到新陣列,並重新設定array值;
  • 4、如果是中間元素,以index為分界點,分兩節複製;
  • 5、將array陣列變數地址指向新陣列;
  • 6、釋放物件鎖;

當然,移除的方法還有基於物件的remove(Object o),原理也是一樣的,先找到元素的下標,然後執行移除操作。

3.3、查詢元素

get()方法是CopyOnWriteArrayList的查詢元素的入口!

原始碼如下:

public E get(int index) {
    //獲取陣列內容,通過下標直接獲取
    return get(getArray(), index);
}

查詢因為不涉及到資料操作,所以無需使用鎖進行處理!

3.4、遍歷元素

上文中我們介紹到,基本都是在遍歷元素的時候因為修改次數與迭代器中的修改次數不一致,導致檢查的時候拋異常,我們一起來看看CopyOnWriteArrayList迭代器實現。

開啟原始碼,可以得出CopyOnWriteArrayList返回的迭代器是COWIterator,原始碼如下:

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

開啟COWIterator類,其實它是CopyOnWriteArrayList的一個靜態內部類,原始碼如下:

可以看出,在使用迭代器的時候,遍歷的元素都來自於上面的getArray()方法傳入的物件陣列,也就是傳遞進來的 array 陣列!

由此可見,CopyOnWriteArrayList 在使用迭代器遍歷的時候,操作的都是原陣列,沒有像上面那樣進行修改次數判斷,所以不會拋異常!

當然,從原始碼上也可以得出,使用CopyOnWriteArrayList的迭代器進行遍歷元素的時候,不能呼叫remove()方法移除元素,因為不支援此操作!

如果想要移除元素,只能使用CopyOnWriteArrayList提供的remove()方法,而不是迭代器的remove()方法,這個需要注意一下!

四、總結

CopyOnWriteArrayList是一個典型的讀寫分離的動態陣列操作類!

在寫入資料的時候,將舊陣列內容複製一份出來,然後向新的陣列寫入資料,最後將新的陣列記憶體地址返回給陣列變數;移除操作也類似,只是方式是移除元素而不是新增元素;而查詢方法,因為不涉及執行緒操作,所以並沒有加鎖出來!

因為CopyOnWriteArrayList讀取內容沒有加鎖,在寫入資料的時候同時也可以進行讀取資料操作,因此效能得到很大的提升,但是也有缺陷,對於邊讀邊寫的情況,不一定能實時的讀到最新的資料,比如如下操作:

public static void main(String[] args) throws InterruptedException {
    final CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    list.add("a");
    list.add("b");
    for (int i = 0; i < 5; i++) {
        final int j =i;
        new Thread(new Runnable() {
            @Override
            public void run() {
                //寫入資料
                list.add("i-" + j);
                //讀取資料
                for (String str : list) {
                    System.out.println("執行緒-" + Thread.currentThread().getName() + ",讀取內容:" + str);
                }
            }
        }).start();
    }
}

新建5個執行緒向list中新增元素,執行結果如下:

可以看到,5個執行緒的讀取內容有差異!

因此CopyOnWriteArrayList很適合讀多寫少的應用場景!

五、參考

1、JDK1.7&JDK1.8 原始碼

2、掘金 - 擁抱心中的夢想 - 說一說Java中的CopyOnWriteArrayList

相關文章