1. 简介

ArrayList 并不是线程安全的,在读线程在读取 ArrayList 的时候如果有写线程在写数据的时候,基于fast-fail机制,会抛出 ConcurrentModificationException 异常。

当然您可以用 Vector,或者使用 Collections 的静态方法将 ArrayList 包装成一个线程安全的类,但是这些方式都是采用 java 关键字 synchronzied 对方法进行修饰,利用独占式锁来保证线程安全的,单效率很低。

Doug Lea 大师就为我们提供 CopyOnWriteArrayList 容器可以保证线程安全,保证读读之间在任何时候都不会被阻塞。适用场景:读多写少;

2. COW 设计思想

如果简单的使用读写锁的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性

既然我们说到要进行优化,必然有 trade-off,我们就可以牺牲数据实时性满足数据的最终一致性即可。而 CopyOnWriteArrayList 就是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。

COW 通俗的理解是:

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器
  • 然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
  • CopyOnWrite 容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite 容器也是一种读写分离的思想
  • 延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。


3. CopyOnWriteArrayList 的实现原理

实际上 CopyOnWriteArrayList 内部维护的就是一个数组:

  1. /** The array, accessed only via getArray/setArray. */
  2. private transient volatile Object[] array;

并且该数组引用是被 volatile 修饰,注意这里仅仅是修饰的是数组引用,其中另有玄机,稍后揭晓。关于volatile很重要的一条性质是它能够保证可见性

3.1 get 方法实现原理

  1. public E get(int index) {
  2. return get(getArray(), index);
  3. }
  4. /**
  5. * Gets the array. Non-private so as to also be accessible
  6. * from CopyOnWriteArraySet class.
  7. */
  8. final Object[] getArray() {
  9. return array;
  10. }
  11. private E get(Object[] a, int index) {
  12. return (E) a[index];
  13. }

get 方法实现非常简单,几乎就是一个「单线程」程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有CAS操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。

3.2 add 方法实现原理

  1. public boolean add(E e) {
  2. final ReentrantLock lock = this.lock;
  3. //1. 使用 Lock,保证写线程在同一时刻只有一个
  4. lock.lock();
  5. try {
  6. //2. 获取旧数组引用
  7. Object[] elements = getArray();
  8. int len = elements.length;
  9. //3. 创建新的数组,并将旧数组的数据复制到新数组中
  10. Object[] newElements = Arrays.copyOf(elements, len + 1);
  11. //4. 往新数组中添加新的数据
  12. newElements[len] = e;
  13. //5. 将旧数组引用指向新的数组
  14. setArray(newElements);
  15. return true;
  16. } finally {
  17. lock.unlock();
  18. }
  19. }

需要注意这么几点:

  1. 采用 ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;
  2. 前面说过数组引用是volatile修饰的,因此将旧的数组引用指向新的数组,根据volatilehappens-before规则,写线程对数组引用的修改对读线程是可见的。
  3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。

4. 总结

我们知道COW和读写锁都是通过读写分离的思想实现的,但两者还是有些不同,可以进行比较:

3.1 COW VS 读写锁

  • 相同点:
    • 两者都是通过读写分离的思想实现的
    • 读线程之间互不阻塞
  • 不同点
    • 读写锁:为了实现数据的实时性,在写锁被获取后,读线程会等待。或者读锁获取后,写线程会等待,从而解决「脏读」的问题
    • COW:牺牲数据实时性(不能立即读到新增的数据)而保证数据最终一致性。即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况

对这一点从文字上还是很难理解,我们来通过 debug 看一下,add方法核心代码为:

  1. 1.Object[] elements = getArray();
  2. 2.int len = elements.length;
  3. 3.Object[] newElements = Arrays.copyOf(elements, len + 1);
  4. 4.newElements[len] = e;
  5. 5.setArray(newElements);

假设 COW 的变化如下图所示:
CopyOnWriteArrayList - 图1
数组中已有数据1,2,3,现在写线程想往数组中添加数据4,我们在第5行处打上断点,让写线程暂停。读线程依然会「不受影响」的能从数组中读取数据,可是还是只能读到 1,2,3。如果读线程能够立即读到新添加的数据的话就叫做能保证数据实时性。当对第5行的断点放开后,读线程才能感知到数据变化,读到完整的数据1,2,3,4,而保证数据最终一致性,尽管有可能中间间隔了好几秒才感知到。

这里还有这样一个问题: 为什么需要复制呢? 如果将 array 数组设定为 volitile 的, 对 volatile 变量写 happens-before 读,读线程不是能够感知到 volatile 变量的变化

原因是,这里 volatile 的修饰的仅仅只是数组引用数组中的元素的修改是不能保证可见性的。因此 COW 采用的是新旧两个数据容器,通过第5行代码将数组引用指向新的数组。

这也是为什么 concurrentHashMap 只具有弱一致性的原因,关于concurrentHashMap的弱一致性可以看这篇文章

4.2 COW 的优缺点

CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

  1. 内存占用问题:因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的 minor GC 和major GC。
  2. 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。