使用List切割數組,拋出java.util.ConcurrentModificationException異常分析

guava包中數組切割方法Lists.patitions採坑記 :異常java.util.ConcurrentModificationException

背景

先來說一下背景:業務中有個場景需要批量操作某個originList,當originList中的元素過多(超過10),考慮到系統RT值的問題,需要切割originList,然後用多線程的方式來異步請求,處理每個itemList。

錯誤代碼示例

public static void main(String[] args) throws Exception {
	    List<Long> originList = Lists.newArrayList();
	    for (int i = 0; i < 100; i++) {
	        originList.add(Long.valueOf(i));
	    }
	
	    List<List<Long>> splitLists = Lists.partition(originList, 10);
	
	    // 通過線程池的方式,啓動10個線程來分別處理每個切割後的數組
	    ExecutorService executors = Executors.newFixedThreadPool(10);
	    splitLists.forEach(itemList ->
	            executors.execute(() -> {
	                try {
	                	// 對每個itemList根據業務訴求進行增刪改的操作	
	                    itemList.set(1, 1L);
                        itemList.remove(2);
	                    System.out.println("scuess");
	                } catch (Exception e) {
	                    e.printStackTrace();
	                    System.out.println("error");
	                }
	            })
	    );
	
	    executors.shutdownNow();
}

問題分析

按照預期,業務代碼中對itemList進行下列操作時,是不會有任務異常的。

itemList.set(1, 1L);
itemList.remove(2);

因爲其 只是對單個的itemList元素進行操作,是不存在併發競爭同一資源的情況
因此,我期待輸出的是十個“success”纔對。

實際上,卻拋了java.util.ConcurrentModificationException異常。且多次嘗試,輸出“success”和“error”的次數並不固定。

java.util.ConcurrentModificationException
	at java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1237)
	at java.util.ArrayList$SubList.set(ArrayList.java:1033)
	at com.jszhao.demo.map.ListsDemo.lambda$null$0(ListsDemo.java:24)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

ListsDemo.java:24 指向的是

itemList.set(1, 1L);

猜測與嘗試

由於拋的異常是ConcurrentModificationException,且異常代碼是itemList.set(1, 1L),故而猜測是由於在併發場景下修改了同一個list導致的問題。
爲了驗證猜測,每個線程裏創建臨時tempList用來操作。修改run裏面的代碼如下:

splitLists.forEach(itemList ->
                executors.execute(() -> {
                	// 在此處創建了一個tempList,確保每次操作的一定是不同list
                    List tempList = Lists.newArrayList(itemList);
                    try {
                        tempList.set(1, 1L);
                        tempList.remove(2);
                        System.out.println("scuess");
                    } catch (Exception e) {
                        System.out.println("error");
                        e.printStackTrace();
                    }
                })
        );

結果符合預期,輸出了10個“success”。

原有代碼分析

到了這一步,初步得到結論:

由Lists.patitions切割得到的List,實際底層可能存在同一個引用,導致在併發場景下,操作同一對象,拋出ConcurrentModificationException異常。

查看Lists.patitions(list,size)方法的底層實現可以看到,其實際上是返回了個Patitions對象,而Patitions對象結果如下所示:

	Partition(List<T> list, int size) {
	  this.list = list;
	  this.size = size;
	}
	
	@Override
	public List<T> get(int index) {
	  checkElementIndex(index, size());
	  int start = index * size;
	  int end = Math.min(start + size, list.size());
	  return list.subList(start, end);
	}

可以看到,其實際上操作的是構建時傳入的originList,且get時實際上用了subList方法

再翻看subList的源碼:

	public List<E> subList(int fromIndex, int toIndex) {
        subListRangeCheck(fromIndex, toIndex, size);
        return new SubList(this, 0, fromIndex, toIndex);
    }
	SubList(AbstractList<E> parent,
               int offset, int fromIndex, int toIndex) {
       	this.parent = parent;
        this.parentOffset = fromIndex;
        this.offset = offset + fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = ArrayList.this.modCount;
     }

從以上可以看出,subList方法返回的List實際上並不是一個新list,而是對原有鏈表引用。
爲了確定,再來看sublist的get、set方法:

	public E set(int index, E e) {
        rangeCheck(index);
        checkForComodification();
        E oldValue = ArrayList.this.elementData(offset + index);
        ArrayList.this.elementData[offset + index] = e;
        return oldValue;
     }

     public E get(int index) {
        rangeCheck(index);
        checkForComodification();
        return ArrayList.this.elementData(offset + index);
     }

