并发容器之CopyOnWriteArrayList

1. CopyOnWriteArrayList的简介

java学习者都清楚ArrayList并非线程安全的,在读线程在读取ArrayList的时候若是有写线程在写数据的时候,基于fast-fail机制,会抛出ConcurrentModificationException异常,也就是说ArrayList并非一个线程安全的容器,固然您能够用Vector,或者使用Collections的静态方法将ArrayList包装成一个线程安全的类,可是这些方式都是采用java关键字synchronzied对方法进行修饰,利用独占式锁来保证线程安全的。可是,因为独占式锁在同一时刻只有一个线程可以获取到对象监视器,很显然这种方式效率并非过高。html

回到业务场景中,有不少业务每每是读多写少的,好比系统配置的信息,除了在初始进行系统配置的时候须要写入数据,其余大部分时刻其余模块以后对系统信息只须要进行读取,又好比白名单,黑名单等配置,只须要读取名单配置而后检测当前用户是否在该配置范围之内。相似的还有不少业务场景,它们都是属于读多写少的场景。若是在这种状况用到上述的方法,使用Vector,Collections转换的这些方式是不合理的,由于尽管多个读线程从同一个数据容器中读取数据,可是读线程对数据容器的数据并不会发生发生修改。很天然而然的咱们会联想到ReenTrantReadWriteLock(关于读写锁能够看这篇文章),经过读写分离的思想,使得读读之间不会阻塞,无疑若是一个list可以作到被多个读线程读取的话,性能会大大提高很多。可是,若是仅仅是将list经过读写锁(ReentrantReadWriteLock)进行再一次封装的话,因为读写锁的特性,当写锁被写线程获取后,读写线程都会被阻塞。若是仅仅使用读写锁对list进行封装的话,这里仍然存在读线程在读数据的时候被阻塞的状况,若是想list的读效率更高的话,这里就是咱们的突破口,若是咱们保证读线程不管何时都不被阻塞,效率岂不是会更高?java

Doug Lea大师就为咱们提供CopyOnWriteArrayList容器能够保证线程安全,保证读读之间在任什么时候候都不会被阻塞,CopyOnWriteArrayList也被普遍应用于不少业务场景之中,CopyOnWriteArrayList值得被咱们好好认识一番。编程

2. COW的设计思想

回到上面所说的,若是简单的使用读写锁的话,在写锁被获取以后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,即读线程任什么时候候都是获取到最新的数据,知足数据实时性。既然咱们说到要进行优化,必然有trade-off,咱们就能够牺牲数据实时性知足数据的最终一致性便可。而CopyOnWriteArrayList就是经过Copy-On-Write(COW),即写时复制的思想来经过延时更新的策略来实现数据的最终一致性,而且可以保证读线程间不阻塞。数组

COW通俗的理解是当咱们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,而后新的容器里添加元素,添加完元素以后,再将原容器的引用指向新的容器。对CopyOnWrite容器进行并发的读的时候,不须要加锁,由于当前容器不会添加任何元素。因此CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是经过在写的时候针对的是不一样的数据容器来实现的,放弃数据实时性达到数据的最终一致性。安全

3. CopyOnWriteArrayList的实现原理

如今咱们来经过看源码的方式来理解CopyOnWriteArrayList,实际上CopyOnWriteArrayList内部维护的就是一个数组多线程

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
复制代码

而且该数组引用是被volatile修饰,注意这里仅仅是修饰的是数组引用,其中另有玄机,稍后揭晓。关于volatile很重要的一条性质是它可以够保证可见性,关于volatile的详细讲解能够看这篇文章。对list来讲,咱们天然而然最关心的就是读写的时候,分别为get和add方法的实现。并发

3.1 get方法实现原理

get方法的源码为:app

public E get(int index) {
    return get(getArray(), index);
}
/**
 * Gets the array.  Non-private so as to also be accessible
 * from CopyOnWriteArraySet class.
 */
final Object[] getArray() {
    return array;
}
private E get(Object[] a, int index) {
    return (E) a[index];
}
复制代码

能够看出来get方法实现很是简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有CAS操做等等,缘由是,全部的读线程只是会读取数据容器中的数据,并不会进行修改。post

3.2 add方法实现原理

再来看下如何进行添加数据的?add方法的源码为:性能

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

add方法的逻辑也比较容易理解,请看上面的注释。须要注意这么几点:

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

4. 总结

咱们知道COW和读写锁都是经过读写分离的思想实现的,但二者仍是有些不一样,能够进行比较:

COW vs 读写锁

相同点:1. 二者都是经过读写分离的思想实现;2.读线程间是互不阻塞的

不一样点:对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说若是使用读写锁依然会出现读线程阻塞等待的状况。而COW则彻底放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,所以读线程不会存在等待的状况

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

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

假设COW的变化以下图所示:

最终一致性的分析.png

数组中已有数据1,2,3,如今写线程想往数组中添加数据4,咱们在第5行处打上断点,让写线程暂停。读线程依然会“不受影响”的能从数组中读取数据,但是仍是只能读到1,2,3。若是读线程可以当即读到新添加的数据的话就叫作能保证数据实时性。当对第5行的断点放开后,读线程才能感知到数据变化,读到完整的数据1,2,3,4,而保证数据最终一致性,尽管有可能中间间隔了好几秒才感知到。

这里还有这样一个问题: 为何须要复制呢? 若是将array 数组设定为volitile的, 对volatile变量写happens-before读,读线程不是可以感知到volatile变量的变化

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

这也是为何concurrentHashMap只具备弱一致性的缘由,关于concurrentHashMap的弱一致性能够看这篇文章

COW的缺点

CopyOnWrite容器有不少优势,可是同时也存在两个问题,即内存占用问题和数据一致性问题。因此在开发的时候须要注意一下。

  1. 内存占用问题:由于CopyOnWrite的写时复制机制,因此在进行写操做的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会建立新对 象添加到新容器里,而旧容器的对象还在使用,因此有两份对象内存)。若是这些对象占用的内存比较大,比 如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候颇有可能形成频繁的minor GC和major GC。

  2. 数据一致性问题:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。因此若是你但愿写入的的数据,立刻能读到,请不要使用CopyOnWrite容器。

参考资料

《java并发编程的艺术》

COW讲解

相关文章
相关标签/搜索