news 2026/3/2 18:10:25

CopyOnWriteArrayList 的故事--一起看看java原生的读写分离

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CopyOnWriteArrayList 的故事--一起看看java原生的读写分离

pyOnWriteArrayList 是JUC中,为了实现高并发而提供的list容器之一。

对于大部分的业务场景,都是读多写少,并发度也基本卡在了读的位置。

通常支持并发的容器在解决并发时,采用是:

(1)数据分割,每个线程只操作属于当前线程自己的数据,如ThreadLocal (感兴趣的同学可以看我前文 《Java内存模型及Java关键字 volatile的作用和使用说明》 https://www.cnblogs.com/jilodream/p/9452391.html)

(2)元数据被操作时,通过锁来进行并发控制。(如Hashtable 、concurrentHashMap 等)

今天要说的CopyOnWriteArrayList的思想是读写分离:

如图,他的思路大概是这样的,

cowlist

(0)提供一个数组来作为队列:

(1)所有的读操作直接读取数组,由于只有读操作,因此不会存在数据安全问题。

(2)所有的写操作是会将原始数据(数组)+改动操作(增删数据) 直接体现到另外一个新的数组中。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )在写操作完毕之前,替换为原有的,外部读可以访问的数组。

(3)由于写操作才会有并发的问题,因此所有的写操作是通过锁来隔离的。而读操作都是建立在一个完整的,不被改动的数组中的,因此读也就不再需要锁了。

先来看下命名

Copy 复制/拷贝

On 在...时间点

Write 写

Array 数组

List 队列

结合起来就是一个在写数据时进行拷贝的数组队列。一语道破该并发容器的核心。

怎么使用CopyOnWriteArrayList就不说了,他的常规使用与其他List并没有太大的区别。

我们直接来看源码(我这里采用的是JDK17):

首先来看定义

public class CopyOnWriteArrayList<E>

implements List<E>, RandomAccess, Cloneable, java.io.Serializable

定义中首先指定该容器依次实现了

List<E>, ---> 队列接口

RandomAccess, ---> 标记接口,支持随机访问

Cloneable, ---> 标记访问,支持拷贝

java.io.Serializable ---> 标记接口,支持可序列化

