Java 移除List中的元素,這玩意講究!

前言

 前陣子,一名java初學者 遇到了list 使用remove的問題,當時我暫且給他說了一種解決方案。

事後,我細想,


是不是很多初學者都會碰到這種問題?

雖然阿里開發手冊裏面有說到這個坑,但是是不是每個人都清楚?

這個錯誤的出現原由是什麼?

怎麼避免?怎麼解決?

只能使用迭代器iterator 方式嗎?

removeAll ? stream?removeIf ?

 

這篇文章裏, 上面的種種疑問,都會涉及,但不限於。

因爲我經常寫着寫着就扯遠了,可能會說到一些其他東西。

 

正文

跟着我的思路走,耐心讀完,沒有收穫你直接打我。

 

有個list :

List<String> list = new ArrayList();
list.add("C");
list.add("A");
list.add("B");
list.add("C");
list.add("F");
list.add("C");
list.add("C");

[C, A, B, C, F, C, C]

怎麼移除掉list裏面的某個元素呢 ?      
list裏面給我們提供了4個方法 :

先看 remove ( Object o) :

 

這個方面字面意思看,就是,你想移除list裏面的哪個 Object ,你傳進來就可以。

看源碼,如下圖:

也就是說並不是想移除哪個傳哪個就能移除完, 而僅僅是隻移除首個符合規則的元素。

 

 

結合例子:

現在這個List裏面,存在4 個  "C" 元素 , 使用remove("C"):

        List<String> list = new ArrayList();
        list.add("C");
        list.add("A");
        list.add("C");
        list.add("B");
        list.add("F");
        list.add("C");
        list.add("C");
        
        System.out.println("未移除前"+list.toString());
        list.remove("C");
        System.out.println("移除後"+list.toString());

結果:

未移除前[C, A, C, B, F, C, C]
移除後[A, C, B, F, C, C]

所以,光這樣使用remove是不行的,不能實現我們需求 : 移除 list中的所有符合條件的元素,僅僅移除了符合條件的第一個 元素了。

 

這時候,大家可能就會想,那麼我們就循環+remove唄,這樣就能把每個符合條件的移除了。

真的嗎?

 

接着看。

 

循環 +   remove ( Object o)/ remove(Index i):

 

沒錯,我們可以配合循環,把list裏面的“C”元素都移除。

 

循環自然有分 while循環和 for循環(包含foreach) 。

 

先看  foreach方式  :

  不得行! 切記!

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

代碼看似沒問題,但是在foreach 使用 list的 remove / add 方法都是不行的!

報錯:

 

ConcurrentModificationException  :  併發異常 

 

PS: 其實如果大家曾閱讀過阿里的開發規範,也許會有一點印象。

7.【強制】不要在foreach循環裏進行元素的remove/add 操作。remove元素請使用Iterator方式,如果併發操作,需要對Iterator對象加鎖。

那麼先不管,如果你閱讀過,可能也不一定知道里面的原理,所以繼續往下看吧。

 

在分析這個錯誤前,我來提一嘴 ,一部分的ArrayList的特性:

ArrayList是基於數組實現的,是一個動態數組,其容量能自動增長。 

ArrayList不是線程安全的。

支持快速隨機訪問,通過下標序號index進行快速訪問。

接下來,跟着我,一起來分析這個報錯的出現 (當然我的挖錯方式不一定適合大家,但是也可以參考):

 

1. 分析出錯的代碼段 

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

光這樣看,我們只能知道,用了foreach的語法糖,那麼我們看編譯後的:

再看我們的報錯信息:

 

源碼分析:

通過我們反編譯的代碼,結合ArrayList的源碼, 我們可以知道, 

Itr 就是ArrayList裏面的內部類,

而foreach的語法糖其實就是幫我們 new了一下 Itr,先調用hashNext()
 

  while(var2.hasNext()) 

顯然是作爲循環的條件,那麼我們也一起來簡單看下這個方法源碼:

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

size是啥?

那cursor是啥?

所以,hashNext() 意思是, 當cursor 不等於 size的時候,代表 還有下一位,繼續循環就完事了,這個值其實不是本次報錯的重點。

 

我們繼續看 Itr的next()方法中的 checkForComodification()方法,就是這玩意導致報錯的。

那麼我們直接定位到  checkForComodification()方法的源碼:

 

代碼簡單, 也看到了我們剛纔報的錯 ConcurrentModificationException 在裏面躺着。

只要modCount 不等於 expectedModCount ,就拋錯。

那麼我們就得明白  modCount 和 expectedModCount是啥?

 expectedModCount簡單,是Itr裏的一個屬性 ,在初始化的時候,就已經把 modCount的值 等賦給了 expectedModCount。

其實 expectedModCount 就是用來記錄 一開始 迭代的 list的 變更數modCount, 至於 list的 變更數modCount是啥,我們接着看。

 

點進去看modCount的源碼:

可以看到作者真是苦口婆心,這麼一個字段屬性,人家寫了這麼多註釋, 那肯定是解釋得非常細緻了。

那麼我來抽出一些核心的 翻譯一下,給各位看看:

此列表在結構上被修改的次數。結構修改是指改變結構尺寸的修改。

如果此字段的值意外更改,則迭代器(或列表迭代器)將在

對{@code next}、{@code remove}、{@code previous}的響應,{@code set}或{@code add}操作。這提供了<i>快速失敗</i>行爲,而不是迭代過程中併發修改的情況。

我來簡單再說一下:

這個modCount,可以理解爲記錄list的變動值。 如果你的list裏面連續add 7個元素,那麼這個變動值就是7 . 如果是add 7個元素,remove 1個元素, 那麼這個值就是8 . 反正就是修改變動的次數的一個統計值。

而這個值,在使用迭代的時候,會在迭代器初始化傳入,賦值給到迭代器 Itr 裏面的內部記錄值 ,也就是我們剛剛講到的 expectedModCount 值 。 這樣來防止使用的時候,有意外的修改,導致併發的問題。

 

這麼一說,其實我們報錯ConcurrentModificationException  的原因就很明顯了。

一開始的情況:

所以在我們第一次循環檢測,使用foreach語法糖,調用  Itr的next()方法時,會去調用 check方法:

因爲確實一開始大家都是7,檢測modCount和 expectedModCount值是通過的:

接着,我們繼續觸發  Itr的next()方法,按照往常,也是調用了check方法,結果檢測出來初始化傳入的list變化記錄數expectedModCount是7,而 最新的list的變更記錄數modCount 因爲在第一次的list.remove觸發後,modCount++了,變成了8,所以: 

兩值不等, 拋出錯誤。

 

 

 所以上述出現報錯 ConcurrentModificationException 的原因非常明瞭, 其實就是因爲調用了 Itr的next()方法, 而next()方法每次執行時,會調check方法。 那麼可以理解爲,這是foreach語法糖+移除時的鍋。

 

那麼我們就避免這個語法糖 ,我們先來個習慣性編寫的for循環方式:

        List<String> list = new ArrayList();
        list.add("C");
        list.add("A");
        list.add("C");
        list.add("B");
        list.add("F");
        list.add("C");
        list.add("C");

        System.out.println("未移除前" + list.toString());
        int size = list.size();
        for (int i = 0; i < size; i++) {

         if ("C".equals(list.get(i))){
             list.remove("C");
         }

        }
        
        System.out.println("移除後" + list.toString());

這樣的執行結果是啥, 報錯了,IndexOutOfBoundsException 數組索引邊界異常:

爲啥會錯啊,原因很簡單:

所以這個示例報錯的原由很簡單,我編碼問題,把size值提前固定爲7了, 然後list的size是實時變化的。

 

那麼我把size不提前獲取了,放在for循環裏面。這樣就不會導致 i++使 i大於list的size了:
 

        List<String> list = new ArrayList();
        list.add("C");
        list.add("A");
        list.add("C");
        list.add("B");
        list.add("F");
        list.add("C");
        list.add("C");

        System.out.println("未移除前" + list.toString());

        for (int i = 0; i < list.size(); i++) {
            if ("C".equals(list.get(i))) {
                list.remove("C");
            }
        }
        System.out.println("移除後" + list.toString());

    }

這樣的運行結果是什麼:

雖然沒報錯,但是沒有移除乾淨,爲什麼?

其實還是因爲 list的size在真實的變動 。每次移除,會讓size的值 -1 , 而 i 是一如既往的 +1 .

而因爲ArrayList是數組, 索引是連續的,每次移除,數組的索引值都會 ’重新編排‘ 一次。

 

看個圖,我畫個簡單的例子給大家看看:
 

也就是說,其實每一次的remove變動, 因爲我們的循環 i值是一直 增加的,
所以會造成,我們想象的  數組內第二個 C 元素 的索引是 2, 當i爲2時會 拿出來檢測,這個假想時不對的。 

