CopyOnWriteArrayList
是什么?JAVA并发容器。
容器->读写操作。
- 顾名思义,就是装数据的,核心操作就是存和取,即:写和读。
并发->读写冲突。
- 潜台词意味着冲突,读和写操作自由组合有4种情况,分别是:
- 读读操作(1)
- 读写操作(2)
- 写读操作(3)
- 写写操作(4)
- (1)并不涉及数据一致性问题,即:读读无冲突,不存在数据的修改。
- (2)(3)(4)有冲突,需要解决数据一致性问题。
啥问题?真实应用场景读写比重情况各异。
- 真实应用场景读写比重是不确定的,可能是下面任意一种情况:
- 几乎全是读。
- 几乎全是写。
- 几乎读写相当。
- 读远多于写。
- 写远多于读。
怎么办?具体情况具体分析,避免冲突,兼顾性能。
- 当问题变得错综复杂,那就分而治之,具体情况具体分析!
- 避免冲突->第一印象是加锁!
- 兼顾性能->不同场景,针对性的解决方案!
- 试想:读远多于写的场景,大部分都是读操作,而读读操作是不涉及冲突的,加锁的方式是不是对于性能不那么友好?嗯,确实浪费,并发读你锁我干嘛!
- 试想:可是不锁你,当写操作发生的时候,冲突了怎么办?
- 矛盾点:读读的时候不想加锁,当涉及写操作的时候又想保证数据一致性,是不是有种分身乏术的感觉!
- 灵光一现:是的,就是分身。CopyOnWriteArrayList背后的思想就是分身,咱干脆不用锁了,直接写的时候copy个副本往副本中写,你读你的,我写你的分身,读写分离,互不影响。
实现细节?源码走起!
核心源码:
- java version “1.8.0_144”
- 一个线程安全的变量,所有变化的操作(增加、修改等)都是基于复制一份新的数组实现的。
- 复制看起来浪费,但在遍历操作远远多于变化操作的时候,你可以无需为锁定并发操作而烦恼!
- 针对遍历操作,有特定的引用指向固定的数组,专项用于遍历。
- 而变化的操作有其自身的对应数组用于改变。
- 数组元素可为空。
- 源码实现主要是数组的基础操作,下标\判空\增加元素\删除元素\排序等操作,凡是涉及数组变化的操作都是通过可重入锁进行锁定后复制出新的数据,再将全局持有指针(可见性)指向新的数组。
/** 可重入锁用于锁定变化操作 */
final transient ReentrantLock lock = new ReentrantLock();
/** 持有数组全局引用,保证可见性 */
private transient volatile Object[] array;
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] elements;
if (c.getClass() == CopyOnWriteArrayList.class)
elements = ((CopyOnWriteArrayList<?>)c).getArray();
else {
elements = c.toArray();
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elements.getClass() != Object[].class)
elements = Arrays.copyOf(elements, elements.length, Object[].class);
}
// 数组引用指向新的elements
setArray(elements);
}
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();
}
}
弊端缺陷:
保证最终一致性,不保证实时一致性。
- 由于涉及变化的操作是锁定的全局索引,变化是在分身中体现执行落地,而后切换全局索引指向新复制的数组,所以在切换全局索引期间,读取的还是复制前的数组元素,也就是说:分身思想实践存在不可避免的延迟,实际上也是可用性与一致性的舍得与权衡,让读的过程没有锁定机制,通过牺牲一致性保证高可用的性能优势.
- 读线程没有阻塞,高可用.
- 写线程有阻塞等待,有锁定机制.
- 读数据仅保证最终一致性.
占用内存资源相对较多,可能引起频繁GC.
- 写操作需要复制原容器,在新的复制容器中进行元素变化.
- 数据量大,内存压力大,资源浪费.
- 进而可能会引起频繁GC.
一句话小结:
- 分身的思想,将读写操作作用域分离,牺牲强一致性,提升并发读的高可用性.在读远多于写的场景,优势明显.