本文將記錄和分析日誌中的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消費者,負責監聽訊息進行消費。在消費邏輯中,會呼叫MyRpcService
的getList()
方法進行狀態查詢,因為查詢的狀態是固定的,所以在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原始碼
從日誌中可以看到,ConcurrentModificationException
是java.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
欄位用於iterator
和listIterator
方法返回的迭代器(或列表迭代器)。如果這個欄位的值在迭代過程中發生意外的變化,迭代器(或列表迭代器)將在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原始碼和執行緒安全兩個方面分析問題產生原因,最後我提出了修復該問題的方案和類似問題的思考,希望對大家有幫助。