一、问题背景
最近在分析一个客户的实际问题中,从日志中看到了一个类似如下的异常(图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(
因此需要看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
图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
(说明:
AbstractCollection拿到迭代器后,就开始迭代,将HashMap中的元素一个一个迭代出来保存到对象数组中,所以此时另外一个线程拿到该map对象,正好进行put操作,此时就会抛异常了。
通过上述分析可以明白,list.addAll为啥会产生知迭代操作了。
那么是如何传同一个map对象的呢?又是在什么时候进行put操作的呢?
分析调试相关基础框架代码可知,是同一个上下文导致了同一个map;基础框架中通过SessionManager 来管理SessionInfo,存放SessionInfo的容器实际上是一个final static HashMap
经实验(调试)可知,在不退出客户端的情况下,sessionid是相同的,并且多线程提交到服务端后取到 的sessionid,sessionInfo对象也是相同的,此处并没有深究sessionid为啥是相同的,但从业务语义来讲在 客户端未关闭前,每次拿到的sessionid是一样的是比较合理。
分析至此,离真相越来越近,下面则是什么时候进行的put操作。
从Context里可知有一个put方法,而该方法是在unmarashel中调用,unmarshel则又是通过rpc调用远程方 法 的时候调用,根据日志的异常栈信息可知,当时确实进行了远程调用,所以当时是一个线程调用远程 方法后进行addAll中的迭代操作时,另一个线程也在调用远程方法进行unmarashel的过程中进行了put操作, 导致并发产生,出现了该异常。
一切都是那么不可思议,但确实又发生了。