CopyOnWriteArrayList真的线程安全吗?
最近笔者看了一遍关于《CopyOnWriteArrayList真的完全线程安全》的文章,心中不禁疑惑,无论是平常道听途说,还是网上各种资料,包括在面试中都是高频热点话题,所以它到底是不是绝对的程安全呢?
0.前置知识
1.写入时复制
- 写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种通用优化策略。其核心思想是,如果有多个调用者(Callers)同时访问相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
- 通俗易懂的讲,写入时复制技术就是不同进程在访问同一资源的时候,只有更新操作,才会去复制一份新的数据并更新替换,否则都是访问同一个资源。
- JDK 的 CopyOnWriteArrayList/CopyOnWriteArraySet 容器正是采用了 COW 思想,它是如何工作的呢?简单来说,就是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下。
2.volatile
- volatile (挥发物、易变的): 变量修饰符,只能用来修饰变量。volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
3.transient
- transient 只能用来修饰字段。在对象序列化的过程中,标记为transient的变量不会被序列化。
1.问题复现
- 假设现在有一个已存在的列表,线程A尝试去查询列表最后一个元素,而此时线程B要去删除列表最后一个元素。此时线程A由于最开始读取的size()=n,在线程B删除后size()=n-1,再拿原Index方式时,便触发ArrayIndexOutOfBoundsException异常。
给出最简单的思路代码:
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(l);
while (true) {
if (!cowList.isEmpty()) {
cowList.remove(0);
} else {
return;
}
}
我们来试一下:
/**
* Editor: hengBao
* date: 2022/12/5/22:14
*/
@Slf4j
public class COWTest {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(String.valueOf(i));
}
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(list);
final Runnable rab = () -> {
while (true) {
if (!cowList.isEmpty()) {
cowList.remove(0);
} else {
return;
}
}
};
new Thread(rab).start();
new Thread(rab).start();
new Thread(rab).start();
new Thread(rab).start();
new Thread(rab).start();
new Thread(rab).start();
new Thread(rab).start();
}
}
结果输出:
Exception in thread "Thread-0" Exception in thread "Thread-3" java.lang.ArrayIndexOutOfBoundsException: 0
at java.util.concurrent.CopyOnWriteArrayList.get(CopyOnWriteArrayList.java:388)
at java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:495)
at COWTest.lambda$main$0(COWTest.java:26)
at java.lang.Thread.run(Thread.java:748)
原因就在于cowList.isEmpty()和cowList.remove(0)为两个操作。在这两个操作之间,并没有什么机制来保证cowList不会改变。所以出现异常,是可预见的。
2.源码分析
成员属性
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) {
arr
【写】操作肯定是重中之重,包括:增(add)、删(remove)、改(set)。下面看这个,嘿嘿:
add方法
//获得独占锁
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
//获得list底层的数组array
Object[] elements = getArray();
//获得数组长度
int len = elements.length;
//拷贝到新数组,新数组长度为len+1
Object[] newElements = Arrays.copyOf(elements, len + 1);
//给新数组末尾元素赋值
newElements[len] = e;
//用新的数组替换掉原来的数组
setArray(newElements);
return true;
} finally {
lock.unlock();//释放锁
}
其实也非常简单,就是在访问的时候加锁,拷贝出来一个副本,先操作这个副本,再把现有的数据替换为这个副本。
【读】,主要是查 get(int index)这个方法,普通的无锁访问
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
3.优点和缺点
3.1、优点
- 对于一些读多写少的数据,写入时复制的做法就很不错,例如配置、黑名单、物流地址等变化非常少的数据,这是一种无锁的实现。可以帮我们实现程序更高的并发。
- CopyOnWriteArrayList 并发安全且性能比 Vector 好。Vector 是增删改查方法都加了synchronized 来保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而 CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于 Vector。
3.2、缺点
- 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)
- 数据一致性问题。CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据。
- 写的时候不能并发写,需要对写操作进行加锁;
4.总结
- 面对事物,要知其然知其所以然。只有了解内部原理,才能更好的去使用它。
- 对于CopyOnWriteArrayList这种并发安全的类,如果不合理(不规范的、错误的)的使用,也会导致并发安全问题。在CopyOnWriteArrayList代码中可以看到,当遇到修改操作时,基本都离不开Arrays.copyOf,这种拷贝会占用额外一倍的内存空间。如果有大量频繁的修改操作,显然是不太合适的。