顯然,代碼看到這裏,答案已經呼之欲出了。

異常原因

顯然,guava包裏Lists.patitions(list, size)方法返回的List<List>中維護了對originList的引用關係。故而在併發場景下,對單個itemList的修改實際上是對originList的修改。
而我們創建的originList並不是一個線程安全的list,因此會拋異常;

線程安全,能否解決問題?

分析到這,可能有部分同學想到,是否可以通過鎖機制來解決?

  • synchronized關鍵字
    實際上並不能通過synchronized來加隱式鎖的機制來解決問題。
    因爲originList採用ArrayList時,實際上拋出異常的代碼是
	private void checkForComodification() {
      	if (ArrayList.this.modCount != this.modCount)
           	throw new ConcurrentModificationException();
   	}

可知,實際上判定的是當前itemList中的modcount與originList的modcount是否一致?在多線程環境下,其它itemList的modcount變化值顯然是不會同步至當前itemList的,因此不能通過synchronized加鎖來解決問題。
實際上,在此處加鎖就算能解決問題,也不需考慮,因爲有更好的辦法。

  • 線程安全類
    那麼將originList用線程安全的list實現,比如說CopyOnWriteArrayList,能否解決問題?
    我們來實踐下,代碼如下:
	public static void main(String[] args) throws Exception {
        // 用線程安全的CopyOnWriteArrayList,而非ArrayList
        List<Long> originList = Lists.newCopyOnWriteArrayList();
        for (int i = 0; i < 100; i++) {
            originList.add(Long.valueOf(i));
        }

        List<List<Long>> splitLists = Lists.partition(originList, 10);

        // 通過線程池的方式,啓動10個線程來分別處理每個切割後的數組
        ExecutorService executors = Executors.newFixedThreadPool(10);
        splitLists.forEach(itemList ->
                executors.execute(() -> {
                    try {
                        itemList.set(1, 1L);
                        itemList.remove(2);
                        System.out.println("scuess");
                    } catch (Exception e) {
                        System.out.println("error");
                        e.printStackTrace();
                    }

                }));

        executors.shutdown();
    }

運行結果如下:

scuess
error
error
scuess
error
scuess
java.util.ConcurrentModificationException
error
	at java.util.concurrent.CopyOnWriteArrayList$COWSubList.checkForComodification(CopyOnWriteArrayList.java:1277)
	at java.util.concurrent.CopyOnWriteArrayList$COWSubList.set(CopyOnWriteArrayList.java:1292)
	at com.jszhao.demo.map.ListsDemo.lambda$null$0(ListsDemo.java:25)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
scuess
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

從結果來看,選用線程安全的類同樣是不可取的,這是爲何呢?

	// only call this holding l's lock
   private void checkForComodification() {
        if (l.getArray() != expectedArray)
            throw new ConcurrentModificationException();
    }

分析CopyOnWriteArrayList的源代碼可知,實際比較上拋出ConcurrentModificationException異常的原因是,當itemList去執行set/remove操作時會去比較當前的Array和expectedArray是否一致。
而由於CopyOnWriteArrayList的特性,每次更新(set、remove等)會生成一個新的數組,因此在執行l.getArray() != expectedArray時,結果必然時false

因此,選用CopyOnWriteArrayList也不可解這個問題。至於其它的線程安全類,讀者可自行嘗試。

最終解決方案

  • 在每個線程中重新構造List
	public static void main(String[] args) throws Exception {
        // 用線程安全的CopyOnWriteArrayList,而非ArrayList
        List<Long> originList = Lists.newCopyOnWriteArrayList();
        for (int i = 0; i < 100; i++) {
            originList.add(Long.valueOf(i));
        }

        List<List<Long>> splitLists = Lists.partition(originList, 2);

        // 通過線程池的方式,啓動10個線程來分別處理每個切割後的數組
        ExecutorService executors = Executors.newFixedThreadPool(10);
        splitLists.forEach(itemList ->
                executors.execute(() -> {
                    try {
                        // 構建新的list以供remove、等
                        List tempList = Lists.newArrayList(itemList);
                        tempList.set(1, 1L);
                        tempList.remove(2);
                        System.out.println("scuess");
                    } catch (Exception e) {
                        System.out.println("error");
                        e.printStackTrace();
                    }

                }));

        executors.shutdown();
    }
  • 選用其它切割方法切割list

總結

在使用一個不熟悉的服務,一定要查看其源碼,瞭解其具體實現,避免踩類似的坑。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章