起因:
最近一次的的项目版本迭代中,我们的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)发现问题后,尽量先在本地复现,并且要注意尽量接近线上的真实情况。
相关文章: