三種騷操作繞過迭代器遍歷時的資料修改異常

未讀程式碼發表於2021-02-19

前言

既然是繞過迭代器遍歷時的資料修改異常,那麼有必要先看一下是什麼樣的異常。如果在集合的迭代器遍歷時嘗試更新集合中的資料,比如像下面這樣,我想輸出 Hello,World,Java,迭代時卻發現多了一個 C++ 元素,如果直接刪除掉的話。

List<String> list = new ArrayList<>();
Collections.addAll(list, "Hello", "World", "C++", "Java");
// 我想輸出 Hello,World,Java,迭代時發現多一個 C++,所以直接刪除掉。
Iterator iterator = list.iterator();
System.out.println(iterator.next());
System.out.println(iterator.next());
list.remove("C++");
System.out.println(iterator.next());

那麼我想你一定會遇到一個異常 ConcurrentModificationExceptio

Hello
World

java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:907)
	at java.util.ArrayList$Itr.next(ArrayList.java:857)
	at com.wdbyte.lab.jdk.ModCountDemo.updateCollections(ModCountDemo.java:26)

這個異常在剛開始學習 Java 或者使用其他的非執行緒安全的集合過程中可能都有遇到過。導致這個報錯出現的原因就和我們操作的一樣,對於某些集合,不建議在遍歷時進行資料修改,因為這樣會資料出現不確定性。

那麼如何繞過這個錯誤呢?這篇文章中腦洞大開的三種方式一定不會讓你失望。

異常原因

這不是一篇原始碼分析的文章,但是為了介紹繞過這個異常出現的原因,還是要提一下的,已經知道的同學可以直接跳過。

根據上面的報錯,可以追蹤到報錯位置 ArrayList.java 的 857 行和 907 行,追蹤原始碼可以發現在迭代器的 next 方法的第一行,呼叫了 checkForComodification() 方法。

迭代器 next 原始碼

而這個方法直接進行了一個把變數 modCountexpectedModCount 進行了對比,如果不一致就會丟擲來 ConcurrentModificationException 異常。

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

那麼 modCount 這個變數儲存的是什麼資訊呢?

/**
 * The number of times this list has been <i>structurally modified</i>.
 * Structural modifications are those that change the size of the
 * list, or otherwise perturb it in such a fashion that iterations in
 * progress may yield incorrect results.
 *
 * <p>This field is used by the iterator and list iterator implementation
 * returned by the {@code iterator} and {@code listIterator} methods.
 * If the value of this field changes unexpectedly, the iterator (or list
 * iterator) will throw a {@code ConcurrentModificationException} in
 * response to the {@code next}, {@code remove}, {@code previous},
 * {@code set} or {@code add} operations.  This provides
 * <i>fail-fast</i> behavior, rather than non-deterministic behavior in
 * the face of concurrent modification during iteration.
 *
 * <p><b>Use of this field by subclasses is optional.</b> If a subclass
 * wishes to provide fail-fast iterators (and list iterators), then it
 * merely has to increment this field in its {@code add(int, E)} and
 * {@code remove(int)} methods (and any other methods that it overrides
 * that result in structural modifications to the list).  A single call to
 * {@code add(int, E)} or {@code remove(int)} must add no more than
 * one to this field, or the iterators (and list iterators) will throw
 * bogus {@code ConcurrentModificationExceptions}.  If an implementation
 * does not wish to provide fail-fast iterators, this field may be
 * ignored.
 */
protected transient int modCount = 0;

直接看原始碼註釋吧,直接翻譯一下意思就是說 modCount 數值記錄的是列表的結構被修改的次數,結構修改是指那些改變列表大小的修改,或者以某種方式擾亂列表,從而使得正在進行的迭代可能產生不正確的結果。同時也指出了這個欄位通常會在迭代器 iterator 和 listIterator 返回的結果中使用,如果 modCount 和預期的值不一樣,會丟擲 ConcurrentModificationException 異常。

而上面與 modCount 進行對比的欄位 expectedModCount 的值,其實是在建立迭代器時,從 modCount 獲取的值。如果列表結構沒有被修改過,那麼兩者的值應該是一致的。

繞過方式一:40 多億次迴圈繞過

上面分析了異常產生的位置和原因,是因為 modCount 的當前值和建立迭代器時的值有所變化。所以第一種思路很簡單,我們只要能讓兩者的值一致就可以了。在原始碼 int modCount = 0; 中可以看到 modCount 的資料型別是 INT ,既然是 INT ,就是有資料範圍,每次更新列表結構 modCount 都會增1,那麼是不是可以增加到 INT 資料型別的值的最大值溢位到負數,再繼續增加直到變回原來的值呢?如果可以這樣,首先要有一種操作可以在更新列表結構的同時不修改資料。為此翻閱了原始碼尋找這樣的方法。還真的存在這樣的方法。

