一、犯錯經歷
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
原始碼中ArrayList
的remove()
原始碼是怎麼實現的:
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
迴圈實際執行
通過編譯程式碼可以看到:增強 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()
方法的原始碼,會發現其實底層也是用的 Iterator
的remove()
方法:
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()
,多問幾個為什麼,估計自己會比現在優秀很多吧。
當然,只要意識到這個,什麼時候都不算晚,共勉!