因爲如果 第二個 C 元素前面的 元素髮生了變化, 那麼它自己的索引也會往前 移動。

 

所以爲什麼會出現 移除不乾淨的 現象    
其實簡單說就是    最後一個C元素因爲前面的元素變動移除/新增,它的 index變化了。
然後i > list.size() 的時候就會 跳出循環, 而這個倒黴蛋 C元素排在後面,index值在努力往前移,而 i 值在變大, 但是因爲我們這邊是執行remove操作, list的size 在變小。

在 i值和 size值 兩個 交鋒相對的時候,最後一個C元素沒來得及匹對, i就已經大於 list.size ,導致循環結束了。

 

這麼說大家不知道能不能懂,因爲對於初學者來說,可能沒那麼快可以反應過來。

沒懂的兄弟,看我的文章,我決不會讓你帶着疑惑離開這篇文章,我再上個栗子,細說(已經理解的可以直接往下拉,跳過這段羅嗦的分析)。

 

上栗子:

我們的list 裏面 緊緊有 三個元素    "A"  "C"  "C" , 然後其餘的不變,也是循環裏面移除”C“ 元素 。

        List<String> list = new ArrayList();
        list.add("A");
        list.add("C");
        list.add("C");
        System.out.println("未移除前" + list.toString());

        for (int i = 0; i < list.size(); i++) {
            if ("C".equals(list.get(i))) {
                list.remove("C");
            }
        }
        System.out.println("移除後" + list.toString());

先看一下結果,還是出現移除不乾淨:

分析:

1. list的樣子:

2. 循環觸發,第一次 i 的值爲 0, 取出來的 元素是 A ,不符合要求:

3.繼續循環, 此時list的size值 依然是不變,還是 3 ,而i的值因爲 i++ 後變成了1 , 1 小於 3,條件符合,進入循環內,取出 list裏 index爲 1的元素:



4.這個 C符合要求, 被移除, 移除後,我們的 list狀態變成了:



5. 此時此刻 list的 size 是 2 ,再一輪for循環 ,  i 的值 i++ 後繼續變大,從1 變成了 2  2不小於 2 ,所以循環結束了。

但是我們這時候list裏面排在最後的那個C元素 原本index是 2,變成了index 1 ,這個傢伙 都還沒被 取出來, 循環結束了,它就逃過了檢測。 所以沒被移除乾淨。

 

PS: 很可能有些看客 心裏面會想(我YY你們會這麼想), 平時用的remove是利用index移除的, 跟我上面使用的 remove(Object o) 還不一樣的,是不是我例子的代碼使用方法問題。 

然而並不然,因爲這個remove調用的是哪個,其實不是重點,看圖:

結果還是一樣:

其實 這樣的for循環寫法, 跟  list的remove 到底使用的是 Object匹配移除 還是 Index移除 , 沒有關係的。 移除不乾淨是因爲 循環 i的值 跟 list的size變動 ,跳出循環造成的。

 

能看到這裏的兄弟, 辛苦了。

 

那麼 使用 remove 這個方法,結合循環,那就真的沒辦法 移除乾淨了嗎?

 

行得通的例子:

 

while循環 :

        List<String> list = new ArrayList();
        list.add("C");
        list.add("A");
        list.add("C");
        list.add("B");
        list.add("F");
        list.add("C");
        list.add("C");

        System.out.println("未移除前"+list.toString());
        while (list.remove("C"));
        System.out.println("移除後"+list.toString());

    }

結果,完美執行:

爲什麼這麼些 不會報ConcurrentModificationException錯,也不會報 IndexOutOfBoundsException 錯 呢?

我們看看編譯後的代碼:

可以看到時單純的調用list的remove方法而已,只要list裏面有"C",那麼移除返回的就是true,那麼就會繼續觸發再一次的remove(“C”),所以這樣下去,會把list裏面的“C”都移除乾淨,簡單看一眼源碼:

所以這樣使用是行得通的。

 

那麼當然還有文章開頭我給那位兄弟說的使用迭代器的方式動態刪除也是行得通的:


Iterator

        List<String> list = new ArrayList();

        list.add("C");
        list.add("A");
        list.add("B");
        list.add("C");
        list.add("F");
        list.add("C");
        list.add("C");

        System.out.println("未移除前" + list.toString());
        Iterator<String> it = list.iterator();
        while(it.hasNext()){
            String x = it.next();
            if("C".equals(x)){
                it.remove();
            }
        }
        System.out.println("移除後" + list.toString());

執行結果:

 

PS:

