關於面試題“ArrayList迴圈remove()要用Iterator”的研究

since1986發表於2017-10-24

兩個月前我在參加一場面試的時候,被問到了ArrayList如何迴圈刪除元素,當時我回答用Iterator,當面試官問為什麼要用Iterator而不用foreach時,我沒有答出來,如今又回想到了這個問題,我覺得應該把它搞一搞,所以我就寫了一個小的demo並結合閱讀原始碼來驗證了一下。

下面是我驗證的ArrayList迴圈remove()的4種情況,以及其結果(基於oracle jdk1.8):

//List<Integer> list = new ArrayList<>();
//list.add(1);
//list.add(2);
//list.add(3);
//list.add(4);
//迴圈remove()的4種情況的程式碼片段:

//#1
for (Integer integer : list) {
    list.remove(integer);
}

結果:
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)
-----------------------------------------------------------------------------------

//#2
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
    Integer integer = iterator.next();
    list.remove(integer);
}

結果:
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)
-----------------------------------------------------------------------------------


//#3
for (int i = 0; i < list.size(); i++) {
    list.remove(i);
}
System.out.println(list);

結果:
[2, 4]
-----------------------------------------------------------------------------------

//#4
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()){
    iterator.next();
    iterator.remove();
}
System.out.println(list.size());

結果:(唯一一個得到期望值的)
0複製程式碼

可以看出來這幾種情況只有最後一種是得到預期結果的,其他的要麼異常要麼得不到預期結果,下面我們們一個一個進行分析。

#1

//#1
for (Integer integer : list) {
    list.remove(integer);
}

結果:
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)複製程式碼

通過異常棧,我們可以定位是在ArrayList的內部類ItrcheckForComodification方法中爆出了ConcurrentModificationException異常(關於這個異常是怎麼回事我們們暫且不提)我們開啟ArrayList的原始碼,定位到901行處:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}複製程式碼

這個爆出異常的方法實際上就做了一件事,檢查modCount != expectedModCount因為滿足了這個條件,所以丟擲了異常,繼續檢視modCountexpectedModCount這兩個變數,發現modCount是繼承自AbstractList的一個屬性,這個屬性有一大段註釋

/**
 * The number of times this list has been <i>structurally modified</i>.
 * 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.
 *
 * <p>This field is used by the iterator and list iterator implementation
 * returned by the {@code iterator} and {@code listIterator} methods.
 * If the value of this field changes unexpectedly, the iterator (or list
 * iterator) will throw a {@code ConcurrentModificationException} in
 * response to the {@code next}, {@code remove}, {@code previous},
 * {@code set} or {@code add} operations.  This provides
 * <i>fail-fast</i> behavior, rather than non-deterministic behavior in
 * the face of concurrent modification during iteration.
 *
 * <p><b>Use of this field by subclasses is optional.</b> If a subclass
 * wishes to provide fail-fast iterators (and list iterators), then it
 * merely has to increment this field in its {@code add(int, E)} and
 * {@code remove(int)} methods (and any other methods that it overrides
 * that result in structural modifications to the list).  A single call to
 * {@code add(int, E)} or {@code remove(int)} must add no more than
 * one to this field, or the iterators (and list iterators) will throw
 * bogus {@code ConcurrentModificationExceptions}.  If an implementation
 * does not wish to provide fail-fast iterators, this field may be
 * ignored.
 */
protected transient int modCount = 0;複製程式碼

大致的意思是這個欄位用於有fail-fast行為的子集合類的,用來記錄集合被修改過的次數,我們回到ArrayList可以找到在add(E e)的呼叫鏈中的一個方法ensureExplicitCapacity(int minCapacity) 中會對modCount自增:

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}複製程式碼

我們在初始化list時呼叫了4次add(E e)所以現在modCount的值為4


再來找expectedModCount:這個變數是定義在ArrayListIterator的實現類Itr中的,它預設被賦值為modCount


知道了這兩個變數是什麼了以後,我們開始走查吧,在Itr的相關方法中加好斷點(編譯器會將foreach編譯為使用Iterator的方式,所以我們看Itr就可以了),開始除錯:

迴圈:


在迭代的每次next()時都會呼叫checkForComodification()

list.remove()

ArrayListremove(Object o)中又呼叫了fastRemove(index)


fastRemove(index)中對modCount進行了自增,剛才說過modCount經過4次add(E e)初始化後是4所以++後現在是5

繼續往下走,進入下次迭代:


又一次執行next()next()呼叫checkForComodification(),這時在上邊的過程中modCount由於fastRemove(index)的操作已經變成了5expectedModCount則沒有人動,所以很快就滿足了丟擲異常的條件modCount != expectedModCount(也就是前面提到的fail-fast),程式退出。

#2

//#2
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
    Integer integer = iterator.next();
    list.remove(integer);
}

結果:
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)複製程式碼

其實這個#2和#1是一樣的,foreach會在編譯期被優化為Iterator呼叫,所以看#1就好啦。


#3

//#3
for (int i = 0; i < list.size(); i++) {
    list.remove(i);
}
System.out.println(list);

結果:
[2, 4]複製程式碼

這種一本正經的胡說八道的情況也許在寫程式碼犯困的情況下會出現... 不做文字解釋了,用println()來說明吧:

第0次迴圈開始
remove(0)前的list: [1, 2, 3, 4]
remove(0)前的list.size()=4
執行了remove(0)
remove(0)後的list.size()=3
remove(0)後的list: [2, 3, 4]
下一次迴圈的i=1
下一次迴圈的list.size()=3
第0次迴圈結束
是否還有條件進入下次迴圈?: true

第1次迴圈開始
remove(1)前的list: [2, 3, 4]
remove(1)前的list.size()=3
執行了remove(1)
remove(1)後的list.size()=2
remove(1)後的list: [2, 4]
下一次迴圈的i=2
下一次迴圈的list.size()=2
第1次迴圈結束
是否還有條件進入下次迴圈?: false


Process finished with exit code 0複製程式碼

實際上ArrayListItr遊標最後一次返回值索引來解決了這種size越刪越小,但是要刪除元素的index越來越大的尷尬局面,這個將在#4裡說明。

#4

這個才是正兒八經能夠正確執行的方式,用了ArrayList中迭代器Itrremove()而不是用ArrayList本身的remove(),我們除錯一下吧看看到底經歷了什麼:

迭代:

Itr初始化:遊標 cursor = 0; 最後一次返回值索引 lastRet = -1; 期望修改次數 expectedModCount = modCount = 4;


迭代的hasNext():檢查遊標是否已經到達當前list的size,如果沒有則說明可以繼續迭代:



迭代的next()checkForComodification() 此時expectedModCountmodCount是相等的,不會丟擲ConcurrentModificationException,然後取到遊標(第一次迭代遊標是0)對應的list的元素,再將遊標+1,也就是遊標後移指向下一個元素,然後將遊標原值0賦給最後一次返回值索引,也就是最後一次返回的是索引0對應的元素

iterator.remove():同樣checkForComodification()然後呼叫ArrayListremove(lastRet)刪除最後返回的元素,刪除後modCount會自增

刪除完成後,將遊標賦值成最後一次返回值索引,其實也就是將遊標回退了一格回到了上一次的位置,然後將最後一次返回值索引重新設定為了初始值-1,最後expectedModCount又重新賦值為了上一步過程完成後新的modCount


由上兩個步驟可以看出來,雖然list的size每次remove()都會-1,但是由於每次remove()都會將遊標回退,然後將最後一次返回值索引重置,所以實際上沒回remove()的都是當前集合的第0個元素,就不會出現#3中size越刪越小,而要刪除元素的索引越來越大的情況了,同時由於在remove()過程中expectedModCountmodCount始終通過賦值保持相等,所以也不會出現fail-fast丟擲異常的情況了。

以上是我通過走查原始碼的方式對面試題“ArrayList迴圈remove()要用Iterator”做的一點研究,沒考慮併發場景,這篇文章寫了大概3個多小時,寫完這篇文章辦公室就剩我一個人了,我也該回去了,今天1024程式設計師節,大家節日快樂!


2017.10.25更新#1

感謝@llearn的提醒,#3也可以用用巧妙的方式來得到正確的結果的(再面試的時候,我覺得可以和麵試官說不一定要用Iterator了,感謝@llearn

//#3 我覺得可以這樣
for (int i = 0; i < list.size(); ) {
list.remove(0);
}
System.out.println(list);

2017.10.25更新#2

感謝@ChinLong的提醒,提供了另一種不用Iterator的方法,也就是倒著迴圈(這種方案我寫完文章時也想到了,但沒有自己印證到demo上),感謝@ChinLong

然道就沒有人和我一下喜歡倒著刪的.
聽別人說倒著迭代速度稍微快一點???
for (int i = list.size() -1; i >= 0; i-- ) {
list.remove(i);
}
System.out.println(list);

相關文章