1. 程式人生 > >Java基礎之你會在foreach遍歷集合時進行remove操作嗎?

Java基礎之你會在foreach遍歷集合時進行remove操作嗎?

當通過for迴圈遍歷集合時,一般禁止操作(add or remove)集合元素。雖然開發規範裡寫的非常清楚,但最近還是有人掉坑裡導致出了一個小BUG,那我們就一起看看這麼做到底會發生什麼?

小例子

程式碼示例

List<String> list = new ArrayList<>();
list.add("e1");
list.add("e2");

for (String str : list) {
    if ("e1".equals(str)) {
        list.remove("e1");
    }

    if ("e2".equals(str)) {
        System.out.println("element 2 fetched"
); } }

執行結果:element 2 fetched 將不會被列印。

位元組碼中是如何處理的?

讓我們看看位元組碼是怎麼樣的,僅截圖了部分位元組碼。

如上面截圖的 #27、#34、#43,foreach 實際上是通過 Iterator 來處理的。最後通過 #87 的 goto 指令進入下一次遍歷,並進行 hasNext()判斷。

class檔案反編譯後又是怎麼樣的?

再來看看將.class檔案反編譯後得到的程式碼,實際上編譯器將 foreach 轉換成了用 Iterator 來處理。

所以,眼見不一定為實,程式設計師開發時用的是高階語言,編碼怎麼簡單高效怎麼來,所以偶爾也可以看看反編譯class後的程式碼以及位元組碼檔案,看看編譯器做了哪些優化。

ArrayList list = new ArrayList();
list.add("e1");
list.add("e2");
Iterator var2 = list.iterator();

while(var2.hasNext()) {
    String str = (String)var2.next();
    if("e1".equals(str)) {
        list.remove("e1");
    }

    if("e2".equals(str)) {
        System.out.println("element 2 fetched"
); } }

為什麼remove(e1)會導致無法獲取e2?

list.remove(“e1”)後,在 while(var2.hasNext()) 時,返回結果將為 false,因此當迴圈一次後Iterator將認為list已經遍歷結束。

要弄清原因,需要看看ArrayList對於Iterator介面的實現,瞭解hasNext()、next()方法的實現。

先看看ArrayList中實現Iterator的內部類Itr

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
    ...
}   

cursor表示下一個返回元素的下標,可以理解成 遊標lastRet表示上一次返回的元素下標。另ArrayList有個size屬性,表示ArrayList中的元素個數。

hasNext() 的判斷條件是cursor != size. 只要沒遍歷到最後一個元素,就返回true.

public boolean hasNext() {
    return cursor != size;
}

下面是 next() 部分程式碼。

public E next() {
    ...
    int i = cursor; // cursor為當前需要返回元素的下標
    ...
    cursor = i + 1; // cursor向後移動一個位置,指向下一個要返回的元素
    return (E) elementData[lastRet = i]; // 對lastRet賦值,然後返回當前元素
}

現在,看一下下面程式碼的執行情況:

ArrayList list = new ArrayList();
list.add("e1");
list.add("e2");
Iterator var2 = list.iterator();
while(var2.hasNext()) {
    String str = (String)var2.next();
    if("e1".equals(str)) {
        list.remove("e1");
    }
}
  • 第一次 呼叫var2.hasNext(),此時滿足條件 cursor(0) != size(2),然後執行 var2.next(),此時 cursor=1
  • 執行 list.remove(“e1”),此時,list的size將從2變為1
  • 當執行完第一次迴圈,進入第二次hasNext()判斷時,cursor=1而且size=1,導致Iterator認為已經遍歷結束,因此e2將被漏掉。

此時,過程已非常清楚。list本有2個元素,Iterator第一次獲取元素時,程式刪掉了當前元素,導致list的size變為1。Iterator第二次獲取元素時,開心說到:”list一共只有一個元素,我已經遍歷了一個,easy,輕鬆搞定!”。

矛盾點在於:hasNext() 是根據已fetch元素和被遍歷物件的size動態判斷的,一旦遍歷過程中被遍歷物件的size變化,就會出現以上問題。

用普通for迴圈進行處理

如果在普通for迴圈中進行如上操作,又會發生什麼呢?

List<String> list = new ArrayList<>();
list.add("e1");
list.add("e2");

for (int i = 0, length = list.size(); i < length; i++) {
    if ("e1".equals(list.get(i))) {
        list.remove("e1");
    }
}

執行後將報如下異常:

java.lang.IndexOutOfBoundsException: Index: 1, Size: 1

原因:區域性變數length為list遍歷前的size,length=2;remove(“e1”)後,list的size變為1;因此,第二次進入迴圈執行list.get(1)時將出現上述異常

正確的姿勢

將remove操作交給Iterator來處理,使用Iterator介面提供的remove操作。

List<String> list = new ArrayList<>();
list.add("e1");
list.add("e2");

for (Iterator<String> iterator = list.iterator(); iterator.hasNext(); ) {
    String str = iterator.next();
    if ("e1".equals(str)) {
        iterator.remove();
    }

    if ("e2".equals(str)) {
        System.out.println("element 2 fetched");
    }
}

執行結果:element 2 fetched 被正常打印出來。

那Iterator的remove()又是怎麼做的?下面是ArrayList中迭代器的remove方法。

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet); // 呼叫ArrayList的remove移除元素,且size減1
        cursor = lastRet; // 將遊標回退一位
        lastRet = -1; // 重置lastRet
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

因為Iterator.remove()在執行集合本身的remove後,同時對遊標進行了 “校準”

關於ConcurrentModificationException

以下Demo將丟擲該異常。

private static List<String> list = new ArrayList<>();
private static boolean isListUpdated = false;

public static void main(String[] args) throws InterruptedException {
    list.add("e1");
    list.add("e2");

    new Thread(() -> {
        list.add("e3");
        isListUpdated = true;
    }).start();

    for (Iterator<String> iterator = list.iterator(); iterator.hasNext(); ) {
        while (!isListUpdated) {
            Thread.sleep(1000);
        }
        iterator.next();
    }
}

在Java集合框架中,很多物件都不是執行緒安全的,例如:HashMap、ArrayList等。當Iterator在遍歷集合時,如果其他執行緒操作了集合中的元素,將丟擲該異常。

ArrayList中對於Iterator的實現類為Itr如下:

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;
}    

其中有個重要的屬性 expectedModCount,表示本次期望修改的次數,初始值為modCount.

modCountAbstractList 的屬性,如下:

protected transient int modCount = 0;

注意,它由transient修飾,保證了執行緒之間修改的可見性。對集合中物件的增加、刪除操作都會對modCount加1。

在next()、remove()操作中都會進行 checkForComodification() ,用於檢查迭代期間其他執行緒是否修改了被迭代物件。下面是checkForComodification方法:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

這是一種 Fail-Fast(快速失敗) 策略,只要被迭代物件發生變更,將滿足 modCount != expectedModCount 條件,從而丟擲ConcurrentModificationException。

歡迎關注陳同學的公眾號,一起學習,一起成長