在ArrayList的迴圈中刪除元素,會不會出現問題?

Wizey發表於2019-03-04

在 ArrayList 的迴圈中刪除元素,會不會出現問題?我開始覺得應該會有什麼問題吧,但是不知道問題會在哪裡。在經歷了一番測試和查閱之後,發現這個“小”問題並不簡單!

不在迴圈中的刪除,是沒有問題的,否則這個方法也沒有存在的必要了嘛,我們這裡討論的是在迴圈中的刪除,而對 ArrayList 的迴圈方法也是有多種的,這裡定義一個類方法 remove(),裡面有五種刪除的實現方法,有的方法執行時會報錯,有的是能執行但不能刪除完全,讀者也可以逐個測試。

public class ArrayListTest {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("aa");
        list.add("bb");
        list.add("bb");
        list.add("aa");
        list.add("cc");
        // 刪除元素 bb
        remove(list, "bb");
        for (String str : list) {
            System.out.println(str);
        }
    }
    public static void remove(ArrayList<String> list, String elem) {
        // 五種不同的迴圈及刪除方法
        // 方法一:普通for迴圈正序刪除,刪除過程中元素向左移動,不能刪除重複的元素
//        for (int i = 0; i < list.size(); i++) {
//            if (list.get(i).equals(elem)) {
//                list.remove(list.get(i));
//            }
//        }
        // 方法二:普通for迴圈倒序刪除,刪除過程中元素向左移動,可以刪除重複的元素
//        for (int i = list.size() - 1; i >= 0; i--) {
//            if (list.get(i).equals(elem)) {
//                list.remove(list.get(i));
//            }
//        }
        // 方法三:增強for迴圈刪除,使用ArrayList的remove()方法刪除,產生併發修改異常 ConcurrentModificationException
//        for (String str : list) {
//            if (str.equals(elem)) {
//                list.remove(str);
//            }
//        }
        // 方法四:迭代器,使用ArrayList的remove()方法刪除,產生併發修改異常 ConcurrentModificationException
//        Iterator iterator = list.iterator();
//        while (iterator.hasNext()) {
//            if(iterator.next().equals(elem)) {
//                list.remove(iterator.next());
//            }
//        }

        // 方法五:迭代器,使用迭代器的remove()方法刪除,可以刪除重複的元素,但不推薦
//        Iterator iterator = list.iterator();
//        while (iterator.hasNext()) {
//            if(iterator.next().equals(elem)) {
//                iterator.remove();
//            }
//        }
    }
}
複製程式碼

這裡我測試了五種不同的刪除方法,一種是普通的 for 迴圈,一種是增強的 foreach 迴圈,還有一種是使用迭代器迴圈,一共這三種迴圈方式。也歡迎你留言和我們討論哦!

上面這幾種刪除方式呢,在刪除 list 中單個的元素,也即是沒有重複的元素,如 “cc”。在方法三和方法四中都會產生併發修改異常 ConcurrentModificationException,這兩個刪除方式中都用到了 ArrayList 中的 remove() 方法(快去上面看看程式碼吧)。而在刪除 list 中重複的元素時,會有這麼兩種情況,一種是這兩個重複元素是緊挨著的,如 “bb”,另一種是這兩個重複元素沒有緊挨著,如 “aa”。刪除這種元素時,方法一在刪除重複但不連續的元素時是正常的,但在刪除重複且連續的元素時,會出現刪除不完全的問題,這種刪除方式也是用到了 ArrayList 中的 remove() 方法。而另外兩種方法都是可以正常刪除的,但是不推薦第五種方式,這個後面再說。

經過對執行結果的分析,發現問題都指向了 ArrayList 中的 remove() 方法,(感覺有種偵探辦案的味道,可能是程式碼寫多了的錯覺吧,txtx...)那麼看 ArrayList 原始碼是最好的選擇了,下面是我擷取的關鍵程式碼(Java1.8)。

public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}
複製程式碼

可以看到這個 remove() 方法被過載了,一種是根據下標刪除,一種是根據元素刪除,這也都很好理解。

根據下標刪除的 remove() 方法,大致的步驟如下:

  • 1、檢查有沒有下標越界,就是檢查一下當前的下標有沒有大於等於陣列的長度
  • 2、列表被修改(add和remove操作)的次數加1
  • 3、儲存要刪除的值
  • 4、計算移動的元素數量
  • 5、刪除位置後面的元素向左移動,這裡是用陣列拷貝實現的
  • 6、將最後一個位置引用設為 null,使垃圾回收器回收這塊記憶體
  • 7、返回刪除元素的值

根據元素刪除的 remove() 方法,大致的步驟如下:

  • 1、元素值分為null和非null值

  • 2、迴圈遍歷判等

  • 3、呼叫 fastRemove(i) 函式

    • 3.1、修改次數加1

    • 3.2、計算移動的元素數量

    • 3.3、陣列拷貝實現元素向左移動

    • 3.4、將最後一個位置引用設為 null

    • 3.5、返回 fase

  • 4、返回 true

這裡我有個疑問,第一個 remove() 方法中的程式碼和 fastRemove() 方法中的程式碼是完全一樣的,第一個 remove() 方法完全可以向第二個 remove() 方法一樣呼叫 fastRemove() 方法嘛,這裡程式碼確實是有些冗餘,我又看了 Java10 的原始碼,這裡編碼作者已經修改了,而且程式碼寫的很六~,看了半天才看懂大牛的高超的程式設計技巧,有興趣的小夥伴可以去看看。

我們重點關注的是刪除過程,學過資料結構的小夥伴可能手寫過這樣的刪除,下面我畫個圖來讓大家更清楚的看到整個刪除的過程。以刪除 “bb” 為例,當指到下標為 1 的元素時,發現是 "bb",此處元素應該被刪除,根據上面的刪除步驟可知,刪除位置後面的元素要向前移動,移動之後 “bb” 後面的 “bb” 元素下標為1,後面的元素下標也依次減1,這是在 i = 1 時迴圈的操作。在下一次迴圈中 i = 2,第二個 “bb” 元素就被遺漏了,所以這種刪除方法在刪除連續重複元素時會有問題。但是如果我們使 i 遞減迴圈,也即是方法二的倒序迴圈,這個問題就不存在了,正序刪除和倒序刪除如下圖所示。

刪除操作.jpg

既然我們已經搞清不能正常刪除的原因,那麼再來看看方法五中可以正常刪除的原因。方法五中使用的是迭代器中的 remove() 方法,通過閱讀 ArrayList 的原始碼可以發現,有兩個私有內部類,Itr 和 ListItr,Itr 實現自 Iterator 介面,ListItr 繼承 Itr 類和實現自 ListIterator 介面。Itr 類中也有一個 remove() 方法,迭代器實際呼叫的也正是這個 remove() 方法,我也擷取這個方法的原始碼。

