上篇文章 走進 JDK 之 ArrayList(一) 簡單分析了 ArrayList 的原始碼,文末留下了一個問題,modCount
是幹啥用的?下面我們通過一個小例子來引出今天的內容。
public static void main(String[] args){
List<String> list= new ArrayList<>();
list.add("java");
list.add("kotlin");
list.add("dart");
for (String s:list){
if (s.equals("dart"))
list.remove(s);
}
}
複製程式碼
大多數人應該都這麼幹過,然後得到一個鮮紅的 ConcurrentModificationException
,具體錯誤堆疊資訊如下:
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 collection.ArrayListTest.main(ArrayListTest.java:15)
複製程式碼
報錯位置是 ArrayList
的內部類 Itr
中的 checkForComodification()
方法。至於如何呼叫到這個方法的,我們首先得知道上面的程式碼中發生了什麼。看位元組碼的話太麻煩了又不容易理解,推薦一個反編譯神器 jad
,javac 編譯得到 class 檔案之後執行如下命令:
./jad ArrayListTest.class
複製程式碼
得到 ArrayListTest.jad 檔案,直接用文字編輯器開啟即可:
public class ArrayListTest {
public ArrayListTest() { }
public static void main(String args[]) {
ArrayList arraylist = new ArrayList();
arraylist.add("java");
arraylist.add("kotlin");
arraylist.add("dart");
Iterator iterator = arraylist.iterator(); // 1
do {
if(!iterator.hasNext()) // 2
break;
String s = (String)iterator.next(); // 3
if(s.equals("dart"))
arraylist.remove(s);
} while(true);
}
}
複製程式碼
從反編譯得到的程式碼我們可以發現,增強型 for 迴圈只是一個語法糖而已,編譯器幫我們進行了處理,其實是呼叫了迭代器來進行迴圈。著重看一下上面標註的三句程式碼,是整個迭代過程的核心。
第一句,獲取 ArrayList 的迭代器。
public Iterator<E> iterator() {
return new Itr();
}
複製程式碼
AbstractList
中定義了一個迭代器 Itr
,但是它的子類 ArrayList 並沒有直接使用父類的迭代器,而是自己定義了一個優化版本的 Itr
。迴圈體中第二句程式碼首先會判斷是否 hasNext()
,存在的話呼叫 next
獲取元素,不存在的話跳出迴圈。增強型 for 迴圈的基本實現就是這樣的。hasNext()
和 next()
方法原始碼如下:
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification(); // 併發檢測
int i = cursor;
if (i >= size) // 判斷是否越界
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) // 再次判斷,如果越界,可能是併發修改導致
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
......
// 省略其他程式碼
}
複製程式碼
cursur
表示當前遊標位置,hasNext()
方法就是根據 cursor 是否等於集合大小 size
判斷是否還有下一個元素。成員變數中有個 expectedModCount
,定義如下:
int expectedModCount = modCount;
複製程式碼
終於發現了 modCount
的蹤影,它被賦值給了 expectedModCount
變數,字面意思就是 期望的修改次數
。具體它有什麼用,接著看 next()
方法中的第一行程式碼,呼叫了 checkForComodification()
方法,這是用來做併發檢測的:
final void checkForComodification() {
if (modCount != expectedModCount) // 在迭代的過程中 modCount 發生了改變
throw new ConcurrentModificationException();
}
複製程式碼
異常就是這樣丟擲來的,modCount
和 expectedModCount
不相等,即實際的修改次數與期望的修改次數不相等。expectedModCount
是在迭代器初始化的過程中賦值的,其值等於 modCount
。在迭代過程中又不相等了,那就只可能是在迭代過程中修改了集合,造成了 modCount
變化。那麼,哪些操作會導致 modCount
發生變化呢?JDK 原始碼註釋中做了以下說明(modCount 在 AbstractList 中宣告):
The number of times this list has been structurally modified. Structural modifications are those that change the size of the list, or otherwise perturb it in such a fashion that iterations in progress may yield incorrect results.
集合的結構修改次數。結構修改指的是集合大小的變化。所以只要是涉及到增加或者刪除元素的方法,都要改變 modCount
。以 ArrayList 的 remove() 方法為例:
public E remove(int index) {
rangeCheck(index); // 邊界檢測
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0) // 移動 index 之後的所有元素
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
複製程式碼
通過 modCount++
使其自增 1。
由於 ArrayList 並不是執行緒安全的,一邊迭代一邊改變集合,的確可能導致多執行緒下程式碼表現不一致。可能有人會有這樣的疑問,文章開頭的測試程式碼並沒有涉及到併發操作啊,為什麼還是丟擲了異常?這就是集合的 fail-fast(快速失敗)
機制。
fail-fast
錯誤機制並不保證錯誤一定會發生,但是當錯誤發生的時候一定可以丟擲異常。它不管你是不是真的併發操作,只要可能是併發操作,就給你提前丟擲異常。針對非執行緒安全的集合類,這是一種健壯的處理方式。但是你如果真的想在單執行緒中這樣操作應該怎麼辦?沒關係,讓 modCount
和 expectedModCount
相等就完事了,ArrayList 的迭代器為我們提供了這樣的 add()
和 remove()
方法:
public void add(E e) {
checkForComodification();
try {
int i = cursor;
ArrayList.this.add(i, e); // add 之後要修改 modCount
cursor = i + 1;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet); // remove 之後要修改 modCount
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
複製程式碼
上面的程式碼實現在修改了集合結構之後都會給 expectedModCount
重新賦值,使其與 modCount
相等。修改一下文章開頭的測試程式碼:
public static void main(String[] args){
List<String> list= new ArrayList<>();
list.add("java");
list.add("kotlin");
list.add("dart");
// for (String s:list){
// if (s.equals("dart"))
// list.remove(s);
// }
Iterator<String> iterator=list.iterator();
while (iterator.hasNext()){
String s= iterator.next();
if (s.equals("dart"))
iterator.remove();
}
}
複製程式碼
這樣就不會再報錯了。
最後最後再給你出一道題,仔細看一下:
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("java");
list.add("kotlin");
list.add("dart");
for (String s : list) {
if (s.equals("kotlin"))
list.remove(s);
}
}
複製程式碼
如果沒看出來和文章開頭那道題的區別,那就再翻上去仔細觀察一下。之前我們要刪的是 dart
,集合中的最後一個元素。現在要刪的是 kotlin
,集合中的第二個元素。執行結果會怎麼樣?你要是精通腦筋急轉彎的話,肯定能給出正確答案。沒錯,這次成功刪除了元素並且沒有任何異常。這是為什麼呢?刪除 dart
就報異常,刪除 kotlin
就沒問題,這是歧視 dart
嗎。再把迭代器的程式碼掏出來:
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification(); // 併發檢測
int i = cursor;
if (i >= size) // 判斷是否越界
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) // 再次判斷,如果越界,可能是併發修改導致
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
複製程式碼
集合中新增了 3 個元素,所以初始化迭代器之後,expectedModCount = modCount = 3
,cursor
此時為 0 。先來分析文章開頭的程式碼,刪除集合中最後一個元素的情況:
- 執行完第一次迴圈,
cursor
為 1,未產生刪除操作,modCount
為 3,expectedModCount
為 3,size
為 3。cursor != size
,hasNext()
判斷還有元素。 - 執行完第二次迴圈,
cursor
為 2,仍未產生刪除操作,modCount
為 3,expectedModCount
為 3,size
為 3。cursor != size
,hasNext()
判斷還有元素。 - 執行完第三次迴圈,
cursor
為 3,由於產生刪除了操作,modCount
為 4,expectedModCount
仍為 3,size
變為 2。cursor != size
,hasNext()
判斷還有元素,繼續迭代,其實已經沒有元素了。 - 繼續迭代,呼叫
next()
方法,此時expectedModCount != modCount
,直接丟擲異常。
迴圈次數 | cursor | modCount | expectedModCount | size |
---|---|---|---|---|
1 | 1 | 3 | 3 | 3 |
2 | 2 | 3 | 3 | 3 |
3 | 3 | 4 | 3 | 2 |
再來看看刪除 kotlin
的執行流程:
- 執行完第一次迴圈,
cursor
為 1,未產生刪除操作,modCount
為 3,expectedModCount
為 3,size
為 3。cursor != size
,hasNext()
判斷還有元素。 - 執行完第二次迴圈,
cursor
為 2,產生刪除操作,modCount
為 4,expectedModCount
為 3,size
為 2。cursor == size
,hasNext()
判斷沒有元素了,不再呼叫next()
方法。
並不是 fail-fast
失效了,僅僅只是恰好 cursor == size
,hasNext()
方法誤以為集合中已經沒有元素了,其實還有一個元素。迴圈兩次之後就終止迴圈了,不再呼叫 next()
方法,也就不存在併發檢測了。
迴圈次數 | cursor | modCount | expectedModCount | size |
---|---|---|---|---|
1 | 1 | 3 | 3 | 3 |
2 | 2 | 3 | 3 | 2 |
本文由一個 ConcurrentModificationException
的例子,順藤摸瓜,解析了 ArrayList 迭代器的原始碼,同時說明了 Java 集合框架的 fail-fast
機制。最後也驗證了增強型 for 迴圈中刪除元素並不是百分之百會觸發 fail-fast
。
ArrayList
就說到這裡了,下一篇來看看 List
中同樣重要的 LinkedList
。
文章首發微信公眾號:
秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解。更多 JDK 原始碼解析,掃碼關注我吧!