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

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

clipboard.png

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

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

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

clipboard.png

clipboard.png

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

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

  • 在一个线程遍历过程中,List被其他线程修改,导致List长度产生变化

多线程遍历安全

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

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

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相关的问题,会更容易解决~


jimsshom
46 声望2 粉丝