public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
          ? EMPTY_ELEMENTDATA
          : Arrays.copyOf(elementData, size);
    }
}

上來就遞增了 modCount,同時沒有修改任何資料,只是把資料的儲存進行了壓縮。

List<String> list = new ArrayList<>();
Collections.addAll(list, "Hello", "World", "C++", "Java");

list.listIterator();
Iterator iterator = list.iterator();
System.out.println(iterator.next());
System.out.println(iterator.next());
list.remove("C++");
// 40 多億次遍歷,溢位到負數,繼續溢位到原值
for (int n = Integer.MIN_VALUE; n < Integer.MAX_VALUE; n++) ((ArrayList) list).trimToSize();
System.out.println(iterator.next());

正確輸出了想要的 Hello,World,Java

繞過方式二:執行緒加物件鎖繞過

分析一下我們的程式碼,每次輸出的都是 System.out.println(iterator.next());。可以看出來是先執行了迭代器 next 方法,然後才執行了System.out 進行輸出。所以第二種思路是先把第三個元素C++ 更新為Java ,然後啟動一個執行緒,在迭代器再次呼叫 next 方法後,把第四個元素移除掉。這樣就輸出了我們想要的結果。

List<String> list = new ArrayList<>();
Collections.addAll(list, "Hello", "World", "C++", "Java");

list.listIterator();
Iterator iterator = list.iterator();
System.out.println(iterator.next());
System.out.println(iterator.next());

// 開始操作
list.set(2, "Java");
Phaser phaser = new Phaser(2);
Thread main = Thread.currentThread();
new Thread(() -> {
    synchronized (System.out) {
        phaser.arriveAndDeregister();
        while (main.getState() != State.BLOCKED) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        list.remove(3);
    }
}).start();
phaser.arriveAndAwaitAdvance();

System.out.println(iterator.next());

// 輸出集合
System.out.println(list);

/**
 * 得到輸出
 * 
 * Hello
 * World
 * Java
 * [Hello, World, Java]
 */

正確輸出了想要的 Hello,World,Java 。這裡簡單說一下程式碼中的思路,Phaser 是 JDK 7 的新增類,是一個階段執行處理器。構造時的引數 parties 的值為2,說明需要兩個參與方完成時才會進行到下一個階段。而 arriveAndAwaitAdvance 方法被呼叫時,可以讓一個參與方到達。

所以執行緒中對 System.out 進行加鎖,然後執行 arriveAndAwaitAdvance 使一個參與方報告完成,此時會阻塞,等到另一個參與方報告完成後,執行緒進入到一個主執行緒不為阻塞狀態時的迴圈。

這時主執行緒執行 System.out.println(iterator.next()); 。獲取到迭代器的值進行輸出時,因為執行緒內的加鎖原因,主執行緒會被阻塞。知道執行緒內把集合的最後一個元素移除,執行緒處理完成才會繼續。

繞過方式三:利用型別擦除放入魔法物件

在建立集合的時候為了減少錯誤概率,我們會使用泛型限制放入的資料型別,其實呢,泛型限制的集合在執行時也是沒有限制的,我們可以放入任何物件。所以我們可以利用這一點做些文章。

List<String> list = new ArrayList<>();
Collections.addAll(list, "Hello", "World", "C++", "Java");

list.listIterator();
Iterator iterator = list.iterator();
System.out.println(iterator.next());
System.out.println(iterator.next());

// 開始操作
((List)list).set(2, new Object() {
    public String toString() {
        String s = list.get(3);
        list.remove(this);
        return s;
    }
});

System.out.println(iterator.next());

程式碼裡直接把第三個元素放入了一個魔法物件,重寫了 toString() 方法,內容是返回集合的第四個元素,然後刪除第三個元素,這樣就可以得到想要的 Hello,World,Java 輸出。

上面就是繞過迭代器遍歷時的資料修改報錯的三種方法了,不管實用性如何,我覺得每一種都是大開腦洞的操作,這些操作都需要對某個知識點有一定的瞭解,關注我,瞭解更多稀奇古怪的開發技巧

參考

[1] https://www.javaspecialists.eu/archive/Issue186-Iterator-Quiz.html

訂閱

文章已經收錄到 github.com/niumoo/javanotes

也可以關注我的部落格或者微信搜尋「 未讀程式碼 」。

文章會在部落格和公眾號同步更新。

公眾號

相關文章