问题:
在多线程中使用ArrayList调用Add()添加元素时,有时候会出现下面的错误
Exception in thread "Thread-1" Exception in thread "Thread-2" java.lang.ArrayIndexOutOfBoundsException: 15
at java.util.ArrayList.elementData(ArrayList.java:418)
at java.util.ArrayList.get(ArrayList.java:431)
at com.jant.demo_jant.DemoJantApplication$AddToTest.run(DemoJantApplication.java:35)
at java.lang.Thread.run(Thread.java:745)
java.lang.ArrayIndexOutOfBoundsException: 15
at java.util.ArrayList.add(ArrayList.java:459)
at com.jant.demo_jant.DemoJantApplication$AddToTest.run(DemoJantApplication.java:32)
at java.lang.Thread.run(Thread.java:745)
那么问题来了,ArrayList是自动扩容、没有长度限制,为什么还会出现数组下标越界这种错误呢?
示例代码:
通过这个示例,来研究一下
public class DemoJantApplication {
public static List<Integer> numberlist = new ArrayList<>();
class AddToTest implements Runnable {
int startNum = 0;
public AddToTest(int startNum) {
this.startNum = startNum;
}
@Override
public void run() {
int count = 0;
while (count < 100) {
try {
java.lang.Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
numberlist.add(startNum);
System.out.println(java.lang.Thread.currentThread().getName() + "--" + (count) + "times"
+" 被添加的数字:"+ startNum
+ ",list的最后数字:" + numberlist.get(numberlist.size() - 1)
+ ",集合的大小:" + numberlist.size()
+ ",list" + numberlist.toString());
startNum += 2;
count++;
}
}
}
public static void main(String[] args) {
Thread t = new Thread(new DemoJantApplication().new AddToTest(0));
Thread t1 = new Thread(new DemoJantApplication().new AddToTest(100));
t.start();
t1.start();
}
}
打印出的log,我把他分为三种情况:
情况一:
这种情况算是正常情况,因为元素没有丢失:
线程1和线程2,第0次执行,但是集合的大小都是2
Thread-1--0times 被添加的数字:0,list的最后数字:100,集合的大小:2,list[0, 100]
Thread-2--0times 被添加的数字:100,list的最后数字:100,集合的大小:2,list[0, 100]
Thread-1--1times 被添加的数字:2,list的最后数字:2,集合的大小:4,list[0, 100, 102, 2]
Thread-2--1times 被添加的数字:102,list的最后数字:2,集合的大小:4,list[0, 100, 102, 2]
Thread-1--2times 被添加的数字:4,list的最后数字:4,集合的大小:5,list[0, 100, 102, 2, 4]
情况二:
线程1和线程2,第0次执行,但是集合的大小都是1
Thread-1--0times 被添加的数字:0,list的最后数字:100,集合的大小:1,list[100]
Thread-2--0times 被添加的数字:100,list的最后数字:100,集合的大小:1,list[100]
Thread-1--1times 被添加的数字:2,list的最后数字:2,集合的大小:2,list[100, 2]
Thread-2--1times 被添加的数字:102,list的最后数字:102,集合的大小:3,list[100, 2, 102]
Thread-1--2times 被添加的数字:4,list的最后数字:104,集合的大小:4,list[100, 2, 102, 104]
情况三:
添加元素出现错误
Exception in thread "Thread-1" Exception in thread "Thread-2" java.lang.ArrayIndexOutOfBoundsException: 15
at java.util.ArrayList.elementData(ArrayList.java:418)
at java.util.ArrayList.get(ArrayList.java:431)
at com.jant.demo_jant.DemoJantApplication$AddToTest.run(DemoJantApplication.java:35)
at java.lang.Thread.run(Thread.java:745)
java.lang.ArrayIndexOutOfBoundsException: 15
at java.util.ArrayList.add(ArrayList.java:459)
at com.jant.demo_jant.DemoJantApplication$AddToTest.run(DemoJantApplication.java:32)
at java.lang.Thread.run(Thread.java:745)
分析
首先,ArrayList是基于数组实现的,是一个动态数组,其容量能自动增长,类似于C语言中的动态申请内存,动态增长内存。
对于ArrayList而言,它实现List接口、底层使用数组保存所有元素。其操作基本上是对数组的操作。
先来看下ArrayList 使用add() 添加元素的流程:
1、添加元素,其中size是elementData数组中元组的个数,初始为0。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
2、确定list的容量,其作用为保证数组的容量始终够用
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//DEFAULT_CAPACITY 为10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
3、扩展list的长度,每次数组容量的增长大约是其原容量的1.5倍
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
//拷贝数组,返回新长度的数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中
4、接下来回到Add()函数,继续执行,elementData[size++] = e;
当添加一个元素的时候,它可能会有两步来完成:
- 如果需要增大 Size 的值。
- 在 elementData[Size] 的位置存放此元素;
下面针对示例的三种情况逐一分析:
在单线程运行的情况下,如果 Size = 0,添加一个元素后,此元素在位置 0,而且 Size=1;
情况一:
执行顺序,在多线程情况下
线程 A 向ArrayList添加元素,位置0 。
线程 B 向ArrayList添加元素,位置1
线程A执行打印代码
线程B执行打印代码
情况二:
如果是在多线程情况下,
1、刚开始ArrayList 大小为0,首次扩充大小为10 (通过size获得的是实际存储数据的大小)
2、线程 A 添加元素,此时size 为0。在准备执行elementData[0] = e; 之后执行size++时 CPU 调度线程A暂停
3、线程B 添加元素,此时size 为0,执行了elementData[0] = e; 之后执行size++,此时size为1.
4、此时线程A继续执行size++ ,此时size为1.
所以线程B添加的值会被线程A添加的值覆盖
情况三:
观察发生越界时的数组下标,分别为10、15、22、33、49和73。
结合前面讲的数组自动机制,数组初始长度为10,第一次扩容为15=10+10/2,第二次扩容22=15+15/2,第三次扩容33=22+22/2…以此类推,通过不断调试可以发现,越界异常都发生在数组扩容之时。
1、 当集合中已经添加了14个元素时,线程A率先进入add()方法,在执行ensureCapacityInternal(size + 1)时,发现还可以添加一个元素,故数组没有扩容,但随后线程A被阻塞在此处。
2、接着线程B进入add()方法,执行ensureCapacityInternal(size + 1),由于线程A并没有添加元素,故size依然为14,依然不需要扩容,所以该线程就开始添加元素,使得size++,变为15,数组已经满了。
3、 此时线程A运行,开始执行elementData[size++] = e;,它要在集合中添加第16个元素,而数组容量只有15个,所以就发生了数组下标越界异常!
参考:
ArrayList在多线程调用Add()添加元素时的下标越界问题(java.lang.ArrayIndexOutOfBoundsException)
这篇博客写的不错,但是下面这段话分析不正确:
而如果是在多线程情况下,比如有两个线程,线程 A 先将元素存放在位置 0。但是此时 CPU 调度线程A暂停,线程 B 得到运行的机会。线程B也向此 ArrayList 添加元素,因为此时 Size 仍然等于 0 (注意哦,我们假设的是添加一个元素是要两个步骤哦,而线程A仅仅完成了步骤1),所以线程B也将元素存放在位置0。然后线程A和线程B都继续运行,都增加 Size 的值。那好,我们来看看 ArrayList 的情况,元素实际上只有一个,存放在位置 0,而 Size 却等于 2。这就是“线程不安全”了。这就解释了为何集合中会出现null。