前几天刷博客时,无意中看到一篇名为《CopyOnWriteArrayList真的完全线程安全吗》博客。心中不禁泛起疑问,它就是线程安全的啊,难道还有啥特殊情况?
我们知道CopyOnWrite
的核心思想正如其名:写时复制
。在对数据有修改操作时,先复制再操作,最后替换原数组。在这些操作时,是有加锁的了。
1 问题复现
这篇博文中主要提到数组越界
异常。场景为:假设现在有一个已存在的列表,线程1尝试去查询列表最后一个元素,而此时线程2要去删除列表最后一个元素。此时线程1由于最开始读取的size()=n,在线程2删除后size()=n-1,再拿原Index方式时,便触发ArrayIndexOutOfBoundsException
异常。
其实读到这里,我们就已经知道了问题所在。在读取列表大小
和根据索引访问
两个时间点,列表数据已经发生了改变。这种异常理论上属于可预知的异常。
请看下面的代码,并思考下并发执行会有问题吗
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(l);
while (true) {
if (!cowList.isEmpty()) {
cowList.remove(0);
} else {
return;
}
}
复制代码
我们不妨来试下。
/**
* @author lpe234
* @date 2022/12/03
*/
@Slf4j
public class CowalTest {
public static void main(String[] args) {
List<String> l = new ArrayList<>();
for (int i = 0; i < 100; i++) {
l.add(String.valueOf(i));
}
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(l);
final Runnable rab = () -> {
while (true) {
if (!cowList.isEmpty()) {
cowList.remove(0);
} else {
return;
}
}
};
new Thread(rab).start();
new Thread(rab).start();
}
}
复制代码
程序执行结果如下:
Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
at java.base/java.util.concurrent.CopyOnWriteArrayList.elementAt(CopyOnWriteArrayList.java:386)
at java.base/java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:478)
at com.example.other.CowalTest.lambda$main$0(CowalTest.java:25)
at java.base/java.lang.Thread.run(Thread.java:834)
复制代码
原因就在于cowList.isEmpty()
和cowList.remove(0)
为两个操作。在这两个操作之间,并没有什么机制来保证cowList
不会改变。所以出现异常,是可预见的。
2 源码分析
核心属性及get/set方法。
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
/** 所有涉及到array变更操作的锁。(在内置锁和ReentrantLock都可使用时,我们更倾向于内置锁) */
final transient Object lock = new Object();
/** 这个数组的所有访问,只会通过getArray/setArray来进行。 */
private transient volatile Object[] array;
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
/**
* Sets the array.
*/
final void setArray(Object[] a) {
array = a;
}
复制代码
可见实现其实很简单。内部使用Object[] array
来承载数据。使用volatile
来保证多线程下数组的可见性。
再看下isEmpty
和remove
方法。
public int size() {
return getArray().length;
}
public boolean isEmpty() {
return size() == 0;
}
public E remove(int index) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
E oldValue = elementAt(es, index);
int numMoved = len - index - 1;
Object[] newElements;
if (numMoved == 0)
newElements = Arrays.copyOf(es, len - 1);
else {
newElements = new Object[len - 1];
System.arraycopy(es, 0, newElements, 0, index);
System.arraycopy(es, index + 1, newElements, index,
numMoved);
}
setArray(newElements);
return oldValue;
}
}
复制代码
可以很清晰的看到,在这俩方法中,均有getArray()
调用。如果中间出现其他线程修改数据,这俩数据必然不一致。在看一个add(E e)
方法。
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
复制代码
此时我们可以很清晰的看清他的编程逻辑。
- 凡是对数组有修改的操作,先获取锁。
- 通过
getArray()
获取数据。前面已加锁,为最新数据,在释放锁前不会有其他线程修改。 - 对数据进行相关修改操作,
Arrays.copyOf
是重点。 - 通过
setArray(es)
将修改后的数据赋值给原数组。 - 释放锁。
3 思考
3.1 通过本例我们能学到什么
- 类似
CopyOnWriteArrayList
这种并发安全的类,如果不合理(不规范的、错误的)的使用,也会导致并发安全问题 - 面对事物,要知其然知其所以然。只有了解内部原理,才能更好的去使用它。
- 在
CopyOnWriteArrayList
代码中可以看到,当遇到修改操作时,基本都离不开Arrays.copyOf
,这种拷贝会占用额外一倍的内存空间。如果有大量频繁的修改操作,显然是不太合适的。 - 在修改相关操作代码逻辑中,可以体会到,整体是有那么一点点的延迟的。即一个线程修改完并setArray后,另外的线程才能获取到最新值。
3.2 其他的呢
CopyOnWrite
是一种很好的思想,它能够使读、写
操作并发执行。在Redis的RDB快照生成时,也使用了该思想。- 为什么会有
final transient Object lock = new Object()
这个锁?如果细心看过源码就能明白,其实就是最大程度的减少锁的范围(粒度)。
public boolean addAll(Collection<? extends E> c) {
Object[] cs = (c.getClass() == CopyOnWriteArrayList.class) ?
((CopyOnWriteArrayList<?>)c).getArray() : c.toArray();
if (cs.length == 0)
return false;
synchronized (lock) {
// 略...
}
}
复制代码
echo '5Y6f5Yib5paH56ugOiDmjpjph5Eo5L2g5oCO5LmI5Zad5aW26Iy25ZWKWzkyMzI0NTQ5NzU1NTA4MF0pL+aAneWQpihscGUyMzQp' | base6