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
總結
在使用一個不熟悉的服務,一定要查看其源碼,瞭解其具體實現,避免踩類似的坑。