【代碼優化】List.remove() 剖析


一、犯錯經歷

1.1 故事背景

最近有個需求大致的背景類似:

我已經通過一系列的操作拿到一批學生的考試成績數據,現在需要篩選成績大於 95 分的學生名單。

善於寫 bug 的我,三下五除二完成了代碼的編寫:

@Test
public void shouldCompile() {
    for (int i = 0; i < studentDomains.size(); i++) {
        if (studentDomains.get(i).getScore() < 95.0) {
            studentDomains.remove(studentDomains.get(i));
        }
    }
    System.out.println(studentDomains);
}

測試數據中四個學生,成功篩選出了兩個 95 分以上的學生,測試成功,打卡下班。

[StudentDomain{id=1, name='李四', subject='科學', score=95.0, classNum='一班'}, StudentDomain{id=1, name='王六', subject='科學', score=100.0, classNum='一班'}]

1.2 貌似,下不了班!

從業 X 年的直覺告訴我,事情沒這么簡單。

但是自測明明沒問題,難道寫法有問題?那我換個寫法(增強的 for 循環):

@Test
public void commonError() {
    for (StudentDomain student : studentDomains) {
        if (student.getScore() < 95.0) {
            studentDomains.remove(student);
        }
    }
    System.out.println(studentDomains);
}

好家伙,這一試不得了,直接報錯:ConcurrentModificationException

  • 普通 for 循環“沒問題”,增強 for 循環有問題,難道是【增強 for 循環】的問題?

1.3 普通 for 循環真沒問題嗎?

為了判斷普通 for 循環是否有問題,我將原代碼加了執行次數的打印:

@Test
public void shouldCompile() {
    System.out.println("studentDomains.size():" + studentDomains.size());
    int index = 0;
    for (int i = 0; i < studentDomains.size(); i++) {
        index ++;
        if (studentDomains.get(i).getScore() < 95.0) {
            studentDomains.remove(studentDomains.get(i));
        }
    }
    System.out.println(studentDomains);
    System.out.println("執行次數:" + index);
}

這一加不得了,我的 studentDomains.size() 明明等於 4,怎么循環體內只執行了 2 次。

更巧合的是:執行的兩次循環的數據,剛好都符合我的篩選條件,故會讓我錯以為【需求已完成】。

二、問題剖析

一個個分析,我們先看為什么普通 for 循環比我們預計的執行次數要少。

2.1 普通 for 循環次數減少

這個原因其實稍微有點兒開發經驗的人應該都知道:在循環中刪除元素后,List 的索引會自動變化,List.size() 獲取到的 List 長度也會實時更新,所以會造成漏掉被刪除元素后一個索引的元素。

比如:循環到第 1 個元素時你把它刪了,那么第二次循環本應訪問第 2 個元素,但這時實際上訪問到的是原來 List 的第 3 個元素,因為第 1 個元素被刪除了,原來的第 3 個元素變成了現在的第 2 個元素,這就造成了元素的遺漏。

2.2 增強 for 循環拋錯

  • 我們先看 JDK 源碼中 ArrayListremove() 源碼是怎么實現的:
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;
}

只要不為空,程序的執行路徑會走到 else 路徑下,最終調用 fastRemove() 方法:

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

fastRemove() 方法中,看到第 2 行【把 modCount 變量的值加 1】。

  • 增強 for 循環實際執行

01

通過編譯代碼可以看到:增強 for 循環在實際執行時,其實使用的是Iterator,使用的核心方法是 hasnext()next()

next() 方法調用了 checkForComodification()

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

看到 throw new ConcurrentModificationException() 那么就可以結案了:

因為上面的 remove() 方法修改了 modCount 的值,所以這里肯定會拋出異常。

三、正確方式

既然知道了普通 for 循環和增強 for 循環都不能用的原因,那么我們先從這兩個地方入手。

3.1 優化普通 for 循環

我們知道使用普通 for 循環有問題的原因是因為數組坐標發生了變化,而我們仍使用原坐標進行操作。

  • 移除元素的同時,變更坐標。
@Test
public void forModifyIndex() {
    for (int i = 0; i < studentDomains.size(); i++) {
        StudentDomain item = studentDomains.get(i);
        if (item.getScore() < 95.0) {
            studentDomains.remove(i);
            // 關鍵是這里:移除元素同時變更坐標
            i = i - 1;
        }
    }
    System.out.println(studentDomains);
}
  • 倒序遍歷

采用倒序的方式可以不用變更坐標,因為:后一個元素被移除的話,前一個元素的坐標是不受影響的,不會導致跳過某個元素。

@Test
public void forOptimization() {
    List<StudentDomain> studentDomains = genData();
    for (int i = studentDomains.size() - 1; i >= 0; i--) {
        StudentDomain item = studentDomains.get(i);
        if (item.getScore() < 95.0) {
            studentDomains.remove(i);
        }
    }
    System.out.println(studentDomains);
}

3.2 使用 Iterator 的 remove()

@Test
public void iteratorRemove() {
    Iterator<StudentDomain> iterator = studentDomains.iterator();
    while (iterator.hasNext()) {
        StudentDomain student = iterator.next();
        if (student.getScore() < 95.0) {
            iterator.remove();
        }
    }
    System.out.println(studentDomains);
}

你肯定有疑問,為什么迭代器的 remove() 方法就可以呢,同樣的,我們來看看源碼:

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

我們可以看到:每次執行 remove() 方法的時候,都會將 modCount 的值賦值給 expectedModCount,這樣 2 個變量就相等了。

3.3 Stream 的 filter()

了解 Stream 的童鞋應該都能想到該方法,這里就不過多贅述了。

@Test
public void streamFilter() {
    List<StudentDomain> studentDomains = genData();
    studentDomains = studentDomains.stream().filter(student -> student.getScore() >= 95.0).collect(Collectors.toList());
    System.out.println(studentDomains);
}

3.4 Collection.removeIf()【推薦】

JDK1.8 中,Collection 以及其子類新加入了 removeIf() 方法,作用是按照一定規則過濾集合中的元素。

@Test
public void removeIf() {
    List<StudentDomain> studentDomains = genData();
    studentDomains.removeIf(student -> student.getScore() < 95.0);
    System.out.println(studentDomains);
}

看下 removeIf() 方法的源碼,會發現其實底層也是用的 Iteratorremove() 方法:

default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean removed = false;
    final Iterator<E> each = iterator();
    while (each.hasNext()) {
        if (filter.test(each.next())) {
            each.remove();
            removed = true;
        }
    }
    return removed;
}

四、總結

詳細認真的看完本文的話,最大感悟應該是:還是源碼靠譜!

4.1 啰嗦幾句

其實在剛從事 Java 開發的時候,這個問題就困擾過我,當時只想着解決問題,所以采用了很笨的方式:

新建一個新的 List,遍歷老的 List ,將滿足條件的元素放到新的元素中,這樣的話,最后也完成了當時的任務。

現在想一想,幾年前,如果就像現在一樣,抽空好好想想為什么不能直接 remove() ,多問幾個為什么,估計自己會比現在優秀很多吧。

當然,只要意識到這個,什么時候都不算晚,共勉!

4.2 文中代碼示例

Github/vanDusty


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM