我们上一篇学习了ArrayList,它的添加操作在单线程下是安全的,但是在多线程场景中会存在数据被覆盖等线程不安全的情况,如果我们需要在多线程环境下使用集合类怎么办呢,今天勾勾带你解决这个问题!
Collections工具类
我们可以使用java.utils.Collections工具类将 ArrayList转换为线程安全的集合:
List<String> list = new ArrayList<>(10);
Collections.synchronizedList(list);
我们看下源码它是怎么保证线程安全的。
Collections的synchronizedList方法是创建了Collections的静态内部类对象SynchronizedList。
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
SynchronizedList实现了List接口,提供了像List中添加、修改、删除和查找元素的方法。
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
//构造函数,调用了父类的构造
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
//获取元素,添加、修改和删除元素的方法都加了synchronized锁
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
}
通过源码我们可以看到不论是增删改还是查询都是加了锁的,而线程不安全主要是共享可变数据的写引起的,查询并不存在线程安全问题。开发中我们很少使用Collections生成的线程安全的ArrayList,而是直接使用JUC包下CopyOnWriteArrayList。
CopyOnWriteArrayList
CopyOnWriteArrayList类实现了接口List接口。我们先看它定义了哪些成员变量。
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
}
成员变量
//独占锁
final transient ReentrantLock lock = new ReentrantLock();
/**存放元素的数组容器, 只能通过 getArray/setArray操作 */
private transient volatile Object[] array;
//获取array数组
final Object[] getArray() {
return array;
}
//将入参数组赋值给array
final void setArray(Object[] a) {
array = a;
}
构造函数
CopyOnWriteArrayList提供了3种类型的构造函数:无参构造、参数为集合类的构造、参数为数组的构造。构造函数的方法类似,我们只分析无参构造,开发中常用的构造函数。
//无参构造创建了一个初始容量为0的空数组
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
添加元素
添加元素跟ArrayList一样有很多重载方法,我们可以在尾部添加元素,也可以选择在指定的位置添加元素,也可以批量添加元素,我们只分析最基础最常用的在集合容器尾部添加元素方法add(E e)。
public boolean add(E e) {
//获取锁对象
final ReentrantLock lock = this.lock;
//加锁,保证只有一个线程能够往容器中添加元素
lock.lock();
try {
//获取存储元素的数组容器
Object[] elements = getArray();
//获取数组的长度
int len = elements.length;
//将数组复制为len+1的新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
//将元素添加到容器的末尾
newElements[len] = e;
//将添加元素后的数组赋值给成员变量array
setArray(newElements);
return true;
} finally {
//在finally语句块中释放锁,保证锁肯定会被释放
lock.unlock();
}
}
看完源码我们对比下ArrayList,ArrayList在添加元素的时候如果容量不够会涉及到扩容,按照原来容量的1.5被扩容。而CopyOnWriteArrayList的添加元素的方法每次通过Arrays.copyOf的方式得到一个可以容纳元素的新数组,再把新数组赋值给成员变量,它不会提前准备多余的容量备用。因每次添加元素都要把所有元素复制一次,性能没有ArrayList好。
获取元素
CopyOnWriteArrayList获取元素的方式与ArrayList是一样的,因取元素不会涉及线程安全性,可以不用加锁,提高了查询效率。
public E get(int index) {
//获取成员变量array
return get(getArray(), index);
}
private E get(Object[] a, int index) {
//获取array中index下标位置的元素
return (E) a[index];
}
删除元素
CopyOnWriteArrayList提供了删除元素的重载方法,可以按照指定的下标位置删除元素,也可以按照指定的元素删除元素,还可以批量删除给定的集合中的元素。我们只分析按照指令的下标位置删除元素,其他的都可以参考这个方法看源码分析。
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;
//如果删除的是最后一个下标的元素,则直接复制len-1个元素就可以
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
//如果不是最后一个元素,则需要移动删除元素后面的所有元素
//建立一个可以容纳len-1元素的数组,把0-index范围内的元素先复制过去,再复制index+1位置的元素复制过去,这样就实现了删除index下标位置元素的效果
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
//最后把新数组设置为array
setArray(newElements);
}
//返回被删除的元素
return oldValue;
} finally {
lock.unlock();
}
}
遍历元素
我们可以使用for循环、增强for循环、forEach方法和迭代器遍历元素,前三种遍历方式我们都比较熟悉,我们看下迭代器的元素遍历是怎么实现的。
先来看一个迭代器遍历元素的小例子:
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Java");
list.add("Code");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
iterator方法生成了CopyOnWriteArrayList内部类COWIterator。
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
这个内部类没有支持添加、获取、删除元素的方法。只提供了获取元素的方法。
public boolean hasNext() {
//如果下标小于数组容量,表示还有元素没有遍历结束
return cursor < snapshot.length;
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
//返回下一个指针位置的元素
return (E) snapshot[cursor++];
}
弱一致性
我们在源码的分析中了解到,添加和删除元素时CopyOnWriteArrayList都是新拷贝了一个数组,在拷贝的数组中添加和删除元素,成功之后,再赋值给原数组。如果容器中的的数据量特别大,每一次的删除和添加元素都会是一个特别耗时的操作。CopyOnWriteArrayList为什么要这么做呢?
这里利用了读写分离的思想,我们看到get方法并没有加锁,也就是当有一个线程写元素时并不会影响其他线程读元素,提高了读的并发效率。
此时你是不是有个疑问,那如果这样的话,会不会线程读的时候没有读到写入的元素,或者读的时候由于其他线程删除了元素另一个线程读的数据不一样了呢?确实会存在这种情况,这就是读方法的弱一致性。
如果线程的执行顺序如下:
1)Thread1获取array数组
2)Thread2获取array数组
3)Thread2删除0个位置的元素,并且赋值给array
4)Thread1获取0个位置的元素,拿到的就不是预期的Java了。
get方法和foreach方法遍历都会存在弱一致性,使用迭代器遍历也会存在弱一致性,我们在迭代器的例子中添加一个线程用来删除元素:
public static void main(String[] args) {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Java");
list.add("Code");
Iterator<String> iterator = list.iterator();
new Thread(()->{
list.remove(0);
System.out.println(list);
}).start();
TimeUnit.SECONDS.sleep(1);
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
我们看打印的结果:
[Code]
Java
Code
list中只剩下Code元素了,但是iterator遍历得到的元素中存在两个元素,存在弱一致性。
弱一致性是在并发环境中权衡了性能和数据一致性,我们开发场景中使用最多的是读数据,写数据相对来说比较少,这种弱一致性出现的概率是比较低的。
我是勾勾,一直在努力的程序媛,感谢您的点赞、关注和转发!
我们下一篇文章见!