想到自己前不久的一次问题排查过程,觉得蛮有意思,也写一篇文章分享给大家。
一、发现问题
在一次开发过程中,业务想知道消息是否是退款驱动发送的,而唯一判断的方法,是从消息的明细(可能是乱序的)中取出唯一的一个退款明细,通过修改时间,看它是不是最后一条更新的明细,从而判断出它是退款驱动的消息。初来乍到的笃某不假思索,对方法入参内的列表使用了排序大法,通过实现 compare 接口,对应两个 Detail 的修改时间,便很轻松的获取到了想要的值。方法大致如下:
public Boolean isReFundEvent(Event event) {
List<Detail> details = event.getDetails();
if(Collections.isEmpty(details)) {
throw new Exception(...);
}
Collections.sort(details, new Comparator<Detail>() {
@Override
public int compare(Detail input1, Detail input2) {
return input1.getModifiedDate().compareTo(input2.getModifiedDate());
}
});
return details.get(details.size() - 1).isRefund();
}
复制代码
上述代码看着似乎没有任何问题,同时还经过了自测、联调以及质量验收等层层关卡,于是代码也顺理成章的进入了主干,部署到了预发,准备通过流量回放这最后的关卡。然而,此时 cdo 报了一个很少遇到的异常:ConcurrentModificationException。
遇见异常后,排查起来很容易,大致的代码如下,可以看到 func1 在遍历 detail 处理业务,程序会运行到 func3,在经历了层层签到后,内层的 isRefundEvent 函数又将正在遍历的 details 进行了排序,导致了异常的发生。
// 外层服务类的代码
public void func1(Event event) {
List<Detail> details = event.getDetails();
if(Collections.isEmpty(details)) {return;}
func2(event);
// 省略其他代码...
// 这里对details进行遍历了
for(Detail detail : details) {
List<FundItem> refundItems = fundService.queryRefundItems(event.getExt().getRefundId());
if(!Collections.isEmpty(refundItems)) {
for (FundItem refundItem: refundItems) {
func3(event, detail, refundItem);
}
}
}
}
// 中间引擎层的代码
public void func2(Event event) {
isReFundEvent(Event); // 这里也调用了上面那段方法
}
public void fund3(Event event, Detail detail, FundItem refundItem) {
// 这里调用到了工具类的代码,也就是上面的那段
isReFundEvent(Event);
}
复制代码
所以在代码执行过程中,迭代器识别到了正在迭代的列表对象在迭代过程中被修改了,抛出了 ConcurrentModificationException 异常,这种机制称为 Fast-Fail,GPT 给出的定义为:
Fast-fail 机制,又称作 fail-fast 机制,是计算机系统中的一种设计哲学,它主张当系统检测到错误时应该尽可能快地失败和报告错误,而不是尝试继续执行以保持系统运行。这一哲学的主旨在于通过迅速暴露问题来简化系统的调试和错误处理,从而避免错误在系统中蔓延导致更大的损害。
在软件开发中,fast-fail 机制通常应用在以下几个方面:
数据结构和 API 设计: 例如,在 Java 集合框架中,迭代器的快速失败行为是一个典型的例子。如果在迭代集合时,集合被结构性地修改(添加、删除元素等),迭代器会立即抛出 ConcurrentModificationException,而不是尝试处理或忽略这种修改,从而可能产生不可预知的行为。
1)参数检查: 方法在执行任何操作之前检查其参数的有效性。如果参数不合法,则立即抛出异常(如 IllegalArgumentException),不执行任何后续操作。
2)断言: 在代码中使用断言(assert)来验证在某个特定点上的条件是否满足。如果条件失败,则会抛出一个 AssertionError,表示出现了程序不应该有的状态。
3)异常处理: 在检测到错误后立即抛出适当的异常,而不是返回一个错误码并期望调用者检查和处理这个错误码。
4)事务管理: 在数据库操作中,如果事务中的一个操作失败,整个事务会被立刻回滚,而不是尝试部分提交。
Fast-fail 机制不一定在所有情况下都是理想的选择。在某些系统中,比如需要高可用性的分布式系统中,可能更适合采用 fail-safe 或者 fail-soft 策略,这些策略在检测到错误时会尝试一些恢复或者降级操作来维持服务的连续性。
然而,fast-fail 在开发和测试阶段非常有用,它可以帮助开发人员及早发现并修复潜在的错误,从而提高软件的可靠性和维护性。
异常的原理很简单,使用 for 循环对列表对象进行遍历时,编译器会将 for 循环优化成迭代器进行遍历,所以直接翻看迭代器的源码可以看到:
private class Itr implements Iterator<E> {
//当前元素索引
int cursor;
//当前遍历到的元素索引
int lastRet = -1;
//存ArrayList内部得modCount
int expectedModCount = modCount;
Itr() {}
/*
* -- hasNext()方法,判断是否还有元素。
* 因为调用next()时,cursor每次往后移动,当cursor == size时,说明遍历完毕
* (因为cursor是从0开始)
*/
public boolean hasNext() {
return cursor != size;
}
/*
* 返回当前元素
*/
public E next() {
//此方法就是去检查modCount的情况。
checkForComodification();
//i存储当前将要遍历的元素的索引
int i = cursor;
//越界检查
if (i >= size)
throw new NoSuchElementException();
//获取List内部的数组
Object[] elementData = ArrayList.this.elementData;
//i大于elementData.length 说明再次期间数组已经可能发生扩容了,抛异常
if (i >= elementData.length)
throw new ConcurrentModificationException();
//cursor + 1,指针后移
cursor = i + 1;
//返回当前元素。
return (E) elementData[lastRet = i];
}
}
复制代码
在迭代器被创建时,会记录当前迭代对象被修改的次数 expectedModCount,每当迭代对象(也就是 List)被修改时(add、remove、sort 等),对象自身的 modCount 属性都会+1,最终迭代器在获取下个迭代元素前,会调用的 checkForComodification 方法,通过 expectedModCount 与 modCount 进行对比,检查迭代对象是否被修改过。
当两个值不一致时,便会抛出 ConcurrentModificationException 异常,并且报错堆栈的位置,也是 for 循环处。
final void checkForComodification() {
/*检查创建迭代器对象时的modCount与当前modCount是否相同,
*如果不同,说明当前在迭代遍历元素期间有其他线程对List进行了add或者remove
*那么直接抛出异常。
*/
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
复制代码
既然问题已经定位,修复方案也是十分轻松,对原本要排序的 details 对象深拷贝后,得到一个副本 tempDetail,然后对 tempDetail 进行排序,同样可以顺利的获得想要的结果。然而,在验证修复方案是否正确时,如何复现异常,变成了最大的难题。
二、并不是每次都会报错
按照上面的描述,如此简单的异常,应该是一个必现问题,因为每次遍历 details 时,都会进行排序,从而抛出 ConcurrentModificationException。可事实并不是这样,代码通过了所有的线下的所有测试,难道遇到某种“灰电平衡”?
本着严肃、认真的工作态度,笃某又开始了新一轮的排查。
// 外层服务类的代码
public void func1(Event event) {
List<Detail> details = event.getDetails();
if(Collections.isEmpty(details)) {return;}
func2(event);
// 省略其他代码...
// 这里对details进行遍历了
for(Detail detail : details) {
List<FundItem> refundItems = fundService.queryRefundItems(event.getExt().getRefundId());
if(!Collections.isEmpty(refundItems)) {
for (FundItem refundItem: refundItems) {
func3(event, detail, refundItem);
}
}
}
}
// 中间引擎层的代码
public void func2(Event event) {
isReFundEvent(Event); // 这里也调用了上面那段方法
}
public void fund3(Event event, Detail detail, FundItem refundItem) {
// 这里调用到了工具类的代码,也就是上面的那段
isReFundEvent(Event);
}
复制代码
不难看出,代码其实在执行 func2 时,程序已经调用过 isReFundEvent()了,由于 Collections.sort()方法,是直接对传入的元素本身进行排序,所以等到 fund3 调用时,Event 中的 details 已经是按照修改时间排序好的,最新的一个 detail 一定会出现在列表的最后一位。
此时,笃某大胆猜想:是不是迭代器在最后一次遍历时,“偷偷”修改迭代对象,迭代器是不会进行检查的。抱着试一试的心态,笃某打开了平时做两数之和的工程文件,写了一个 demo:
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
int cnt = 0;
for(Integer ele: list) {
System.out.println(ele);
cnt++;
// 这里cnt为1、2、3就会异常,为4时不会抛出异常
if (cnt == 4) {
Collections.sort(list, new Comparator<Integer>() {
@Override
public int compare(Integer input1, Integer input2) {
return input1 - input2;
}
});
}
}
}
复制代码
实验成果证明了笃某的猜想,于是再一次的打开了源码,可以看到:
// 最后一次调用时,到这里就结束了
public boolean hasNext() {
return cursor != size;
}
/*
* 返回当前元素
*/
public E next() {
//此方法就是去检查modCount的情况。
checkForComodification();
//i存储当前将要遍历的元素的索引
int i = cursor;
//越界检查
if (i >= size)
throw new NoSuchElementException();
//获取List内部的数组
Object[] elementData = ArrayList.this.elementData;
//i大于elementData.length 说明再次期间数组已经可能发生扩容了,抛异常
if (i >= elementData.length)
throw new ConcurrentModificationException();
//cursor + 1,指针后移
cursor = i + 1;
//返回当前元素。
return (E) elementData[lastRet = i];
}
复制代码
//迭代器遍历过程
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
// 操作next
}
复制代码
迭代器会在获取下一个元素的时,才会进行 modCount 的检查,而当迭代器中没有下一个元素时,会直接终止迭代,不会走到 next 方法,也就意味着不去检查迭代器是否被修改过。
所以在代码 func1 中,虽然每次遍历 details 时,只要代码走到了 func3,函数都会对 details 进行排序,按理说异常肯定会发生。但是由于,在 func2 处,已经对明细进行了排序,导致如果有唯一的退款明细,极有可能出现在最后一个(因为大多数交易中,最后的操作都是退款,很少有退款后再支付的场景)。
故执行到 func3 中的排序代码时,details 已经遍历到最后一位了,isReFundEvent 函数可以“偷偷”的对明细进行排序,迭代器也不会再进行检查,bug 也就顺理成章的隐藏了起来。最后,笃某制造了一笔支付后先退款再支付的交易,便成功的将异常复现,修改后的代码也能完美的解决了该问题。
三、复盘
ConcurrentModificationException 异常通常会有单线程和多线程两种可能。
1)单线程:单线程报错只会是上述情况,存在嵌套在循环内的集合类对象本身的修改。建议在写代码的时候,使用对象副本的形式对 list 等集合类本身对象进行操作,或者使用迭代器本身自带的 remove 和 add 方法进行操作。在大型系统中,由于都是方法之间很多都是上下文传递,方法之间嵌套很深,所以出现该问题的几率还是很大的,写代码时还是要注意溯源。
2)多线程:多个线程同时操作同一个集合时,由于 arrayList 等类是线程不安全的,所以就会出现并发修改异常,建议在多线程操作时,使用线程安全类,或者使用工具类等对集合进行加锁,再进行修改操作。
如果大家还有更好的解决方式,欢迎大家在评论区交流。
另外,还是建议大家严格遵守标准的研发流程,认真对待质量设置每一个质量卡点,因为它随时可能成为保护你我启动“接雨水”或“两数之和”的最后一关。
四、冷知识
在排查和研究过程中,还学到了一点冷知识,一起分享给大家。
(也不算冷知识,在开发规约中已经说明过了)
迭代过程中,可以通过迭代器对原本的 list 进行操作,不要通过 list 本身。举个例子:
如果直接对 list 调用 remove 方法,会报错。
但是调用迭代器本身的 remove 方法,不会报错:
原因是因为 list 的迭代器的 remove 方法,会将 exceptedModCount 重置:
add 方法一样:
评论