概述
对于ArrayList,相信大家也都经常使用。
还记得我以前面试的时候,经常有面试观问集合的知识点,其中一个被经常差距到的就是:ArrayList是否是线程安全的?
答案是三个字:不安全。
那它线程不安全的具体体现又是怎样的呢?我们一起来看一下源码。
源码分析
首先看看这个类所拥有的部分属性字段:
public class ArrayList extends AbstractList
implements List, RandomAccess, Cloneable, java.io.Serializable
{
/**
* 列表元素集合数组
* 如果新建ArrayList对象时没有指定大小,那么会将EMPTY_ELEMENTDATA赋值给elementData,
* 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY
*/
transient Object[] elementData;
/**
* 列表大小,elementData中存储的元素个数
*/
private int size;
}
所以通过这两个字段我们可以看出,ArrayList的实现主要就是用了一个Object的数组,用来保存所有的元素,以及一个size变量用来保存当前数组中已经添加了多少元素。
接着我们看下最重要的add操作时的源代码:
public boolean add(E e) {
/**
* 添加一个元素时,做了如下两步操作
* 1.判断列表的capacity容量是否足够,是否需要扩容
* 2.真正将元素放在列表的元素数组里面
*/
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
ensureCapacityInternal()这个方法的详细代码我们可以暂时不看,它的作用就是判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容。
由此看到add元素时,实际做了两个大的步骤:
判断elementData数组容量是否满足需求
在elementData对应位置上设置值
这样也就出现了第一个导致线程不安全的隐患,在多个线程进行add操作时可能会导致elementData数组越界。具体逻辑如下:
列表大小为9,即size=9
线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
线程B也发现需求大小为10,也可以容纳,返回。
线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10。
线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException.
另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作,它由如下两步操作构成:
elementData[size] = e;
size = size + 1;
在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:
列表大小为0,即size=0
线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
线程A开始将size的值增加为1
线程B开始将size的值增加为2
这样线程AB执行完毕后,理想中情况为size为2,elementData下标0的位置为A,下标1的位置为B。而实际情况变成了size为2,elementData下标为0的位置变成了B,下标1的位置上什么都没有。并且后续除非使用set方法修改此位置的值,否则将一直为null,因为size为2,添加元素时会从下标为2的位置上开始。
ArrayList 线程不安全验证
验证Demo:
/**
* 集合类ArrayList线程不安全验证
*
* @author wangjie
* @version V1.0
* @date 2019/12/17
*/
public class ContainerNotSafe {
public static void main(String[] args) {
List list = new ArrayList<>();
for(int i = 0; i < 30 ; i++){
new Thread(() ->{
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list);
},i+"").start();
}
}
}
运行结果:
java.util.ConcurrentModificationException
这个异常各位可是眼熟?
并发修改异常,以下是JDK1.8 文档截图
- 对象被不同的线程同时修改
- 如果单个线程发出违反对象合同的方法调用序列,则该对象可能会抛出此异常。 例如,如果线程在使用故障快速迭代器迭代集合时直接修改集合,则迭代器将抛出此异常。
解决方案
换Vector
两者区别:
- ArrayList是线程不安全的,Vector是线程安全的。
- 两者扩容方式不同。在底层数组容量不足时,ArrayList会将容量扩容为原来的1.5倍。而Vector支持在创建的时候主动声明扩容时增加的容量的大小,通过Vector(int initialCapacity, int capacityIncrement)构造函数实现。如果没有声明,或者capacityIncrement <= 0,那么默认扩容为原来的2倍.
// Vector的扩容方法
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
验证Demo:
/**
* 验证Vector线程安全
*
* @author wangjie
* @version V1.0
* @date 2019/12/17
*/
public class ContainerSafe {
public static void main(String[] args) {
List list = new Vector<>();
for(int i = 0; i < 30 ; i++){
new Thread(() ->{
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list);
},i+"").start();
}
}
}
运行结果:
用Collections.synchronizedList()
先上验证Demo:
public class ContainerSafe {
public static void main(String[] args) {
// List list = new Vector<>();
List list = Collections.synchronizedList(new ArrayList<>());
for(int i = 0; i < 30 ; i++){
new Thread(() ->{
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list);
},i+"").start();
}
}
}
运行结果:
Vector和Collections.synchronizedList
虽然ArrayList是线程不安全的,但是通过Collections.synchronizedList()方法可以将线程不安全的List转成线程安全的List。但官方文档里,有这么一句话:
If you need synchronization, a Vector will be slightly faster than an ArrayList synchronized with Collections.synchronizedList.
Vector比Collections.synchronizedList快一点点。
下面是我扒的一部分源码:
public static List synchronizedList(List list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
SynchronizedList(List list, Object mutex) {
super(list, mutex);
this.list = list;
}
SynchronizedCollection(Collection c, Object mutex) {
this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
public boolean add(E e) {
synchronized (mutex) {return c.add(e);}
}
}
从代码中可以看出,SynchronizedList类使用了委托(delegation),实质上存储还是使用了构造时传进来的list,只是将list作为底层存储,对它做了一层包装。正是因为多了一层封装,所以就会比直接操作数据的Vector慢那么一点点。
从上面的代码我们也可以看出来,SynchronizedList的同步,使用的是synchronized代码块对mutex对象加锁,这个mutex对象还能够通过构造函数传进来,也就是说我们可以指定锁定的对象。
而Vector则使用了synchronized方法,同步方法的作用范围是整个方法,所以没办法对同步进行细粒度的控制。而且同步方法加锁的是this对象,没办法控制锁定的对象。这也是vector和SynchronizedList的一个区别。
使用CopyOnWriteArrayList
什么是CopyOnWrite容器?
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
CopyOnWriteArrayList的实现原理,以下是我扒的CopyOnWriteArrayList的部分源码:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
int numMoved = len - index - 1;
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为开始读的那一刻已经确定了读的对象是旧对象。
写入和删除时加锁,所以一个线程X读取的时候另一个线程Y可能执行remove操作。remove操作首先要获取独占锁,然后进行写时复制操作,就是复制一份当前的array数组,然后在复制的新数组里面删除元素,删除完成后让array指向这个新的数组。
验证Demo:
public class ContainerSafe {
public static void main(String[] args) {
// List list = new Vector<>();
// List list = Collections.synchronizedList(new ArrayList<>());
List list = new CopyOnWriteArrayList<>();
for(int i = 0; i < 30 ; i++){
new Thread(() ->{
list.add(UUID.randomUUID().toString().substring(0,8));
System.out.println(list);
},i+"").start();
}
}
}
运行结果:
CopyOnWrite的缺点
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题:因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
数据一致性问题:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。【当执行add或remove操作没完成时,get获取的仍然是旧数组的元素】