private class Itr implements Iterator<E>
private class ListItr extends Itr implements ListIterator<E> 
複製程式碼
public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification(); // 檢查修改次數

    try {
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
複製程式碼

可以看到這個 remove() 方法中呼叫了 ArrayList 中的 remove() 方法,那為什麼方法四會丟擲併發修改異常而這裡就沒有問題呢?這裡注意 expectedModCount 變數和 modCount 變數,modCount 在前面的程式碼中也見到了,它記錄了 list 修改的次數,而前面還有一個 expectedModCount,這個變數的初值和 modCount 相等。在 ArrayList.this.remove(lastRet); 程式碼前面,還呼叫了檢查修改次數的方法 checkForComodification(),這個方法裡面做的事情很簡單,如果 modCount 和 expectedModCount 不相等,那麼就丟擲 ConcurrentModificationException,而在這個 remove() 方法中存在 ``expectedModCount = modCount`,兩個變數值在 ArrayList 的 remove() 方法後,進行了同步,所以不會有異常丟擲,並且在迴圈過程中,也不會遺漏連續重複的元素,所以可以正常刪除。上面這些程式碼都是在單執行緒中執行的,如果換到多執行緒中,方法五不能保證兩個變數修改的一致性,結果具有不確定性,所以不推薦這種方法。而方法一在單執行緒和多執行緒中都是可以正常刪除的,多執行緒中測試程式碼如下,這裡我只模擬了三個執行緒(注:這裡我沒有用 Java8 新增的 Lambda 表示式):

import java.util.ArrayList;
import java.util.Iterator;

public class MultiThreadArrayList {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("aa");
        list.add("bb");
        list.add("bb");
        list.add("aa");
        list.add("cc");
        list.add("dd");
        list.add("dd");
        list.add("cc");
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                remove(list,"aa");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                remove(list, "bb");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        Thread thread3 = new Thread() {
            @Override
            public void run() {
                remove(list, "dd");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        // 使各個執行緒處於就緒狀態
        thread1.start();
        thread2.start();
        thread3.start();
        // 等待前面幾個執行緒完成
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (String str : list) {
            System.out.println(str);
        }
    }

    public static void remove(ArrayList<String> list, String elem) {
        // 普通for迴圈倒序刪除,刪除過程中元素向左移動,不影響連續刪除
        for (int i = list.size() - 1; i >= 0; i--) {
            if (list.get(i).equals(elem)) {
                list.remove(list.get(i));
            }
        }

        // 迭代器刪除,多執行緒環境下無法使用
//        Iterator iterator = list.iterator();
//        while (iterator.hasNext()) {
//            if(iterator.next().equals(elem)) {
//                iterator.remove();
//            }
//        }
    }
}
複製程式碼

既然 Java 的迴圈刪除有問題,發散一下思維,Python 中的列表刪除會不會也有這樣的問題呢,我抱著好奇試了試,發現下面的方法一也同樣存在不能刪除連續重複元素的問題,方法二則是報列表下標越界的異常,測試程式碼如下,這裡我只測試了單執行緒環境:

list = []
list.append("aa")
list.append("bb")
list.append("bb")
list.append("aa")
list.append("cc")
# 方法一,存在和 Java 相同的刪除問題
# for str in list:
#     if str == "bb":
#         list.remove(str)
# 方法二,直接報錯
# for i in range(len(list)):
#    if list[i] == "bb":
#        list.remove(list[i])
for str in list:
    print(str)
複製程式碼

下面這段話摘抄自網上,很好的給出了上面問題出現的專業術語。

一:快速失敗(fail—fast)

在用迭代器遍歷一個集合物件時,如果遍歷過程中對集合物件的內容進行了修改(增加、刪除、修改),則會丟擲Concurrent Modification Exception。

原理:迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變數。集合在被遍歷期間如果內容發生變化,就會改變modCount的值。每當迭代器使用hashNext()/next()遍歷下一個元素之前,都會檢測modCount變數是否為expectedmodCount值,是的話就返回遍歷;否則丟擲異常,終止遍歷。

注意:這裡異常的丟擲條件是檢測到 modCount!=expectedmodCount 這個條件。如果集合發生變化時修改modCount值剛好又設定為了expectedmodCount值,則異常不會丟擲。因此,不能依賴於這個異常是否丟擲而進行併發操作的程式設計,這個異常只建議用於檢測併發修改的bug。

場景:java.util包下的集合類都是快速失敗的,不能在多執行緒下發生併發修改(迭代過程中被修改)。

二:安全失敗(fail—safe)

採用安全失敗機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷。

原理:由於迭代時是對原集合的拷貝進行遍歷,所以在遍歷過程中對原集合所作的修改並不能被迭代器檢測到,所以不會觸發Concurrent Modification Exception。

缺點:基於拷貝內容的優點是避免了Concurrent Modification Exception,但同樣地,迭代器並不能訪問到修改後的內容,即:迭代器遍歷的是開始遍歷那一刻拿到的集合拷貝,在遍歷期間原集合發生的修改迭代器是不知道的。

場景:java.util.concurrent包下的容器都是安全失敗,可以在多執行緒下併發使用,併發修改。

總結:快速失敗可以看做是一種在多執行緒環境下防止出現併發修改的預防策略,直接通過拋異常來告訴開發者不要這樣做。而安全失敗雖然不拋異常,但是在多個執行緒中修改集合,開發者同樣要注意多執行緒帶來的問題。

歡迎關注下方的微信公眾號哦,另外還有各種學習資料免費分享!

程式設計心路

相關文章