排查一则生产环境报 ConcurrentModificationException 的问题。

在相对复杂的工业代码中(绕来绕去,不是简单的一个函数调用),排查「简单问题」也不是一件简单的事。

记录本文的原因:

  • 个人觉得有点意思(头一回线上碰到这个异常);
  • 此类排查过程对个人有些许收获;

背景

线上报警,对应异常栈:

1
2
3
4
5
6
7
8
nullclass java.util.ConcurrentModificationException:null 
java.util.HashMap$HashIterator.nextNode(HashMap.java:1442) 
java.util.HashMap$EntryIterator.next(HashMap.java:1476) 
java.util.HashMap$EntryIterator.next(HashMap.java:1474) 
SMSServiceImpl.send(SMSServiceImpl.java:101) 
SendSmsEmailService.sendSmsWithTemplate(SendSmsEmailService.java:55) 
VerifyCodeService.sendVerifyCode(VerifyCodeService.java:103)
ContractController.sendVerifyCodeForSignPermission

分析

ConcurrentModificationException 表示对集合的操作出现了并发修改异常【我在遍历集合元素的时候,有人改了集合元素,导致我遍历获取下个元素出错】。

既然异常栈都给出来了,那么我们去看下代码。这里跳过代码详细截图,下图是我梳理出的脱敏流程以及关键代码:

简单解释下这个过程: 在 VerifyCodeService.sendVerifyCode 方法中首先构建了一个 vars map,然后丢进了一个线程池中发邮件,同时主线程开始发送短信。 在发邮件的操作中,经过层层透传,map引用被传到了Velocity模板中,其中对map有 put remove 等增删操作。

问题很明显了,两个线程,一个遍历读,一个并发写,自然就会抛 ConcurrentModificationException

解决

解决方式想到了两种:

  1. Map 传入子方法时,拷贝一个新的对象实例;
  2. Map使用 ConcurrentHashMap 类型;

对比之下,此处的场景中我倾向于在向线程池传入map时拷贝一个新的对象,优点如下:

  • 开销不会太大(map仅承载了邮件、短信的消息、收发人信息);
  • 数据能否确保多线程间的数据一致性,异步流程仅使用快照,对主线程无任何影响;
  • 语义明确,此时我就是要在主线程主流程代码中能看清楚,往线程池传的是一个快照;

使用 ConcurrentHashMap 也可以修复此问题,但我觉得有如下缺点:

  • 此场景无需使用 ConcurrentHashMap 对并发操作的实时性,异步线程改了数据其实在主线程中完全用不到;
  • Api 相较 HashMap 复杂,容易踩坑新的问题;

踩的坑

排查这个问题最开始我陷入了第一个子流程中的代码(对应上图中的红圈1),盯着发短信的代码中遍历的片段迟迟不得其解。

直到我出去上了个厕所,点出去看了下 VerifyCodeService.sendVerifyCode 中的整体流程,才发现发短信使用了异步的线程池。此时才打开了正确的大门。 当然进入这个方法,问题也没有那么快解开,正如我图中所画,第二个子流程(对应上图中的红圈2)中代码嵌套很深,层层进入分析了片刻,才找到了修改原map引用的具体位置。

这里对我的启发:

  • 排查问题切不可陷入细节,要先有全局的视野,再有细节;

总结

此并发修改异常源于我们的问题代码中使用了线程池负责发短信子流程,其中对入参的map进行了并发修改,导致主线程中遍历map时出现了不一致的情况。

问题也好修复,如上所说,我推荐传入线程池时拷贝一个新的map实例。