一次ArrayList的使用不当导致线上jstorm任务启动失败的案例

起因:

最近一次的的项目版本迭代中,我们的jstorm项目里面增加了一些新的功能,开发完毕后,按照正常的上线流程,代码是需要在开发,测试和预发布环境,测试完毕后才能上线。 这次上新版本也不例外,在所有的环境都测试之后并无任何问题,然后由OP上线,结果发布失败。

然后查看异常log,大致如下:

Exception in thread "Thread-16" Exception in thread "Thread-18" Exception in thread "Thread-38" java.lang.ArrayIndexOutOfBoundsException
    at java.lang.System.arraycopy(Native Method)
    at java.util.ArrayList.addAll(ArrayList.java:562)
    at ArrayListTest$PutThread.run(ArrayListTest.java:24)

基本确定了是拷贝执行了ArrayList.addAll方法,导致的拷贝索引越界问题,追查发生异常地方的源码大致如下:

public class CountBolt extends BaseBasicBolt {


    static List<String> list=new ArrayList<>();
    String data[]=new String[]{"1","2","3","4","5","6","7","8","9","10","11"};

    public void prepare(Map stormConf, TopologyContext context) {
        //......
        list.addAll(Arrays.asList(data))//异常代码处
        //.......
    }

}

从上面能够看出,异常代码的触发代码是:

list.addAll(Arrays.asList(data))//异常代码处

但奇怪的是在开发,测试,预发环境均没有出现类似问题,最初怀疑是环境问题,但在仔细的检查各种配置文件之后,并一步步检查发布步骤之后,依然没有线索,仅仅只有线上环境有问题,其他环境均不能复现。

基本排除了是操作步骤的和环境的问题之后,又仔细的检查了代码,发现了这个ArrayList是静态变量:

static List<String> list=new ArrayList<>();

而jstorm的Bolt是以多线程的方式运行的,所以静态变量是类共享的,这意味着有多个线程同时在向list里面添加数据,所以这个addAll方法并不是线程安全的,但抛出的异常是索引越界异常,为了弄清原因,继续追查源码,在ArrayList里面的addAll源码如下(jdk8):

public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew);  // Increments modCount
        System.arraycopy(a, 0, elementData, size, numNew);
        size += numNew;
        return numNew != 0;
    }

ensureCapacityInternal是扩容的方法,每次追加数据前,都会检查当前的数组容量是否能够装的下,如果不能则会扩容50%,而log中的异常是发生在:

System.arraycopy(a, 0, elementData, size, numNew);

然后,我们继续深追arraycopy方法,发现其是native方法,其五个参数的意思是:

(原数组,原数组的开始位置,目标数组,目标数组的的开始位置,拷贝的个数)

具体的方法,如下:

public static native void arraycopy(Object src,  int  srcPos,
                                        Object dest, int destPos,
                                        int length);

也就是说是底层使用C/C++写的,直接查看不了,也就是说线索断了,不过看这个方法的声明,知道其最多会抛出三种异常,如下:

IndexOutOfBoundsException 
ArrayStoreException
NullPointerException

很明显IndexOutOfBoundsException异常就是我们需要关注的,但是源码里面没有细写什么情况下会出现这种异常,到这里似乎线索又断了,不过不着急,我们还可以移步到Oracle的官网文档界面再查找一下,这下找到了有关的详细的方法说明,如下:

Otherwise, if any of the following is true, an IndexOutOfBoundsException is thrown and the destination is not modified:

The srcPos argument is negative.
The destPos argument is negative.
The length argument is negative.
srcPos+length is greater than src.length, the length of the source array.
destPos+length is greater than dest.length, the length of the destination array.

这里面解释了出现IndexOutOfBoundsException的5种情况,其中前四种经过分析,应该是不可能出现的,而最后一种值得怀疑:

destPos+length is greater than dest.length, the length of the destination array.

我们来分析下,什么时候会出现问题:

public boolean addAll(Collection<? extends E> c) {
        Object[] a = c.toArray();
        int numNew = a.length;
        ensureCapacityInternal(size + numNew); 
        System.arraycopy(a, 0, elementData, size, numNew);//1
        size += numNew;
        return numNew != 0;//2
    }

