今天组内的一个同学碰到一个并发问题,帮忙看了下。是个比较小的点,但由于之前没碰到过所以也没特意了解过这块,今天既然看了就沉淀下来。
原始问题是看到日志里有一些零星的异常,如下如所示
根据堆栈信息,可以很快定位到对应的应用代码,同时根据异常的描述,可以初步定为是并发访问ArrayList造成的。
相关应用代码如下(也就是堆栈第三行的CommonUtil.getItemFromList)
这里的list是由上层逻辑传入的
提到Collection的遍历,第一时间想到两种可能性(非针对java,只是一般性的想法):
迭代器内部会保存当前的遍历位置,那么多个线程同时遍历时遍历位置属于共享变量,会导致多线程问题
在一个线程遍历过程中,List被其他线程修改,导致List长度产生变化
多线程遍历安全
对于以上两个可能性,其实只要稍加思考,就能想到第一个可能性是不太可能的,因为是java基本要保证的。通过查看ArrayList的源码也基本确定了这个点。
ArrayList中有三个迭代器相关的函数,返回两种迭代器实现,分别是ListIterator和Iterator。看名字就知道前者只能用于List的遍历,后者可用于所有Collection的遍历,对于for循环来说,使用的是后者。这点参考这两个页面。
http://beginnersbook.com/2014...
https://stackoverflow.com/que...
Iterator相关代码如下
从这里就可以看出来,多线程遍历同一个List是安全的。因为迭代器是在每次for循环(调用iterator)时生成的实例,每次实例独立保存当前的遍历进度(图中的cursor字段),这样每个线程在遍历时只会修改自己线程所创建的Itr对象,没有共享变量被修改。
遍历中修改不安全
排除了上面这种可能性,问题因为基本就定位了。
根据堆栈信息找到出错的地方
可以看到,List保证其遍历时不被修改,采用的是用一个计数器的机制。
在开始遍历前,先记录当前的modCount值
而后每次访问下一个元素之前,都会检查下modCount值是否变化,如果有变化,说明List的长度有变化。一旦长度有变化,就会抛出ConcurrentModificationException异常。
modCount的注释详细说明了这个字段表明List发生结构性变化(长度被修改)的次数,也就是删除插入等操作时,这个字段要加一。有兴趣的读者可以自行搜索下ArrayList代码,看看哪些操作会引起modCount的变化。
定位罪魁祸首
明确了原因,找具体代码问题的时候反而有些波折。因为从代码看这个循环并没有什么特别,同事一直说是和反射有关(反射内部有时候会对类的某些字段的可访问标进行修改),但我自己跟了代码并没有发现什么可疑的地方,无奈写了个小demo验证下。
public class MultiThreadArrayListThread {
public static List list = new ArrayList();
public static Random random = new Random(System.currentTimeMillis());
public static class TestBean {
private Integer value;
public Integer getValue() {
return value;
}
public void setValue(Integer value) {
this.value = value;
}
}
public static class TestThread extends Thread {
@Override
public void run() {
for (Object o : list) {
/*if (Thread.currentThread().getName().equals("1")) {
list.add(new TestBean());
}*/
try {
System.out.println(Thread.currentThread().getName() + ":" + org.apache.commons.beanutils.BeanUtils.getProperty(o, "value"));
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
try {
Thread.sleep(random.nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
int i = 0;
while (i < 100) {
TestBean testBean = new TestBean();
testBean.setValue(i);
list.add(testBean);
i++;
}
int thread = 0;
while (thread < 20) {
TestThread testThread = new TestThread();
testThread.setName(String.valueOf(thread));
testThread.start();
thread++;
}
}
}
上述代码执行后并没有报错,只有在注释掉的add操作打开后,才会抛异常。
这个demo进一步验证了自己对于异常原因的认知,同时也说明了反射的确不会影响List的遍历。因此我的注意力从这段代码中移开,转而关注List的获取。
这下发现问题所在了。
这里同事犯了个低级错误。这段代码的逻辑是有ABCD四个配置信息,要返回这四个配置信息的并集。但同事的代码直接在第一个List中添加后几个List的元素了。由于引用是同一个,因此出现了线程a在执行完这段逻辑拿到一个List(其中包含A+B+C+D)并开始遍历时,线程b开始执行这段逻辑。此时线程a和线程b拿到的其实是同一个List引用(最开始的A),并且在线程a遍历时线程b对其进行了修改(add(B/C/D)),因此会触发线程a抛异常。不仅如此,哪怕不抛异常,每次业务要去拿这个配置文件,都会在该集合中加入BCD的元素,集合元素会递增(A -> ABCD -> ABCDBCD -> ABCDBCDBCD …),一直运行会导致OOM!
定位到问题后修复就很简单了,每次获取配置时new一个新的List即可。
ArrayList list = new ArrayList();
list.add(A);
list.add(B);
list.add(C);
list.add(D);
至此问题顺利结局~
小结
这个问题最终定位到是一个低级的代码错误,但过程还是值得记录下的。自己虽在java这方面工作数年,但像modCount这种机制,要是没有遇到特定的问题还是没可能面面俱到每个小点都关注到的。今天碰到的这个小case正好帮助自己拾遗补缺,相信以后碰到ArrayList相关的问题,会更容易解决~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。