面试官问什么是COW
"靠?我来面试你怎么骂人呢?"。
面试官:"不,不是那个靠。是英文COW。"
"......母牛?"。
面试官汗颜......"你知道写时复制容器么?"
通过这个小例子(非真实存在),加深一下各位对这个简称的印象。
Copy-On-Write简称COW。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。
核心代码
public E set(int index, E element) { final ReentrantLock lock = this.lock; lock.lock();//获得锁 try { Object[] elements = getArray();//得到目前容器数组的一个副本 E oldValue = get(elements, index);//获得index位置对应元素目前的值 if (oldValue != element) { int len = elements.length; //创建一个新的数组newElements,将elements复制过去 Object[] newElements = Arrays.copyOf(elements, len); //将新数组中index位置的元素替换为element newElements[index] = element; //这一步是关键,作用是将容器中array的引用指向修改之后的数组,即newElements setArray(newElements); } else { //index位置元素的值与element相等,故不对容器数组进行修改 setArray(elements); } return oldValue; } finally { lock.unlock();//解除锁定 } }
我们可以看到,在set方法中,我们首先是获得了当前数组的一个拷贝获得一个新的数组,然后在这个新的数组上完成我们想要的操作。当操作完成之后,再把原有数组的引用指向新的数组。
这个过程我们发现加了锁。原因数组拷贝的过程并不是一个原子操作,因此需要写写分离。
反观读操作,当写操作完成的那一瞬间,再把原有数组的引用指向新的数组可以视为原子操作。因此任何的读操作都不用加锁,保证读取到的是读那一刻List完整的快照数据。
无论List本身如何变化,迭代器能感知到的都是它在被创建那一刻时List的状态,任何其他线程对List的改变,对本迭代器都不可见。不会出现ConcurrentHashMap的迭代器可能读取到其他线程修改过程中容器的中间状态的情况。由于CopyOnWriteArrayList读操作无法感知最新正在变化的数据,所以和ConcurrentHashMap一样CopyOnWriteArrayList也是弱一致性的。
总结
我们来对比最常见的并发容器ConcurrentHashMap来总结:
- ConcurrentHashMap和CopyOnWriteArrayList都是无锁化的读取,所以读操作发生时无法确保目前所有其他线程的写操作已经完成,不可用于要求数据强一致性的场景。
- ConcurrentHashMap和CopyOnWriteArrayList都可以保证读取时可以感知到已经完成的写操作。
- ConcurrentHashMap读操作可能会感知到同一时刻其他线程对容器写操作的中间状态。CopyOnWriteArrayList永远只会读取到容器在读取时刻的快照状态。
- ConcurrentHashMap使用锁分段技术,缩小锁的范围,提高写的并发量。CopyOnWriteArrayList使用写时复制技术,保证并发写入数据时,不会对已经开启的读操作造成干扰。我们不难发现CopyOnWriteArrayList的锁粒度是相当大的,因此并发量过大的场景中,写的效率很差。
- ConcurrentHashMap适用于高并发下对数据访问没有强一致性需求的场景。CopyOnWriteArrayList适用于在对并发容器中,元素耦合度较强的场景,可以保证每一个快照中的数据都是一致的。如果当并发容器中的元素过多时,复制的内存成本会过高,且复制效率会进一步下降。
在实际开发中,可以根据业务场景选择并发容器。