ConcurrentModificationException日誌關鍵字報警引發的思考

本文將記錄和分析日誌中的ConcurrentModificationException關鍵字報警,還有一些我的思考,希望對大家有幫助。

一、背景

近期,在日常的日誌關鍵字報警分析時,發現我負責的一個電商核心系統在某時段存在較多ConcurrentModificationException異常日誌,遂進行分析和改進,下面是我的一些思考。

1.1 系統架構

一直以來,無狀態的服務都被當作分佈式服務設計的最佳實踐。因爲無狀態的服務對於擴展性和運維方面有着得天獨厚的優勢,可以隨意地增加和減少節點。本系統的整體架構可以認爲是由一個MQ應用、一個RPC應用底層存儲組成。

RPC應用是無狀態服務,對外提供常用的查詢和操作接口;採用雙機房部署,每個機房10*8C16G;

MQ應用是無狀態服務,負責消費MQ消息,在消費過程中會調用該RPC應用提供方法;採用雙機房部署,每個機房5*8C16G;

底層存儲用的是數據庫集羣和緩存集羣,大概如圖所示:

1.2 關鍵代碼

MyRpcService 對外提供RPC服務,getList 方法可以根據入參中的狀態進行查詢,由於業務需要,需要對入參的狀態進行排序,實現部分關鍵代碼如下:

public class MyRpcServiceImpl implements MyRpcService{

    @Override
    public BaseResult getList(ListParam listParam) {

        BaseResult baseResult = new BaseResult();

        List<Integer> states = listParam.getStateList();

        // 省略大段代碼
        KeyUtil.getKeyString(states);
        // 省略大段代碼

        baseResult.setSuccess(true);

        return baseResult;
    }

}

KeyUtil 是一個工具類,getKeyString 方法對入參的itemList進行排序使用的是Java集合框架內置的sort 方法,代碼如下:

public class KeyUtil {

    public static String getKeyString(List<Integer> itemList) {
        String result = "";
        //省略代碼
        Collections.sort(itemList);
        //省略代碼
        return result;
    }

}

MyMqConsumer是MQ消費者,負責監聽消息進行消費。在消費邏輯中,會調用MyRpcServicegetList() 方法進行狀態查詢,因爲查詢的狀態是固定的,所以在Consumer類中定義了static final 類型的stateList ,關鍵代碼如下:

public class MyMqConsumer implements MessageListener{

    public static final List<Integer> stateList = Stream.of(1).collect(Collectors.toList());

    @Resource
    private MyRpcService myRpcService;

    @Override
    public void onMessage(List<Message> messageList) {

        // 省略代碼

        for (Message message : messageList) {

            // 省略其他代碼
            ListParam listParam = new ListParam();
            listParam.setStateList(stateList);
            BaseResult result = myRpcService.getList(listParam);
            // 省略其他代碼

        }

    }

}

二、  原因分析

看了上面的系統架構和關鍵代碼,不知道你有沒有發現問題?可以先拋開設計和代碼實現方面的問題不談,只看這樣的代碼能不能正常執行,得到正確的業務結果。

既然這麼問了,當然會有問題:在高併發環境下,MQ應用在消費消息時,調用RPC服務查詢時可能會拋出異常,從而觸發MQ異常重試,至於對業務有沒有影響,得具體問題具體分析了。

ERROR 執行流程時出錯
java.util.ConcurrentModificationException:null
at java.util.ArrayList.forEach(ArrayList.java:1260)~[:?1.8.0_192]
at com.shangguan.test.util.KeyUtil.getKeyString(KeyUtil.java:10)
...

2.1 分析1-ArrayList源碼

從日誌中可以看到,ConcurrentModificationExceptionjava.util.ArrayList類裏面的forEach方法拋出來的,源碼如下:

    @Override
    public void forEach(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        final int expectedModCount = modCount;
        @SuppressWarnings("unchecked")
        final E[] elementData = (E[]) this.elementData;
        final int size = this.size;
        for (int i=0; modCount == expectedModCount && i < size; i++) {
            action.accept(elementData[i]);
        }
        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
    }

在該方法中,內部會維護一個expectedModCount變量,賦值爲modCount,在每次迭代過程中,迭代器會檢查expectedModCount是否等於當前的modCount。如果不等,說明在迭代過程中ArrayList的結構發生了修改,迭代器會拋出ConcurrentModificationException異常。這種設計可以確保在多線程環境下,當一個線程修改ArrayList時,其他線程在迭代過程中可以立即發現這種修改,從而避免潛在的數據不一致問題。

