一個ConcurrentModificationException異常引發的故事

一、問題背景

 最近在分析一個客戶的實際問題中,從日誌中看到了一個類似如下的異常(圖A),實際異常棧信息量較多,由於涉及到產品代碼,所以不便在此貼出,圖A是異常棧的最底層拋異常的原因;程序並沒有對最上層那行業務代碼做try{}catch(),而該功能是會與第三方系統交互的,在最開始設計該功能時是分兩大塊處理的,每塊都有一個大的事務管理;因此該異常直接跑飛沒能反寫第一個事務已提交的數據,導致該業務數據對象狀態不正確,從而只能通過在後臺修改讓業務人員在重新操作,實際業務場景不便在此贅述。 

     圖A

 

二、要解決的問題

    問題本身是比較容易解決的,catch處後進行反寫操作,在拋  出異常即可,而真正要弄明白的問題是

  1、這個異常是什麼、通常是什麼場景下可能會出現?

2、該異常是否真的無法重現?

3、拋該異常的本質原因是什麼?拋這個異常的邏輯是?

4、能否用一段小程序通過調試的方式模擬重現?

5、產品當時併發是滿足了哪些因素,程序走到哪,進行了何種操

作這麼巧發生了?


三、解決疑問


   (1)這個異常是什麼、通常是什麼場景下可能會出現?

 百度可知該異常已屢見不鮮,不管是單線程還是多線程的情況都有,比如單線程下一個實際類型爲ArrayList的集合list裏面添加了5個元素,現對list進行迭代,中間做一個if判斷,判斷條件如迭代到第3個元素時,通過list.remove(obj)方式移除對象,在繼續迭代時就會報該異常這種場景是由代碼編寫有誤導致,正確做法應該是通過迭代器移除對象

 而多線程的場景,簡單看了其中一個示例則是把list定義爲靜態屬性,被不同線程操作併發導致的,比如一個線程正在對這個list做迭代操作,另一個線程通過list在做remove操作,這種情形也是有可能發生的 

 

(2)該異常是否真的無法重現?

 分析日誌後,已可以準確判斷是需要做數據修復的,修改其中一筆後,重新操作ok了,後將這些筆數據狀態修改後批量一次操作也是ok的,說明這是偶發現象,因爲平常客戶使用時也一直沒有問題。

 基於該異常的場景及該功能後臺代碼的分析,可以肯定是併發導致。

    爲啥這麼肯定?是不是看錯日誌了?

  1、通過客戶端操作日誌,服務端日誌和後臺數據發生時間,這三者的時間是極其吻合的並且報的異常棧      信息也是對應這個功能的,從業務者的角度操作 該業務數據對象,現象也是相符的  

  2、當時客戶操作的筆數是33筆,大於10筆客戶端會啓動多個線程的

3、日誌已鐵證如山的表明當時拋了異常

     可以想象當時一個線程在迭代HashMap中的元素時,另外一個線程對  HashMap做了某個操作導致,比如   remove操作;因此根據異常堆棧中涉及的流程性代碼,仔細的檢查了一遍,並沒有把map定義爲全局變量,也   沒有remove操作,只看到了list.addAll(<Map.Entry<Object,Object>>),並且Map.Entry<Object,Object>不   是靜態的,而是一個典型的成員變量.

     因此需要看HashMap的源碼仔細分析拋該異常的本質原因,是具體滿足了哪些因素後纔會拋該異常。


  (3)拋該異常的本質原因是什麼?拋這個異常的邏輯是?

    查看HashMap相關源碼可知,當迭代器迭代的時,會判斷modCount的值和expectedModCount的值是否相    等;如果相等則往下進行,如果不等則拋該異常,因此該異常的本質原因是這兩個值不等導致的。

    那麼modCount和expectedModCount這兩個值是在哪定義的呢?什麼時候發生變化的呢?

 分析調試源碼知:modCount是HashMap中的成員變量,expectedModCount是HashIterator中的一個成員變量,並且HashIterator是HashMap的內部類;搜索modCount在HashMap中引用可知,put、remove等操會讓modCount的值發生變化,而且變化指的就是自加1,從英文的表面意思上可以猜出這個變量設計的業務語義是表示HashMap修改的次數,所以只要HashMap這個容器往裏放對象或者是移除對象導致HashMap結構發生變化的操作都會使modCount的值就會加1,而迭代操作不會。

 expectedModCount的值則是在初始化迭代器對象HashIterator時,在構造函數就進行了modCount = expectedModCount的賦值,其次,是在使用迭代器移除map中的元素時又進行了賦值動作,在方法的結尾會將modCount賦值給expectedModCount,因爲HashIterator迭代器移除元素的具體實現就是通過調用了HashMap的移除方法,此時modCount會+1,因此對expectedModCount進行了重新賦值;而這是爲什麼在迭代過程中通過迭代器移除map元素不會報此異常,通過map本身的方式移除元素會報此異常的原因。


 (4)能否用一段小程序通過調試的方式模擬重現

  已模擬出,具體代碼不在此貼出了。


