我们知道 ArrayList 非线程安全,需要自己加锁或者使用 Collections.synchronizedList 包装.
从JDK1.5开始JUC里提供了使用 CopyOnWrite 机制实现的并发容器线程安全的 List - CopyOnWriteArrayList,简称 COW
CopyOnWrite 写时复制. 一般来说就是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器复制出一个新的容器,往新的容器里添加元素,添加完元素之后,再将原容器引用指向新容器. 即一开始大家都在共享同一内容,当有人想修改该内容时,才会真地把内容copy出去形成一个新的内容然后再改,这是一种延时懒惰策略.
1.2 设计优点可并发读 CopyOnWrite 容器,而无需加锁,因为当前容器不会添加任何元素. 所以这也是一种读写分离的思想,读写的是不同的容器.
2 继承体系-
和 ArrayList 的继承体系类似
-
保护所有更改器的锁
-
仅能通过getArray / setArray访问的数组
-
lock 内存偏移量
-
创建一个空 list
-
创建一个列表,该列表包含指定集合的元素,其顺序由集合的迭代器返回。
-
创建一个保存给定数组副本的列表
下面开始看源码,到底是如何实现写时复制的.
5 add(E e)向 COW 里添加元素,是需要加锁的,否则并发写时 copy 出N个副本!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
publicbooleanadd(E e) {
finalReentrantLock lock = this.lock;
// 1.加锁
lock.lock();
try{
// 得到原数组
Object[] elements = getArray();
intlen = elements.length;
// 2.复制出新数组,加一是因为要添加一个元素
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 把新元素添加到新数组里,直接放在数组尾部
newElements[len] = e;
// 把原数组引用指向新数组
setArray(newElements);
returntrue;
} finally{
// finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁
lock.unlock();
}
}
getArray-
获取数组.非priavte,以便也可以从CopyOnWriteArraySet类(直接组合了CopyOnWriteArrayList作为成员变量)访问
-
将引用设置到新数组
- volatile 修饰的是数组引用!简单的在原来数组修改几个元素的值,这种操作是无法发挥可见性的,必须通过修改数组内存地址
- 在新数组上执行 copyOf,对原数组无任何影响,只有新数组完全拷贝完成之后,外部才能访问,避免了原数组数据变动可能造成的不良影响
-
读指定位置元素
读时无需加锁,如果读时其它线程正在向ArrayList添加数据,读还是只会读到旧数据,因为写时并不会锁住旧的数组.
7 remove 7.1 指定索引删除1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
publicE remove(intindex) {
finalReentrantLock lock = this.lock;
// 加锁
lock.lock();
try{
Object[] elements = getArray();
intlen = elements.length;
// 先得到旧值
E oldValue = get(elements, index);
intnumMoved = len - index - 1;
// 如果要删除的数据正好是数组的尾部,直接删除
if(numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else{
// 若删除的数据在数组中间:
// 1. 设置新数组的长度减一,因为是减少一个元素
// 2. 从 0 拷贝到数组新位置
// 3. 从新位置拷贝到数组尾部
Object[] newElements = newObject[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
returnoldValue;
} finally{
lock.unlock();
}
}
依旧三板斧:
- 加锁
- 根据删除索引的位置,进行不同策略拷贝
- 解锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
publicbooleanremoveAll(Collection c) {
if(c == null) thrownewNullPointerException();
finalReentrantLock lock = this.lock;
lock.lock();
try{
Object[] elements = getArray();
intlen = elements.length;
if(len != 0) {
// newlen 表新数组的索引位置,新数组中存在不包含在 c 中的元素
intnewlen = 0;
Object[] temp = newObject[len];
// 循环,把不包含在 c 里面的元素,放到新数组中
for(inti = 0; i < len; ++i) {
Object element = elements[i];
// 不包含在 c 中的元素,从 0 开始放到新数组中
if(!c.contains(element))
temp[newlen++] = element;
}
// 拷贝新数组,变相的删除了不包含在 c 中的元素
if(newlen != len) {
setArray(Arrays.copyOf(temp, newlen));
returntrue;
}
}
returnfalse;
} finally{
lock.unlock();
}
}
并非直接对数组元素逐个删除,而先对数组值循环判断,将无需删除的数据放到临时数组,最后临时数组中的数据就是我们不需要删除的数据.
8 总结CopyOnWrite 并发容器适用于读多写少的并发场景.CopyOnWrite容器有很多优点,但同时也存在问题,开发时候需要注意:
内存占用问题写时,内存里会同时驻存两个对象的内存,旧对象和新写入对象(复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存).若这些对象占用内存较大,很可能造成频繁GC,应用响应时间也变长. 针对该问题,可通过压缩容器中元素,减少大对象的内存,或者直接不使用CopyOnWrite容器,而使用其他并发容器,如ConcurrentHashMap。
CopyOnWriteArrayList 之殇再比如一段简单的非 DB操作的业务逻辑,时间消耗却超出预期时间,在修改数据时操作本地缓存比回写DB慢许多。原来是有人使用了CopyOnWriteArrayList缓存大量数据,而该业务场景下数据变化又很频繁。 CopyOnWriteArrayList虽然是一个线程安全版的ArrayList,但其每次修改数据时都会复制一份数据出来,所以只适用读多写少或无锁读场景。 所以一旦使用CopyOnWriteArrayList,一定是因为场景适宜而非炫技。
CopyOnWriteArrayList V.S 普通加锁ArrayList读写性能-
测试并发写性能
-
测试结果:高并发写,CopyOnWriteArray比同步ArrayList慢百倍
-
测试并发读性能
-
测试结果:高并发读(100万次get操作),CopyOnWriteArray比同步ArrayList快24倍
高并发写时,CopyOnWriteArrayList为何这么慢呢?因为其每次add时,都用Arrays.copyOf创建新数组,频繁add时内存申请释放性能消耗大。
数据一致性问题CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性,请酌情使用.