但是這個方式要注意的是, if判斷裏面的順序,
一定要注意把 已知條件值前置 :  "C".equals ( xxx) , 否則當我們的list內包含null 元素時, null是無法調用equals方法的,會拋出空指針異常。

 

 

 

那麼其實我們如果真的想移除list裏面的某個 元素,移除乾淨 。

我們其實 用removeAll ,就挺合適。

 

removeAll

list.removeAll(Collections.singleton("C"));
或者
list.removeAll(Arrays.asList("C"));


        List<String> list = new ArrayList();
        list.add("C");
        list.add("A");
        list.add("C");
        list.add("B");
        list.add("F");
        list.add("C");
        list.add("C");
        System.out.println("未移除前" + list.toString());
        list.removeAll(Collections.singleton("C"));
        System.out.println("移除後" + list.toString());

運行結果:

 

這裏使用removeAll ,我想給大家提醒一點東西 !

 

list.removeAll(Collections.singleton("C")); 

list.removeAll(Arrays.asList("C"));

這兩種寫法 運行移除 C 的時候都是沒問題的。

 

但是當list裏面有 null 元素的時候,我們就得多加註意了, 我們想移除null元素的時候 ,先回顧一下 remove這個小方法,沒啥問題,使用得當即可:

 

意思是,remove 可以移除 空元素,也就是 null 。

 

但是我們看看 removeAll :



也就是說如果list裏面有 null 元素, 我們又想使用removeAll, 怎麼辦?

首先我們使用  

list.removeAll(Collections.singleton(null));

運行結果,沒問題:

 

接着我們也試着使用

list.removeAll(Arrays.asList(null));

運行結果,報錯,空指針異常:

其實是因爲 Arrays.asList這個方法 , 請看源碼:

再看new ArrayList的構造方法,也是不允許爲空的:

 

PS: 但是這只是構造方法的規定,千萬別搞錯了 ,ArrayList是可以存儲 null 元素的 。 add(null) 可沒有說不允許null元素傳入。

 

回到剛剛的話題, 那麼我們運行沒有問題的 Collections.singleton(null) 怎麼就沒報 空指針異常呢?

那是因爲返回的是Set, 而 Set的構造方法也是允許傳入null的 :

所以在使用removeAll的時候,想移除 null 元素, 其實只需要傳入的集合裏面 是null 元素 就可以,也就是說,可以笨拙地寫成這樣,也是ok的 (瞭解原理就行,不是說非得這樣寫,因爲後面還有更好的方法介紹):

 

 

從一開始的 移除  C元素, 到 現在更特殊一點的移除 null 元素 。 

到這裏,似乎已經有了一個 瞭解和 了結, remove 和 removeAll使用起來應該是沒啥問題。

 

但是本篇文章還沒結束, 越扯越遠。

因爲我想給大家 瞭解更多,不廢話繼續說。

 

removeIf 

這個方法,是java  JDK1.8 版本, Collection以及其子類 新引入的 。 

那既然是新引入的,肯定也是有原因了,肯定是更加好用更加能解決我們移除list裏面的某元素的痛點了。

 

我們直接結合使用方式過一下這個方法吧:


移除list裏面的 null 元素 :

list.removeIf(Objects::isNull);

運行結果:

 

 

再來,我們寫的更加通用一點,還是移除 null 元素:
 

list.removeIf( o -> null ==o );

運行結果,沒有問題:

 

我們移除的條件更多一點 ,把C元素 和 null 元素 和  ”“ 元素都移除了 :
 

list.removeIf( o -> null ==o || o.equals("C") || o.equals(""));

運行結果,完美執行:

 

removeIf  我個人比較喜歡,推薦。

 

最後再說個移除某個元素的方法吧,  stream 流結合過濾器條件使用 :



其實跟上邊的removeIf差不多,只不過 stream流的filter 的用法是把 符合條件 的留下, 不符合條件的都去除  :

所以我們想把C元素 和 null 元素 和  ”“ 元素都移除了 ,要寫成:
 

List<String> listNew = list.stream().filter(  o ->   !(null == o || o.equals("C") || o.equals(""))  ).collect(Collectors.toList());

執行結果:

 

 

 

最後再羅嗦一下:

removeIf 、stream+filter 方式, 不僅僅侷限於 list 中使用 。

 

只要父類是   Collection<E> ,都可以用(所以處理map的時候可以把裏面的EntrySet取出來使用)。

 

SET :

Map:



 

 

 

好吧,該篇暫且就到此吧。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章