class="hide-preCode-box">

- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
当往容器中添加数据时,并非直接将数据添加到原始数组中,而是创建一个长度比原始数组大一的数组 newElements
,将原始数组中的数据拷贝到 newElements
。然后将数据添加到 newElements
的末尾,最后修改 array
引用指向 newElements
。
除此之外,我们可以看到,为了保证写操作的线程安全性,避免两个线程同时执行写时复制,写操作通过加锁(lock.lock();
)来串行执行也就是说:读读、读写都可以并行执行,唯独写写不可以并行执行.
2.2.2、remove() 函数
remove()
函数的代码实现如下所示:
public E remove(int index) {
lock.lock();
try {
int len = array.length;
E oldValue = get(array, index);
int numMoved = len - index - 1;
if (numMoved == 0) {
array = Arrays.copyOf(array, len - 1);
} else {
Object[] newElements = new Object[len - 1];
System.arraycopy(array, 0, newElements, 0, index);
System.arraycopy(array, index + 1, newElements, index, numMoved);
array = newElements;
}
return oldValue;
} finally {
lock.unlock();
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
remove()
函数的处理逻辑跟 add()
函数类似:先通过加锁保证写时复制操作的线程安全性,然后申请一个大小比原始数组大小小一的新数组 newElements
。除了待删除数据之外,我们将原始数组中的其他数据统统拷贝到 newElements
,拷贝完成之后,我们将 array
引用指向 newElements
。
2.2.3、set() 函数
set()
函数的代码实现如下所示
public E set(int index, E element) {
lock.lock();
try {
E oldValue = get(array, index);
if (oldValue != element) {
int len = array.length;
Object[] newElements = Arrays.copyOf(array, len);
newElements[index] = element;
array = newElements;
}
return oldValue;
} finally {
lock.unlock();
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
在 set()
函数中,跟 add()
函数、remove()
函数的类似,通过加锁确保线程安全,在旧值与新值不同时复制底层数组并替换指定索引处的元素,最后更新数组引用并释放锁。
2.3、CopyOnWriteArraySet 的实现
CopyOnWriteArraySet
使用 CopyOnWriteArrayList
作为底层数据结构,通过写时复制的方式保证线程安全。
public class CopyOnWriteArraySet<E> extends AbstractSet<E> {
private final CopyOnWriteArrayList<E> al;
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}
public boolean add(E e) {
return al.addIfAbsent(e);
}
public boolean remove(Object o) {
return al.remove(o);
}
public boolean contains(Object o) {
return al.contains(o);
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
添加元素时,只有在元素不存在时才会添加;移除和检查元素的方法直接委托给底层的 CopyOnWriteArrayList
实现。整个实现确保了高并发环境下的安全性和一致性。
3、写实复制的特性
3.1、读多写少
从上述 CopyOnWriteArrayList
的源码和性能测试结果可以得出以下结论:
-
写操作需要加锁:所有的写操作(如 add
、set
、remove
等)都需要获取锁,确保线程安全性,因此这些操作只能串行执行;
-
写时复制:每次写操作都需要创建数组副本并进行数据拷贝,这涉及大量的数据搬移,导致写操作的执行效率非常低;
-
读多写少的场景:由于写操作的高开销,CopyOnWriteArrayList
适用于读多写少的应用场景。在这种场景下,读操作可以并发执行,且无需加锁。
以下是一个性能测试的示例代码,用于比较 CopyOnWriteArrayList
和 ArrayList
在执行大量写操作时的耗时:
public class Demo {
public static void main(String[] args) {
List<Integer> cowList = new CopyOnWriteArrayList<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
cowList.add(i);
}
System.out.println("CopyOnWriteArrayList耗时: " + (System.currentTimeMillis() - startTime) + " 毫秒");
List<Integer> list = new ArrayList<>();
startTime = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list.add(i);
}
System.out.println("ArrayList耗时: " + (System.currentTimeMillis() - startTime) + " 毫秒");
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
这里我执行的结果是:CopyOnWriteArrayList
执行 100000 次写操作耗时约 2098 毫秒。ArrayList
执行同样数量的写操作仅耗时约 2 毫秒。CopyOnWriteArrayList
的耗时是 ArrayList
的 1000 多倍,说明在写操作频繁的场景下,CopyOnWriteArrayList
的性能表现非常差。
3.2、弱一致性
CopyOnWriteArrayList
由于写时复制的特性,写操作的结果并不会立即对读操作可见。写操作在新数组上执行,而读操作在原始数组上执行,这就导致在 array
引用指向新数组之前,读操作只能读取到旧的数据。这种现象被称为弱一致性。
在示例代码中,存在两个线程:一个线程调用 add()
函数添加数据,另一个线程调用 sum()
函数遍历容器求和。
public class Demo {
private List<Integer> scores = new CopyOnWriteArrayList<>();
public void add(int idx, int score) {
scores.add(idx, score);
}
public int sum() {
int ret = 0;
for (int i = 0; i < scores.size(); i++) {
ret += scores.get(i);
}
return ret;
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
重复统计问题的产生:假设一个线程在执行 add(int idx, int score)
方法向 scores
列表中添加数据的同时,另一个线程在执行 sum()
方法遍历 scores
列表求和。这种情况下,可能会发生以下情况:
-
线程 A 执行 add()
方法:线程 A 调用 scores.add(idx, score)
方法,底层会创建一个新的数组并将原数组的内容复制到新数组,然后将新元素添加到新数组中;
-
线程 B 执行 sum()
方法:在 scores
列表的 array
引用更新之前,线程 B 开始遍历原数组;

- 写时复制导致的数据不一致:由于写时复制的特性,线程 A 操作的是新数组,而线程 B 读取的是旧数组。此时,如果线程 A 更新了
array
引用,指向了新数组,而线程 B 仍然在遍历旧数组,可能会产生数据不一致的问题。
假设 scores
列表中有 n
个元素,线程 A 在第 i
个位置添加新元素,而线程 B 正在遍历第 i
个元素。如果 array
引用在此时更新,指向了新数组,线程 B 会继续遍历旧数组并重复统计第 i
个元素。这就导致了 sum()
方法可能会多统计一次该元素的值,产生错误的求和结果。
迭代器实现与弱一致性问题的解决:CopyOnWriteArrayList
提供了一个专门的迭代器,用于遍历容器。这个迭代器在创建时,将原始数组赋值给 snapshot
引用,之后的遍历操作都是在 snapshot
上进行的。这样,即使 array
引用指向新的数组,也不会影响到 snapshot
引用继续指向原始数组,从而解决了弱一致性带来的问题。
以下是 CopyOnWriteArrayList
中迭代器的实现代码:
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
private final Object[] snapshot;
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
@SuppressWarnings("unchecked")
public E next() {
if (!hasNext()) throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
class="hide-preCode-box">
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
使用迭代器来重构 sum()
方法,使其在遍历过程中避免重复统计的问题。重构后的代码如下:
public int sum() {
int ret = 0;
Iterator<Integer> itr = scores.iterator();
while (itr.hasNext()) {
ret += itr.next();
}
return ret;
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
重构后的优点:
-
避免数据不一致:由于迭代器在创建时将原始数组赋值给 snapshot
,遍历操作都是在 snapshot
上进行,即使 array
引用指向新的数组,遍历过程中的数据也不会改变,从而避免了重复统计的问题;
-
线程安全:迭代器提供了一种线程安全的遍历方式,确保在高并发环境下能够正确读取数据;
-
简洁代码:使用迭代器使得遍历代码更加简洁和易读,同时保证了代码的正确性和性能。
3.3、连续存储
在本篇开头,我们提到了 JUC 提供了 CopyOnWriteArrayList
、CopyOnWriteArraySet
却没有提供 CopyOnWriteLinkedList
、CopyOnWriteHashMap
等其他类型的写时复制容器,这是出于什么样的考虑呢?
3.3.1、数组容器
在写时复制的处理逻辑中,每次执行写操作时,哪怕只添加、修改、删除一个数据,都需要大动干戈,把原始数据重新拷贝一份。如果原始数据比较大,那么对于链表、哈希表来说,因为数据在内存中不是连续存储的,因此拷贝的耗时将非常大,写操作的性能将无法满足一个工业级通用类对性能的要求。
而 CopyOnWriteArrayList
和 CopyOnWriteArraySet
底层都是基于数组来实现的,数组在内存中是连续存储的
JUC 使用 JVM 提供的 native
方法,如下所示,通过 C++ 代码中的指针实现了内存块的快速拷贝,因此写操作的性能在可接受范围之内。
而在平时的业务开发中,对于一些读多写少的业务场景,在确保性能满足业务要求的前提下,我们仍然可以使用写时复制技术来提高读操作性能。
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
3.3.3、非数组容器
JUC 没有提供非数组类型的写时复制容器,是出于对于一个工业级通用类的性能的考量对于非数组类型的容器,我们需要自己去开发相应的写时复制逻辑,
假设系统配置存储在文件中,在系统启动时,配置文件被解析加载到内存中的 HashMap
容器中,之后 HashMap
容器中的配置会频繁地被用到系统支持配置热更新,在不重启系统的情况下,我们希望能较实时地更新内存中的配置,让其跟文件中的配置保持一致
为了实现热更新这个功能,我们在系统中创建一个单独的线程,定时从配置文件中加载解析配置,更新到内存中的 HashMap
容器中
对于这样一个读多写少的应用场景,我们就可以使用写时复制技术,如下代码所示在更新内存中的配置时,使用写时复制技术,避免写操作和读操作互相影响。相对于 ConcurrentHashMap
来说,读操作完全不需要加锁,甚至连 CAS 操作都不需要,因此读操作的性能更高。
public class Configuration {
private static final Map<String, String> map = new HashMap<>();
public void reload() {
Map<String, String> newMap = new HashMap<>();
map = newMap;
}
}
class="hljs-button signin active" data-title="登录复制" data-report-click="{"spm":"1001.2101.3001.4334"}">
data-report-view="{"mod":"1585297308_001","spm":"1001.2101.3001.6548","dest":"https://lizhengi.blog.csdn.net/article/details/140125295","extend1":"pc","ab":"new"}">>
评论记录:
回复评论: