引言
写时复制的含义是当容器发生修改操作时,如add() 等,就会将原来的容器整体复制一份,这个过程是加锁的。而如果只是读取资源,例如 get() ,就不会受到任何同步要求的限制。
写时复制的理念是,如果多个读取线程请求相同的数据,它们会共享相同的数据,而不需要考虑并发修改的问题不得不在线程内部生成一份数据副本;当容器发生修改操作时,系统这时才会真正复制一个副本给其他请求者,也就是说,写时复制的理念最主要是解决访问者需要考虑并发修改的问题,有了这种机制,就可以避免生成线程副本或加锁访问,只要容器没有被修改,就不会产生任何数据副本。在很大程度上提高了读的性能。
但缺点显而易见,如果容器依然有大量的修改操作,或读写比不高的话,使用写时复制容器只会降低程序性能。
一、CopyOnWriteArrayList
1.1 写入效率
public class T04_CopyOnWriteList {public static void runAndComputeTime(Thread[] ths) {long s1 = System.currentTimeMillis();Arrays.stream(ths).forEach(t -> t.start());Arrays.stream(ths).forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});long s2 = System.currentTimeMillis();System.out.println(s2 - s1 + " ms");}public static void main(String[] args) {
// List<String> list = new CopyOnWriteArrayList<>(); // output:7565 ms
// List<String> list = new Vector<>(); // output:53 msList<String> list = Collections.synchronizedList(new ArrayList<>()); // output:52 msThread[] ths = new Thread[100];for (int i = 0; i < ths.length; i++) {Runnable task = () -> {for (int j = 0; j < 1000; j++) {list.add("a" + j);}};ths[i] = new Thread(task);}runAndComputeTime(ths);System.out.println(list.size());}
}
上述代码模拟了100个线程,每个线程向 list 中加入1000个字符串的操作。
runAndComputeTime() 方法先是启动线程,然后通过 join 方法,依次将所有线程合并到主线程中,这么做的目的主要是让全部线程的执行时间累加,从而得出一个总时间。
最后输出消耗时间和 list 存储数量,消耗时间不必多说, list 存储数量主要是看并发场景下线程安全性,不能出现“丢失数据”的情况,想一想 如果用 ArrayList,最终 size() 方法输出多少?
从输出结果来看,两个同步容器执行效率相当,都是 50 ~ 80 ms 左右,而CopyOnWriteArrayList 执行效率最长,达到了惊人的 7500 ms。
所以,如果不是确定写入操作极少,就一定不要使用 CopyOnWriteArrayList!
1.2 读取效率
public class T05_CopyOnWriteList {private static List<String> list = new CopyOnWriteArrayList<>(); // output:66ms
// private static List<String> list = new Vector<>();// output:956ms
// private static List<String> list = Collections.synchronizedList(new ArrayList<>());// output:873msstatic {for (int i = 0; i < 100000; i++) {list.add(String.valueOf(i));}}public static void main(String[] args) {Thread[] ths = new Thread[100];for (int i = 0; i < ths.length; i++) {Runnable task = () -> {for (int j = 0; j < list.size(); j++) {list.get(j);}};ths[i] = new Thread(task);}System.out.println("开始...");long t1 = System.currentTimeMillis();Arrays.stream(ths).forEach(t -> t.start());Arrays.stream(ths).forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});long t2 = System.currentTimeMillis();System.out.println(t2 - t1 + "ms");}
}
上述代码中,先通过 static 块初始化了一个 list,然后通过 100个线程并发读取 list 中的数据,最后通过 join 累加所有过程的执行时间,并输出消耗时间。
从执行结果来看,同步容器的执行效率在 800~900ms 左右,而 CopyOnWriteArrayList 可以达到惊人的 百毫秒以内,效率提升了十倍以上。
所以,如果确定一批数据的写入操作极少,而读取操作非常频繁的话,可以考虑使用 CopyOnWriteArrayList 容器,线程安全+读性能可观。
二、CopyOnWriteArrayList 源码分析
写时复制容器的写入操作包括 add 、set 等,实现逻辑几乎完全一致,以 add() 为例:
public boolean add(E e) {final ReentrantLock lock = this.lock;lock.lock();try {Object[] elements = getArray();int len = elements.length;Object[] newElements = Arrays.copyOf(elements, len + 1);newElements[len] = e;setArray(newElements);return true;} finally {lock.unlock();}}
容器内部维护了一个 ReentrantLock 作为锁的实现,在执行 add 操作时,先进行锁定。
然后取得底层数组,并拷贝一个长度 +1 的新数组,并将新元素放入最后。
将容器指针指向新的数组后,unlock 解锁。
由此可见,写入慢的原因就不言自明了,加锁、整个数组拷贝,这两个逻辑就是写入慢的真正的元凶。
我们再来看下读取的逻辑,以 get() 为例:
public E get(int index) {return get(getArray(), index);
}@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {return (E) a[index];
}
整个获取元素的过程未加任何锁,原因就是容器已经在修改的时候保证了同步逻辑,极大的提升了读取的效率。
总结
写时复制的观念就是在修改时复制容器的副本,从而避免在读取时需要考虑额外的并发修改问题。
写时复制容器的应用场景是写入操作极少,读取操作非常多的情况,切不可在包含大量写入操作的场景下使用 CopyOnWrite 。
Java 的写时复制容器实现是 CopyOnWriteArrayList,其底层就是一个定长数组,当容器发生修改时,会使用容器内的 ReentrantLock 上锁,并拷贝整个数组完成操作。
当发生读取时,可以像单线程那样不需要加任何同步机制,可以让多线程并发读取的效率达到最大。