Java中的CopyOnWrite

什么是CopyOnWrite

CopyOnWrite(COW),写时复制。
其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略。
通俗的理解是当我们往一个CopyOnWrite容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

CopyOnWrite在Java中的应用

通过搜索我们可以发现有三个结果(JDK1.8),分别是

  • CopyOnWriteArrayList
  • CopyOnWriteArraySet
  • CopyOnWriteMap

copyonwrite-search

其中CopyOnWriteMap时Sun公司自己的实现,并不属于JDK;
我们就通过分析一下CopyOnWriteArrayList的源码看看和ArrayList的区别。

首先通过构造方法,我们就会发现和平时的容器构造方法好像有点不一样:没有生成指定容器大小的构造方法。
为什么要这么做?如果不能生成指定指定大小的容器,add()方法肯定会受到影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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();
}
}

果然,add()方法和ArrayList的add()方法不一样:

  • 多了一把锁
  • 每次添加都要copy一次

为什么要加锁?
CopyOnWrite容器一般应用在多线程的条件下。如果不加锁,有五个线程同时添加元素,那么内存中就会用五份拷贝。这样不仅不能保证多线程下的安全性也浪费了大量的内存空间。

为什么每次添加元素都要copy呢?
要解释这个问题,我们得要它的构造方法说起。通过前面的分析,我们知道它的构造方法是不能构造指定大小的容器。来看一下默认构造方法:

1
2
3
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}

只是生成了一个大小为0的数组!结合另外两个构造方法可以发现,所有的构造方法保证数组中的每个元素都不为null。我推断出这样做的原因为了:

  • 不浪费内存
  • 保证多线程下的安全性

因为无论是添加、删除还是迭代操作,为了能够在多线程下保证安全性都是先对快照操作然后再替换掉原来的数组。如此以来,数组中空余的部分变得毫无用处而且还浪费了宝贵的内存空间!
现在就可以很轻松地回答这个问题了:copy是为了将容器的读写操作分离开来,在添加元素到拷贝上时,读操作依然可以并发进行,这也就是为什么叫CopyOnWrite啦~

CopyOnWrite的不足

CopyOnWrite有很多好处,例如在读多写少且对数据的一致性要求不是很高的时候可以考虑采用CopyOnWrite容器,应用场景如:网站黑名单,商品类目的存储,但是我们也要注意到它不足的地方:

  • 内存占用问题
    假如元素组有一百兆的数据,add一百兆的数据,这时候内存中就会存在三百兆数据。删除操作也存着类似问题

  • 数据的一致性
    因为添加、删除都是先对快照进行操作,所以就有可能出现数据不一致的情况,编程的时候要注意到这一点!

  • 性能问题
    添加操作会调用Arrays.copyOf(),拷贝整个数组;而删除操作更耗时,将数组中的元素一个一个拷贝。
    解决办法是尽量批量操作,避免单个操作。

通过分析,其实CopyOnWrite并不是想象中的那么高深,理解它的思想分析起来就轻松啦~

参考

聊聊并发-Java中的Copy-On-Write容器