【拾遗补缺】java ArrayList的不当使用致使的ConcurrentModificationException问题

今天组内的一个同窗碰到一个并发问题,帮忙看了下。是个比较小的点,但因为以前没碰到过因此也没特地了解过这块,今天既然看了就沉淀下来。java

原始问题是看到日志里有一些零星的异常,以下如所示apache

clipboard.png

根据堆栈信息,能够很快定位到对应的应用代码,同时根据异常的描述,能够初步定为是并发访问ArrayList形成的。安全

相关应用代码以下(也就是堆栈第三行的CommonUtil.getItemFromList)
clipboard.png多线程

这里的list是由上层逻辑传入的并发

clipboard.png

clipboard.png

提到Collection的遍历,第一时间想到两种可能性(非针对java,只是通常性的想法):dom

  • 迭代器内部会保存当前的遍历位置,那么多个线程同时遍历时遍历位置属于共享变量,会致使多线程问题ide

  • 在一个线程遍历过程当中,List被其余线程修改,致使List长度产生变化函数

多线程遍历安全

对于以上两个可能性,其实只要稍加思考,就能想到第一个可能性是不太可能的,由于是java基本要保证的。经过查看ArrayList的源码也基本肯定了这个点。oop

ArrayList中有三个迭代器相关的函数,返回两种迭代器实现,分别是ListIterator和Iterator。看名字就知道前者只能用于List的遍历,后者可用于全部Collection的遍历,对于for循环来讲,使用的是后者。这点参考这两个页面。this

http://beginnersbook.com/2014...

https://stackoverflow.com/que...

Iterator相关代码以下

clipboard.png

clipboard.png

从这里就能够看出来,多线程遍历同一个List是安全的。由于迭代器是在每次for循环(调用iterator)时生成的实例,每次实例独立保存当前的遍历进度(图中的cursor字段),这样每一个线程在遍历时只会修改本身线程所建立的Itr对象,没有共享变量被修改。

遍历中修改不安全

排除了上面这种可能性,问题由于基本就定位了。

根据堆栈信息找到出错的地方

clipboard.png

clipboard.png

clipboard.png

能够看到,List保证其遍历时不被修改,采用的是用一个计数器的机制。

在开始遍历前,先记录当前的modCount值

clipboard.png

然后每次访问下一个元素以前,都会检查下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操做打开后,才会抛异常。

clipboard.png

这个demo进一步验证了本身对于异常缘由的认知,同时也说明了反射的确不会影响List的遍历。所以个人注意力从这段代码中移开,转而关注List的获取。

这下发现问题所在了。

clipboard.png

这里同事犯了个低级错误。这段代码的逻辑是有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相关的问题,会更容易解决~

相关文章
相关标签/搜索