多个线程同时执行,当A线程扩容完执行到2处,B线程刚好执行到1,这个时候如果B线程恰巧看到了A线程已经更新过的最新的size的值,就会出现size+numNew大于elementData.length的情况,这个时候相当于扩容后的容量,仍然不能装下最新添加集合的数据,所以就自然会抛出越界异常:

IndexOutOfBoundsException

知道原因后,我们来思考下,如何让其复现:

条件(1):必须有多线程同时添加数据的情况,或者多个线程不停的添加数据

条件(2):必须触发了ArrayList内部的Object数组的扩容动作

下面,我们看下复现问题的程序,注意这里我为了符合和我们生产环境一致的写法,用的多线程同时并发的插一批数据,而并不是无限循环的向里面的追加数据,虽然这种方法,更能复现问题,但为了严谨性,有必要保持和尽量生产环境一样的写法,这样才更能接近真相。

代码如下:

package concurrent.thread_unsafe_example;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/***
 *测试ArrayList在多线程环境下扩容异常问题
 * cpu核数越多,几率越大
 */
public class ListAddAllTest {



    static class PutThread extends Thread{

        static List<String> list=new ArrayList<>();

        CountDownLatch latch;

        public PutThread(CountDownLatch latch) {
            this.latch = latch;
        }

        @Override
        public void run() {
            try {

            String data[]=new String[]{"1","2","3","4","5","6","7","8","9","10","11"};
            latch.await();//必须等到所有的线程到达之后,才能向下执行
            list.addAll(Arrays.asList(data));
            Thread.sleep(2000);//等待一会再结束,避免结束的块,共享数据会刷新到主内存里面
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }


    public static void main(String[] args) throws InterruptedException {
        //使用 CountDownLatch 作共享锁
        CountDownLatch latch=new CountDownLatch(1);
        for (int i = 0; i < 40; i++) {
            PutThread pt=new PutThread(latch);
            pt.start();
        }
        //释放栅栏
        latch.countDown();






    }

}

在运行第五次时,出现了异常:

java.lang.ArrayIndexOutOfBoundsException
    at java.lang.System.arraycopy(Native Method)
    at java.util.ArrayList.addAll(ArrayList.java:580)
    at concurrent.thread_unsafe_example.ListAddAllTest$PutThread.run(ListAddAllTest.java:32)

虽然出现了异常,但奇怪的是:为什么生产环境每次都是必现,而在我自己的开发机上却是有几率的出现?

其实,这个很好解释,虽然我开的线程是40个,并且故意让其同时触发,但我本地的开发机cpu个数只有4个,而生产环境应该有32个cpu,多线程的情况下,如果cpu的个数越多,那么同时并行运行的几率就越大,注意我这里用词是并行,而并不是并发,在复现上面的问题中,一定是并行的几率越大,复现的几率就越大,因为并发会涉及线程的短暂调度,在这短暂的周期之间,是有一定的先后顺序,所以这会降低异常发生的几率。

为了验证我的想法,我把程序部署在另外一台拥有20个cpu的机器上,这下几乎每次都能抛出异常。

现在一切真相大白了,但还有最后一个疑问,为什么当初同样的代码,没有在开发,测试和所谓的预发环境测出来呢?

原因是因为这几个环境storm的bolt的并发task的个数只有2个,也就说最多只有2个线程,所以能导致出现问题的几率非常之小,这才发生了文章开头的一幕。

一些思考与总结:

(1)遇到问题时候,尽量先通过参数一致排除环境问题

(2)排除环境问题之后,查看是否有线程安全问题

(3)如果没有线程安全问题,最后可从最近更新或者发布的代码中及一些框架的源码中追加问题,是否有死循环,内存泄漏等bug

(4)发现问题后,尽量先在本地复现,并且要注意尽量接近线上的真实情况。

相关文章:

Java里面关于数组拷贝的几种方式

原文发布于微信公众号 - 我是攻城师(woshigcs)

原文发表时间:2018-12-08

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区