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

在 ArrayList 的循環中刪除元素,會不會出現問題?我開始以爲應該會有什麼問題吧,可是不知道問題會在哪裏。在經歷了一番測試和查閱以後,發現這個「小」問題並不簡單!java

不在循環中的刪除,是沒有問題的,不然這個方法也沒有存在的必要了嘛,咱們這裏討論的是在循環中的刪除,而對 ArrayList 的循環方法也是有多種的,這裏定義一個類方法 remove(),裏面有五種刪除的實現方法,有的方法運行時會報錯,有的是能運行但不能刪除徹底,讀者也能夠逐個測試。python

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() 方法,大體的步驟以下:數據結構

  • 一、檢查有沒有下標越界,就是檢查一下當前的下標有沒有大於等於數組的長度
  • 二、列表被修改(add和remove操做)的次數加1
  • 三、保存要刪除的值
  • 四、計算移動的元素數量
  • 五、刪除位置後面的元素向左移動,這裏是用數組拷貝實現的
  • 六、將最後一個位置引用設爲 null,使垃圾回收器回收這塊內存
  • 七、返回刪除元素的值

根據元素刪除的 remove() 方法,大體的步驟以下:多線程

  • 一、元素值分爲null和非null值併發

  • 二、循環遍歷判等app

  • 三、調用 fastRemove(i) 函數

    • 3.一、修改次數加1

    • 3.二、計算移動的元素數量

    • 3.三、數組拷貝實現元素向左移動

    • 3.四、將最後一個位置引用設爲 null

    • 3.五、返回 fase

  • 四、返回 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包下的容器都是安全失敗,能夠在多線程下併發使用,併發修改。

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

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

編程心路
相關文章
相關標籤/搜索