四、場景還原


  通過上述分析和模擬程序可知,當時拋該異常應該同時滿足如下3點:

  1、必須操作的是同一個map對象

  2、其中一個線程肯定在執行到了判斷modCount值與 expectedModCount是否相等此處(日誌可知此時是在做迭 代操作,此條件已滿足)

  3、另外一個線程同樣拿到該map對象進行了put,或者remove操作,導致modCount值發生了變化


   業務代碼並沒有寫,爲什麼會有迭代操作?

     從日誌分析可知是list在addAll時產生的,這是sun實現addAll方法時產生的。

  查看ArrayList中的addAll(Collection c)可知,實現Collection接口的類的對象都可以作爲參數傳入該方法   中,在ArrayList的addAll方法中,首先是要將c轉換成一個對象數組,Collection接口定義了該方法       toArray(),讓實現類去實現,在運行期在去真正調用實現類的該方法,通過日誌可知,我們傳進去的對象是   HashMap的內部類EntrySet<Map.Entry<Object,Object>>對象,而EntrySet是實現了Collection接口的,事實   上內部類EntrySet本身沒有重寫toArray()方法,而是他繼承的父類的父類AbstractCollection實現了該方     法,   具體如圖B;在AbstractCollection的toArray方法中首先是創建了一個數組對象,而其本身定義了一   個獲取迭   代器的抽象方法iterator,在運行期動態調用真正子類的iterator創建迭代器,具體如圖C

    

                            圖B(AbstractCollecton實現toArray)

  

                             圖C(子類創建迭代器)

  繼續看源碼可知,newEntryIterator 是創建了一個EntryIterator的迭代器對象,完成其父類和自己的初   始化工作,而EntryIterator是繼承自HashIterator的,並且這兩個類都是HashMap的內部類,HashIterator實   現了Iterator接口,因此在初始化EntryIterator時就連帶着初始化HashIterator並且是先初始化         HashIterator;在初始化HashIterator時expectedModCount就被賦值了,取的是當前HashMap對象的         modCount,modCount的值此時不一定是0,假如該對象已經put了2個值,那麼此時              expectedModCount=modCount=2,如expectedModCount初始化被賦值代碼如圖D

 

                              圖D

方法調用棧如圖E

                                                                    

                             圖E                                             

說明:<init>是對象初始化方法,<clinit>是存放靜態屬性或者方法塊的方法,這兩個方法是編譯器在編譯的時候產生的,具體是什麼條件下產生的在此就不在贅述了

     AbstractCollection拿到迭代器後,就開始迭代,將HashMap中的元素一個一個迭代出來保存到對象數組中,所以此時另外一個線程拿到該map對象,正好進行put操作,此時就拋異常了。

通過上述分析可以明白,list.addAll爲啥會產生知迭代操作了。


那麼是如何傳同一個map對象的呢?又是在什麼時候進行put操作的呢?

   分析調試相關基礎框架代碼可知,是同一個上下文導致了同一個map;基礎框架中通過SessionManager   來管理SessionInfo,存放SessionInfo的容器實際上是一個final static HashMap<String,SessionInfo>,不同   線程則是通過該對象進行信息交換和傳遞的;而上下文context是作爲SessionInfo的成員變量存在的,並且用   final進行修飾了;而我們最終關心的entrySet對應的Map則來於Context的一個普通的成員變量;因此不同線   程通過同一個sessionid就可以拿到同一個session,最終操作同一個map對象了。

  經實驗(調試)可知,在不退出客戶端的情況下,sessionid是相同的,並且多線程提交到服務端後取到   的sessionid,sessionInfo對象也是相同的,此處並沒有深究sessionid爲啥是相同的,但從業務語義來講在   客戶端未關閉前,每次拿到的sessionid是一樣的是比較合理。

  分析至此,離真相越來越近,下面則是什麼時候進行的put操作。

 從Context裏可知有一個put方法,而該方法是在unmarashel中調用,unmarshel則又是通過rpc調用遠程方   法    的時候調用,根據日誌的異常棧信息可知,當時確實進行了遠程調用,所以當時是一個線程調用遠程   方法後進行addAll中的迭代操作時,另一個線程也在調用遠程方法進行unmarashel的過程中進行了put操作,   導致併發產生,出現了該異常。

一切都是那麼不可思議,但確實又發生了。

 

                        

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