1. 在学习COW时,先强调两点:
-
JAVA中“=”操作只是将引用和某个对象关联,假如同时有一个线程将引用指向另外一个对象,一个线程获取这个引用指向的对象,那么他们之间不会发生ConcurrentModificationException,他们是在虚拟机层面阻塞的,而且速度非常快,几乎不需要CPU时间。
-
JAVA中两个不同的引用指向同一个对象,当第一个引用指向另外一个对象时,第二个引用还将保持原来的对象。
2. 常用集合在多线程下的问题:
代码:
public class TestCopyOnWriteArrayList {
public static void main(String[] args) {
List list = new ArrayList();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0,5));
System.out.println(list);
}, String.valueOf(i)).start();
}
}
}
可以看到报错:并发修改异常
2.1 解决方案:
- 使用Vector类
List list = new Vector(); - 使用Collections的方法:
List list = Collections.synchronizedList(new ArrayList<>()); - 使用JUC中的集合类
3. CopyOnWriteArrayList:
在学习次list之前,我们先理解一个概念:CopyOnWrite:
3.1 CopyOnWrite:写入时复制
写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种优化策略。其核心思想是,如果有多个调用者(Callers)同时要求相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者视图修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
简单点说就是:增删改操作会将底层数组拷贝一份,更新操作在新数组上执行,这时不影响其他线程并发读,读写分离。
接下来,我们来看看CopyOnWriteArrayList的源码:
3.2 CopyOnWriteArrayList的数组对象:
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
可以看到,它是被volatile修饰的,来保证这个数组对象的可见性,关于volatile可以查看:volatile详解,这里不多解释了。
3.3 写操作
这里以add()方法为例,更多的就不多说了,源码也相对简单,这里主要说设计思想:
// JDK13,JDK8用的可重入锁
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;
}
}
从add方法可以看到,我们的新增操作是加了synchronized锁的,具体我也不清楚在什么时候变成了synchronized锁,但是在jdk8的时候还是用的ReentrantLock锁。
接着将数组对象复制了一份副本,用于操作(增删改),操作完成之后,再将副本的数组写入到数组对象中,由于数组对象添加了volatile关键字,所以此修改会立即被其他线程发现。
3.4 读操作:
get()方法:
public E get(int index) {
return elementAt(getArray(), index);
}
static <E> E elementAt(Object[] a, int index) {
return (E) a[index];
}
可以看到并没有加锁,这是由于数组对象加了volatile关键字,读到的一定是最新的数据。
由于读操作没有加锁,所以CopyOnWriteArrayList集合在读取数据方面相比于Vector和Collections.synchronizedList()来说要快很多。
3.5 最重要的问题:为什么要写时复制?
这个问题困扰了我一会,明明已经加锁了,并且还加了volatile关键字,为什么还要写时复制,直接操作不行么?
关于这个问题,就不得不提到 ConcurrentModificationException。ArrayList 在 for-each 循环中删除元素时,就会抛出 ConcurrentModificationException。其表面原因是该操作破坏了 ArrayList 内部维持的一系列状态,最后在检查中不通过,导致报错。而这样设计的本质原因,还是在于保障并发读写的安全性,因为迭代期间对底层数组进行了并发修改,这样很可能会导致不可预期的错误。ArrayList 为了防止这一问题,就会在迭代期间进行检测,如果发现了有并发读写现象,就会抛出这个 ConcurrentModificationException,这是一种 fail-fast(快速失败)机制。
再回到 CopyOnWriteArrayList 的问题。CopyOnWriteArrayList 的写操作进行了加锁。如果 CopyOnWriteArrayList 只有写操作,那么这里确实只通过加锁就可以保证安全,不需要进行复制。但是 CopyOnWriteArrayList 还有读操作,而且大多数情况下,List 都是读多写少的。所以这里本质上也依然是并发读写的问题:
- 若没有复制,写时加锁,读时不加锁,那么就会发生并发读写问题,产生不可预期的异常,即上面说的 ConcurrentModificationException;
- 若没有复制,写时加锁,读时也需要加锁,这样就相当于退化为 SynchronizedList,读性能大大减弱。
而写时复制,则可以很好的处理并发读写问题,而且还保障了性能:
- 写时加锁,不会产生并发写的问题,保证了写操作的安全性;
- 实际的写操作,是在复制的新数组上进行;而同一时刻的读操作,是在原数组进行的,所以这里的读操作不会产生并发读写问题,也不需要加锁;
- 新数组操作完成后,将原数组替换,这里则是通过 volatile 关键字保障了新数组的线程可见性。
这样,引入写时复制的原因就说清楚了。实际上,这是 volatile、锁、写时复制三者共同作用的结果,既保证了并发读写的安全性,也保证了读的性能,三者缺一不可,可谓精妙。
一句话回答:由于读没有加锁,而volatile并不保证原子性,写的过程中的数据修改会被其他线程看到。
3.6 存在的不足:
正由于上述的复制操作,导致了其存在以下问题:
- 存在内存占用的问题,因为每次对容器结构进行修改的时候都要对容器进行复制,这么一来我们就有了旧有对象和新入的对象,会占用两份内存。如果对象占用的内存较大,就会引发频繁的垃圾回收行为,降低性能;
- CopyOnWrite只能保证数据最终的一致性,不能保证数据的实时一致性。
比如:一个线程正在对容器进行修改,另一个线程正在读取容器的内容,这其实是两个容器数组。那么读线程读到的是旧数据
3.6.1 弱一致性:
迭代器弱一致性:
所以CopyOnWriteArrayList 集合比较适用于读操作远远多于写操作的场景。
3.7 相比vector和Collections.synchronizedList()的优势:
读操作的性能更加优越些,因为CopyOnWriteArrayList的读操作是通过volatile实现的,并没有加锁,而其他的方法都是用加锁实现的。
加锁意味着阻塞,阻塞意味着性能低下。
4. CopyOnWriteArraySet
思想类似,这里不多解释了。
参考:
https://patchouli-know.com/2020/05/16/copy-on-write-array-list/
https://www.jianshu.com/p/5f570d2f81a2