再可以看下源碼中modCount的註釋,大意是:

modCount表示ArrayList自從創建以來結構上發生的修改次數。結構修改是指改變列表大小的修改,或者以其他方式擾亂列表,使正在進行的迭代可能產生不正確的結果。

modCount字段用於iteratorlistIterator方法返回的迭代器(或列表迭代器)。如果這個字段的值在迭代過程中發生意外的變化,迭代器(或列表迭代器)將在next、remove、previous、set或add操作時拋出ConcurrentModificationException異常。這提供了fail-fast(快速失敗)行爲,而不是在迭代過程中遇到併發修改時具有不確定性。

子類可以選擇使用這個字段。如果子類希望提供fail-fast迭代器(和列表迭代器),那麼它只需在其add(int, E)remove(int)方法(以及覆蓋的任何其他導致列表結構修改的方法)中遞增此字段。單次調用add(int, E)remove(int)應該在此字段上增加不超過1次,否則迭代器(和列表迭代器)將拋出虛假的ConcurrentModificationException。如果實現不希望提供fail-fast迭代器,可以忽略此字段。

2.2 分析2-線程安全問題

有個有趣的現象是,這個異常日誌僅存在MQ應用中,這是爲什麼呢?

這其實是一個多線程問題。我們知道,static對象是在類加載時創建的全局對象,它們的生命週期與類的生命週期相同。static對象在程序啓動時創建,在程序結束時銷燬。這意味着static對象在多個線程之間共享的,可能存在線程安全問題。

翻回去仔細看下代碼,可以看到MyMqConsumer定義的stateList是static類型的,是否是否存在線程安全問題呢?

在流量較低的情況下,多個消息不在同一時刻到達,每個線程處理消息將不會爭奪static對象,所以不會有問題;

當流量較大情況下,有多個消息可能在同一時刻到達,每個線程處理過程中都會對stateList進行賦值,調用遠程RPC接口,它們之間將會爭奪static對象,可能存在問題。例如上圖中右半部分,線程1還沒有處理完消息1時,線程2就開始爭搶,那麼就可能使ArrayList中modCount != expectedModCount條件滿足,從而拋出異常。

三、改進思考

3.1 本問題的優化

經過上述分析,已經清楚問題的產生原因了。對於本問題的優化,其實也比較簡單。有如下兩種方式可供選擇:

1.  在MyMqConsumer調用RPC查詢的入參,使用new List來替代原來的類中定義好的static對象;

2.  修改KeyUtil代碼,淺拷貝傳入的itemList,再進行排序

3.2 類似問題的發現和改進

本問題已經修復,那類似的問題是否可以避免或者減少,將是接下來值得思考的一個問題。爲了減少這類問題發生,我結合平時工作過程中的幾個階段,認爲可以從以下幾個方面進行改進:

  • 開發

開發過程中,開發人員需要提升認知和水平,注意代碼中可能存在的線程問題;注意編寫單元測試,可以通過模擬多線程環境來檢測潛在的問題。

  • 代碼評審

開發完成的代碼一定需要進行代碼評審,評審過程中架構師需要發揮自己豐富的開發經驗和較強的代碼直覺,“火眼金睛”,發現代碼中的漏洞;當然這對評審人員的要求很高,因爲僅通過改動的幾行代碼發現問題確實是一件很有挑戰的事情。如果要有一些自動化工具或者插件,則可以起到事半功倍的效果。這裏其實我還沒有調研相關的工具,如果有大佬有相關經驗歡迎評論交流。

  • 測試

測試階段除了驗證正常的業務功能,還需要進行集成測試和性能測試。在集成測試中,將多個模塊組合在一起,測試整個系統在多線程環境中的行爲,有助於發現模塊之間的交互問題。除了繼承測試,有時還需要性能測試,性能測試可以發現潛在的競爭條件、死鎖、資源爭用等多線程問題。

四、小結

最後,我簡單總結一下本文內容。本文主要記錄和分析日誌中的ConcurrentModificationException關鍵字報警,首先介紹了系統整體架構和關鍵代碼;然後從ArrayList源碼和線程安全兩個方面分析問題產生原因,最後我提出了修復該問題的方案和類似問題的思考,希望對大家有幫助。

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