至于什么是标记接口,可以看我的这篇文章(为什么这些java接口没有抽象方法?浅谈Java标记接口 https://www.cnblogs.com/jilodream/p/5986519.html)。

(ps. 不知道大家发现没有代表能力的接口jdk往往喜欢加-able,表示可以的。这在代码简洁之道这本书中也是推荐的命名方式之一。)

我们接下来看下它的核心源码。

首先是元数据部分:

复制代码

/**

* The lock protecting all mutators. (We have a mild preference

* for builtin monitors over ReentrantLock when either will do.)

*/

final transient Object lock = new Object();

/** The array, accessed only via getArray/setArray. */

private transient volatile Object[] array;

复制代码

定义了一个Object lock作为全局锁。用final transient 来修饰。

final 是为了防止锁被乱改,导致出现安全问题。

transient 则是表示在自动序列化时,不要主动来序列化该字段。这里也是了为了数据安全考虑的,防止正反序列化后,实例中的lock对象暴露出去,导致有安全问题。

注意在JDK1.8中,锁的实现使用的是ReentrantLock。但是在后续的版本中由于synchronized锁的优化,又使用了syncronized锁。源码中的注释也有这样写:

/**

* The lock protecting all mutators. (We have a mild preference

* for builtin monitors over ReentrantLock when either will do.)

*/

当内置锁和 ReentrantLock 都能用的时候,我们略微倾向于使用内置锁。

定义了用来存放Object[] array;使用了transient volatile来修饰。

使用transient 是为了解决序列化的问题。这里就会有一个疑问,为什么支持序列化,(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )并且又要在array 前边修饰transient,防止它序列化?这是为何,我们序列化不就是为了转存数据么?

使用volatile修饰是为了保证array 发生变化时,所有直接使用的线程可以及时的感知到。这里需要对volatile有一定的认知才能明白,不太明白的同学请先了解

首先是构造方法:

public CopyOnWriteArrayList() {

setArray(new Object[0]);

}

注意看CopyOnWriteArrayList是将一个len=0的数组初始化到容器中的。而并不像ArrayList一样预留一个初始化大小的数组。这是为什么呢?

然后是核心的读方法:

复制代码

public E get(int index) {

return elementAt(getArray(), index);

}

static <E> E elementAt(Object[] a, int index) {

return (E) a[index];

}

复制代码

很简单,从指定的数组中直接读出对应位置的元素,返回

核心的四个写方法:

(1)队列添加全新元素

复制代码

public boolean add(E e) {

synchronized (lock) {

Object[] es = getArray();

int len = es.length;

es = Arrays.copyOf(es, len + 1);

es[len] = e;

setArray(es);

return true;

}

}

复制代码

1、写方法直接加了一把全局锁。

2、然后在同步的代码块中,将元数据加入到一个(比当前数组长度+1长度的)新数组中。

3、接着将新元素添加到队列的末尾中。

4、最后将新数组覆盖到原数组中。

5、由于原数组在定义时,使用了volatile关键字修饰。因此下次的读操作就会立刻感知到该元素,

(2)队列指定位置添加全新元素

复制代码

public void add(int index, E element) {

synchronized (lock) {

Object[] es = getArray();

int len = es.length;

if (index > len || index < 0)

throw new IndexOutOfBoundsException(outOfBounds(index, len));

Object[] newElements;

int numMoved = len - index;

if (numMoved == 0)

newElements = Arrays.copyOf(es, len + 1);

else {

newElements = new Object[len + 1];

System.arraycopy(es, 0, newElements, 0, index);

System.arraycopy(es, index, newElements, index + 1,

numMoved);

}

newElements[index] = element;

setArray(newElements);

}

}

复制代码

1、写方法直接加了一把全局锁。

2、然后判断当前添加位置的索引是否合法超出了len的合理范围,如果是,那么就抛出异常。

3、然后判断当前添加位置是不是数组的最后位置:

<1>如果是的话,就把原数组的所有元素拷贝到新数组的中。新数组的长度等于老数组+1。

<2>如果不是的话,就直接new 一个长度为len+1的数组。然后分别把老数组的前后两部分拷贝到新数组中。

4、将添加位置的元素设置为新元素。

(3)移出指定位置的元素

复制代码

public E remove(int index) {

synchronized (lock) {

Object[] es = getArray();

int len = es.length;

E oldValue = elementAt(es, index);

int numMoved = len - index - 1;

Object[] newElements;

if (numMoved == 0)

newElements = Arrays.copyOf(es, len - 1);

else {

newElements = new Object[len - 1];

System.arraycopy(es, 0, newElements, 0, index);

System.arraycopy(es, index + 1, newElements, index,

numMoved);

}

setArray(newElements);

return oldValue;

}

}

复制代码

1、移出方法首先加入一把全局锁

2、获取指定位置的元素

3、判断移出元素是不是队列的末尾:

如果是的话,(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )就把原数组的去除末尾元素的部分拷贝到新数组的中。新数组的长度等于老数组-1。

如果不是的话,就直接new 一个长度为len-1的数组。然后分别把老数组的前后两部分拷贝到新数组中。

4、返回查询到的指定元素

(4)移出指定元素

复制代码

public boolean remove(Object o) {

Object[] snapshot = getArray();

int index = indexOfRange(o, snapshot, 0, snapshot.length);

return index >= 0 && remove(o, snapshot, index);

}

private boolean remove(Object o, Object[] snapshot, int index) {

synchronized (lock) {

Object[] current = getArray();

int len = current.length;

if (snapshot != current) findIndex: {

int prefix = Math.min(index, len);

for (int i = 0; i < prefix; i++) {

if (current[i] != snapshot[i]

&& Objects.equals(o, current[i])) {

index = i;

break findIndex;

}

}

if (index >= len)

return false;

if (current[index] == o)

break findIndex;

index = indexOfRange(o, current, index, len);

if (index < 0)

return false;

}

Object[] newElements = new Object[len - 1];

System.arraycopy(current, 0, newElements, 0, index);

System.arraycopy(current, index + 1,

newElements, index,

len - index - 1);

setArray(newElements);

return true;

}

}

复制代码

1、先获取当前元素的位置

2、获取元素对应的位置(注意如果有多个相同的元素这里只取第一个找到的元素o),我们记为index

3、然后跳转到另外一个remove 重载的方法中。

4、在重载方法中加入全局锁

5、取出最新的数组current

6、判断新数组的current和之前查找时用的数组是不是一个数组:

<1>如果不是,说明有其他线程并发写操作了,此时开始重新找。

<2>首先在刚才index之前的元素都找一遍,如果找到,说明前移了,或者在index的更前方又插入该元素,此时这个新位置就是要移除的值。

<3>如果没找到又判断index>=新数组长度,那就说明是没有这个元素o了,直接就返回false了(因为上一步找的是index的位置,如果index都>=新数组长度,那么其实就已经遍历了)。

<4>如果还没有,那么判断index的位置是否仍等于元素o,如果是那么也判断找到了。

<5>如果还没有就判断index后边的位置是否有元素等于o

这里其实相当于分了3部分,如果发生了写操作就判断 index之前,index的位置,index之后,这三块分别有没有o出现。

7、直接new 一个长度为len-1的数组。然后分别把老数组的前后两部分拷贝到新数组中。

最后这个remove方法其实看的人觉得很繁琐,为什么他没有直接进来一把全局锁,然后遍历查找最后生成新数组即可。其实我觉得是可以的,jdk1.7也的确是这样的。但是在后续的版本中将预查找的部分移出了锁的范围。

锁的粒度更小。对写的操作更友好。

以上就是CopyOnWriteArrayList的内部核心实现了。

现在又回到最初的问题。(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )为什么元数组array的len始终和当前存储的元素数保持一致。而不是像arraylist等其他容器,给一定的预留空间方便,方便写呢?

我认为主要基于以下考虑:

CopyOnWriteArrayList本质是为了读写分离更准确的说是为了读多写少的场景设计的。尽可能的压缩数组也为数组拷贝提供了优势。

array是及时变量,每次使用都是全新new一个出来,并不是在原有基础上写,没有必要为了性能预留空间。(核心原因)

另外的一个疑问为什么支持序列化,又要在array 前边修饰transient,防止它序列化?这是为何,我们序列化不就是为了转存数据么?

这主要是由于CopyOnWriteArrayList是使用写单独拷贝的方式来处理并发的。而array前边又有volatile修饰,也就是说array一旦发生变化,那么所有使用的线程在下次使用时就会立刻感知到。倘若我们序列化进行中,突然发生了写操作,并已经完成,我是应该接着序列化(此时就有错误),还是重新序列化(性能太差)?

因此又单独实现了该方法配合序列化用:

复制代码

private void writeObject(java.io.ObjectOutputStream s)

throws java.io.IOException {

s.defaultWriteObject();

Object[] es = getArray();

// Write out array length

s.writeInt(es.length);

// Write out all elements in the proper order.

for (Object element : es)

s.writeObject(element);

}

复制代码

也就是我单独取出一份当前的数组放到es中,后边不管容器怎么写,我只序列化es中数据。

除此之外,队列的迭代也会有类似的问题。

我们知道队列的迭代,本质上就是使用迭代器依次的迭代元数据。而在CopyOnWriteArrayList中,和序列化相似,在迭代时,是将当前的元数组的引用指向到snapshot变量中。后续的迭代操作都是对该snapshot来操作的。

也就是说,在并发迭代时,我们迭代的数据是当前缓存的历史快照数据。如下:

复制代码

public Iterator<E> iterator() {

return new COWIterator<E>(getArray(), 0);

}

static final class COWIterator<E> implements ListIterator<E> {

/** Snapshot of the array */

private final Object[] snapshot;

/** Index of element to be returned by subsequent call to next. */

private int cursor;

COWIterator(Object[] es, int initialCursor) {

cursor = initialCursor;

snapshot = es;

}

....

}

复制代码

可以看这个例子:

复制代码

public class CopyOnWriteArrayListLearn {

public static void main(String[] args) {

CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList();

list.add(1);

list.add(2);

list.add(3);

list.add(4);

for (Integer item : list) {

list.add(item * 10);

System.out.println("item:" + item);

}

}

}

复制代码

输出如下,只输出了迭代开启一瞬间的容器中的元素:

Connected to the target VM, address: '127.0.0.1:61377', transport: 'socket'

item:1

item:2

item:3

item:4

Disconnected from the target VM, address: '127.0.0.1:61377', transport: 'socket'

同时,直接使用迭代器进行写操作时,(防盗连接:本文首发自http://www.cnblogs.com/jilodream/ )就会因为是快照数据而没有意义,所以源码中直接将迭代器的写操作抛出异常了:

复制代码

public void remove() {

throw new UnsupportedOperationException();

}

/**

* Not supported. Always throws UnsupportedOperationException.

* @throws UnsupportedOperationException always; {@code set}

* is not supported by this iterator.

*/

public void set(E e) {

throw new UnsupportedOperationException();

}

/**

* Not supported. Always throws UnsupportedOperationException.

* @throws UnsupportedOperationException always; {@code add}

* is not supported by this iterator.

*/

public void add(E e) {

throw new UnsupportedOperationException();

}

复制代码

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/22 2:22:43

如何用AI自动生成C++字符串处理代码?

快速体验 打开 InsCode(快马)平台 https://www.inscode.net输入框内输入如下内容&#xff1a; 请使用C的std::string实现以下功能&#xff1a;1)从用户输入读取一个字符串&#xff1b;2)统计字符串中每个字符出现的频率&#xff1b;3)将字符串中所有字母转为大写&#xff1b;4…

作者头像 李华
网站建设 2026/2/25 5:18:54

2025网络安全自学攻略:零基础构建系统化知识体系

前言 什么是网络安全 网络安全可以基于攻击和防御视角来分类&#xff0c;我们经常听到的 “红队”、“渗透测试” 等就是研究攻击技术&#xff0c;而“蓝队”、“安全运营”、“安全运维”则研究防御技术。 如何成为一名黑客 很多朋友在学习安全方面都会半路转行&#xff0c…

作者头像 李华
网站建设 2026/3/2 15:59:29

前端小白必看:模块化报错完全指南

快速体验 打开 InsCode(快马)平台 https://www.inscode.net输入框内输入如下内容&#xff1a; 制作一个交互式学习模块&#xff1a;1) 用动画演示ES模块和CommonJS的区别 2) 可交互修改的代码沙盒 3) 实时错误反馈系统 4) 渐进式练习题目。要求&#xff1a;a) 从最简单的scrip…

作者头像 李华
网站建设 2026/2/27 2:02:20

一篇就够了!网络安全零基础保姆级教程:从入门到精通系统指南

一、怎样规划网络安全 如果你是一个安全行业新人&#xff0c;我建议你先从网络安全或者Web安全/渗透测试这两个方向先学起&#xff0c; 一、是市场需求量高 二、则是发展相对成熟入门比较容易 值得一提的是&#xff0c;学网络安全&#xff0c;是先网络后安全&#xff1b;学Web…

作者头像 李华