资讯专栏INFORMATION COLUMN

【拾遗补缺】java ArrayList的不当使用导致的ConcurrentModification

曹金海 / 3479人阅读

摘要:中有三个迭代器相关的函数,返回两种迭代器实现,分别是和。根据堆栈信息找到出错的地方可以看到,保证其遍历时不被修改,采用的是用一个计数器的机制。

今天组内的一个同学碰到一个并发问题,帮忙看了下。是个比较小的点,但由于之前没碰到过所以也没特意了解过这块,今天既然看了就沉淀下来。

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

根据堆栈信息,可以很快定位到对应的应用代码,同时根据异常的描述,可以初步定为是并发访问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相关的问题,会更容易解决~

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/67428.html

相关文章

  • 拾遗补缺java ArrayList不当使用导致ConcurrentModification

    摘要:中有三个迭代器相关的函数,返回两种迭代器实现,分别是和。根据堆栈信息找到出错的地方可以看到,保证其遍历时不被修改,采用的是用一个计数器的机制。 今天组内的一个同学碰到一个并发问题,帮忙看了下。是个比较小的点,但由于之前没碰到过所以也没特意了解过这块,今天既然看了就沉淀下来。 原始问题是看到日志里有一些零星的异常,如下如所示 showImg(https://segmentfault.co...

    13651657101 评论0 收藏0
  • 拾遗补缺java ArrayList不当使用导致ConcurrentModification

    摘要:中有三个迭代器相关的函数,返回两种迭代器实现,分别是和。根据堆栈信息找到出错的地方可以看到,保证其遍历时不被修改,采用的是用一个计数器的机制。 今天组内的一个同学碰到一个并发问题,帮忙看了下。是个比较小的点,但由于之前没碰到过所以也没特意了解过这块,今天既然看了就沉淀下来。 原始问题是看到日志里有一些零星的异常,如下如所示 showImg(https://segmentfault.co...

    huhud 评论0 收藏0
  • LEETCODE刷题记录【27 Remove Element】

    摘要:复杂度分析时间复杂度遍历次空间复杂度还有没有优化空间方法在某些特定场景下会进行不必要的复制操作,影响性能。注意尾部的元素有可能是需要剔除的,所以,下一轮循环要从当前索引重新开始。 给定一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,返回移除后数组的新长度。不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。 元素的...

    马龙驹 评论0 收藏0
  • Java并发编程——线程基础查漏补缺

    摘要:告诉当前执行的线程为线程池中其他具有相同优先级的线程提供机会。不能保证会立即使当前正在执行的线程处于可运行状态。当达到超时时间时,主线程和是同样可能的执行者候选。下一篇并发编程线程安全性深层原因 Thread 使用Java的同学对Thread应该不陌生了,线程的创建和启动等这里就不讲了,这篇主要讲几个容易被忽视的方法以及线程状态迁移。 wait/notify/notifyAll 首先我...

    luqiuwen 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<