【拾遺補缺】java ArrayList的不當使用致使的ConcurrentModificationException問題

今天組內的一個同窗碰到一個併發問題,幫忙看了下。是個比較小的點,但因爲以前沒碰到過因此也沒特地瞭解過這塊,今天既然看了就沉澱下來。java

原始問題是看到日誌裏有一些零星的異常,以下如所示apache

clipboard.png

根據堆棧信息,能夠很快定位到對應的應用代碼,同時根據異常的描述,能夠初步定爲是併發訪問ArrayList形成的。安全

相關應用代碼以下(也就是堆棧第三行的CommonUtil.getItemFromList)
clipboard.png多線程

這裏的list是由上層邏輯傳入的併發

clipboard.png

clipboard.png

提到Collection的遍歷,第一時間想到兩種可能性(非針對java,只是通常性的想法):dom

  • 迭代器內部會保存當前的遍歷位置,那麼多個線程同時遍歷時遍歷位置屬於共享變量,會致使多線程問題ide

  • 在一個線程遍歷過程當中,List被其餘線程修改,致使List長度產生變化函數

多線程遍歷安全

對於以上兩個可能性,其實只要稍加思考,就能想到第一個可能性是不太可能的,由於是java基本要保證的。經過查看ArrayList的源碼也基本肯定了這個點。oop

ArrayList中有三個迭代器相關的函數,返回兩種迭代器實現,分別是ListIterator和Iterator。看名字就知道前者只能用於List的遍歷,後者可用於全部Collection的遍歷,對於for循環來講,使用的是後者。這點參考這兩個頁面。this

http://beginnersbook.com/2014...

https://stackoverflow.com/que...

Iterator相關代碼以下

clipboard.png

clipboard.png

從這裏就能夠看出來,多線程遍歷同一個List是安全的。由於迭代器是在每次for循環(調用iterator)時生成的實例,每次實例獨立保存當前的遍歷進度(圖中的cursor字段),這樣每一個線程在遍歷時只會修改本身線程所建立的Itr對象,沒有共享變量被修改。

遍歷中修改不安全

排除了上面這種可能性,問題由於基本就定位了。

根據堆棧信息找到出錯的地方

clipboard.png

clipboard.png

clipboard.png

能夠看到,List保證其遍歷時不被修改,採用的是用一個計數器的機制。

在開始遍歷前,先記錄當前的modCount值

clipboard.png

然後每次訪問下一個元素以前,都會檢查下modCount值是否變化,若是有變化,說明List的長度有變化。一旦長度有變化,就會拋出ConcurrentModificationException異常。

modCount的註釋詳細說明了這個字段代表List發生結構性變化(長度被修改)的次數,也就是刪除插入等操做時,這個字段要加一。有興趣的讀者能夠自行搜索下ArrayList代碼,看看哪些操做會引發modCount的變化。

定位罪魁禍首

明確了緣由,找具體代碼問題的時候反而有些波折。由於從代碼看這個循環並無什麼特別,同事一直說是和反射有關(反射內部有時候會對類的某些字段的可訪問標進行修改),但我本身跟了代碼並無發現什麼可疑的地方,無奈寫了個小demo驗證下。

public class MultiThreadArrayListThread {

    public static List list = new ArrayList();
    public static Random random = new Random(System.currentTimeMillis());

    public static class TestBean {
        private Integer value;

        public Integer getValue() {
            return value;
        }

        public void setValue(Integer value) {
            this.value = value;
        }
    }

    public static class TestThread extends Thread {

        @Override
        public void run() {
            for (Object o : list) {
                /*if (Thread.currentThread().getName().equals("1")) {
                    list.add(new TestBean());
                }*/
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + org.apache.commons.beanutils.BeanUtils.getProperty(o, "value"));
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                } catch (NoSuchMethodException e) {
                    e.printStackTrace();
                }
                try {
                    Thread.sleep(random.nextInt(100));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        int i = 0;
        while (i < 100) {
            TestBean testBean = new TestBean();
            testBean.setValue(i);
            list.add(testBean);
            i++;
        }

        int thread = 0;
        while (thread < 20) {
            TestThread testThread = new TestThread();
            testThread.setName(String.valueOf(thread));
            testThread.start();
            thread++;
        }
    }
}

上述代碼執行後並無報錯,只有在註釋掉的add操做打開後,纔會拋異常。

clipboard.png

這個demo進一步驗證了本身對於異常緣由的認知,同時也說明了反射的確不會影響List的遍歷。所以個人注意力從這段代碼中移開,轉而關注List的獲取。

這下發現問題所在了。

clipboard.png

這裏同事犯了個低級錯誤。這段代碼的邏輯是有ABCD四個配置信息,要返回這四個配置信息的並集。但同事的代碼直接在第一個List中添加後幾個List的元素了。因爲引用是同一個,所以出現了線程a在執行完這段邏輯拿到一個List(其中包含A+B+C+D)並開始遍歷時,線程b開始執行這段邏輯。此時線程a和線程b拿到的實際上是同一個List引用(最開始的A),而且在線程a遍歷時線程b對其進行了修改(add(B/C/D)),所以會觸發線程a拋異常。不只如此,哪怕不拋異常,每次業務要去拿這個配置文件,都會在該集合中加入BCD的元素,集合元素會遞增(A -> ABCD -> ABCDBCD -> ABCDBCDBCD …),一直運行會致使OOM!

定位到問題後修復就很簡單了,每次獲取配置時new一個新的List便可。

ArrayList list = new ArrayList();
list.add(A);
list.add(B);
list.add(C);
list.add(D);

至此問題順利結局~

小結

這個問題最終定位到是一個低級的代碼錯誤,但過程仍是值得記錄下的。本身雖在java這方面工做數年,但像modCount這種機制,要是沒有遇到特定的問題仍是沒可能面面俱到每一個小點都關注到的。今天碰到的這個小case正好幫助本身拾遺補缺,相信之後碰到ArrayList相關的問題,會更容易解決~

相關文章
相關標籤/搜索