深度解析CopyOnWriteArrayList
一、线程安全的 List
目前比较常用的构建线程安全的List有三种方法:
- 使用Vector容器。(过时)
- 使用Collections的静态方法synchronizedList(List< T> list)。Collections.synchronizedList。
- 采用CopyOnWriteArrayList容器。
我们知道ArrayList是一种读取效率很高的集合,但是它是不安全的,比如它的add方法,在多线程并发情况下可能因为两个写操作的相互覆盖而丢失数据。
在JDK1.5之前,我们在Java开发中想要使用线程安全的List时只能使用Vector。Vector是一个古老的集合,它对于增删改查等方法基本都加了synchronized,虽然保证同步,但是这相当于对整个Vector都加上了一把大锁,每个方法执行的时候都要去获得锁,性能非常低下。
JDK1.5的时候出现的JUC包中提供了很多线程安全并且并发性能较好的容器,其中就有线程安全的List的实现,也是唯一的实现:CopyOnWriteArrayList。
注:Vector 类和 SynchronizedList 类这两个是在 java.util
包,CopyOnWriteArrayList在java.util.concurrent
包中
接下来我们通过对Vector类和SynchronizedList 类的简单分析来说明为什么需要CopyOnWriteArrayList。
1、Vector
接下来我们来看下线程安全的 Vector 的 size 和 get 方法的代码:
public synchronized int size() {
return elementCount;
}
// Vector 中的 get 操作添加了 synchronized
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
可以看出,Vector 内部是使用 synchronized 来保证线程安全的,并且锁的粒度比较大,都是方法级别的锁,在并发量高的时候,很容易发生竞争,并发效率相对比较低。在这一点上,Vector 和 Hashtable 很类似。
2.、Collections.synchronizedList(List< T> list)
它和Vector的区别在于它采用了同步代码块实现线程间的同步。通过分析源码,它的底层使用了新的容器包装原始的List。
下图是新容器的继承关系图:
synchronizedList方法:
public static <T> List<T> synchronizedList(List<T> list) {
//如果给定的列表 list 支持随机访问,即实现了 RandomAccess 接口,创建一个 SynchronizedRandomAccessList 对象,该对象是 list 的线程安全包装。否则,创建一个 SynchronizedList 对象,也是 list 的线程安全包装。
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
该方法主要用于将普通的列表转换为线程安全的列表。
SynchronizedList 类的 get 方法:
//get 方法接受一个索引 index,表示要获取的元素在列表中的位置。
public E get(int index) {
//使用关键字 synchronized 声明了一个同步块,括号中的 mutex 是一个互斥锁对象。
synchronized (mutex) {
//在同步块中,调用 list.get(index) 方法获取指定索引位置的元素。
return list.get(index);
}
}
这个方法通过在访问列表元素的过程中使用互斥锁(mutex
)来实现线程安全。可以保证在同一时间只有一个线程访问列表的指定位置,避免多个线程同时读取或写入导致的数据不一致性或冲突。
通过上面的分析可以看出,无论是读操作还是写操作,它都会进行加锁,当线程的并发级别非常高时就会浪费掉大量的资源,因此某些情况下它并不是一个好的选择。
而且这两个其实都是fail-fast的典型代表。即在迭代期间不允许编辑,如果在迭代期间进行添加或删除元素等操作,则会抛出 ConcurrentModificationException 异常,这样的特点在很多情况下会给使用者带来了麻烦。
对fail-fast不了解的同学可以参考这篇文章–(3条消息) Iterator_FailFast_FailSafe源码解析_如果我是枫的博客-CSDN博客
那么针对以上的这些问题,从 JDK5 开始,Java 并发包里提供了使用 CopyOnWrite 机制实现的并发容器 CopyOnWriteArrayList 。
二、概述
如上图是CopyOnWriteArrayList的继承关系。通过基础关系我们可以粗略知道CopyOnWriteArrayList的一些特点:
- 实现了List接口,具有List集合体系的公共特征,比如一系列通过索引操作集合元素的方法!
- 实现了RandomAccess标志性接口,标志着它支持快速随机访问,因此底层一定是使用数组来实现。
- 实现了Cloneable和Serializable标志性接口,支持克隆和序列化。
- 还支持null元素。
接一下来在探究 CopyOnWriteArrayList 的实现之前,我们不妨再思考一下,如果是你,你会怎么来实现一个线程安全的 List。
- 并发读写时该怎么保证线程安全?
- 数据要保证强一致性吗?数据读写更新后是否立刻体现?
- 初始化和扩容时容量给多少呢?
- 遍历时要不要保证数据的一致性?需要引入 Fail-Fast 机制吗?
通过类名我们大致可以猜测到 CopyOnWriteArrayList 类的实现思路:Copy-On-Write, 也就是写时复制策略;末尾的 ArrayList 表示数据存放在一个数组里。在对元素进行增删改时,先把现有的数据数组拷贝一份,然后增删改都在这个拷贝数组上进行,操作完成后再把原有的数据数组替换成新数组。这样就完成了更新操作。
但是这种写入时复制的方式必定会有一个问题,因为每次更新都是用一个新数组替换掉老的数组,如果不巧在更新时有一个线程正在读取数据,那么读取到的就是老数组中的老数据。
以上问题其实就是读写分离的思想,放弃数据的强一致性来换取性能的提升。那么我们该怎么解决呢?
为了方便读者了解什么是写时复制和读写分离思想,我们这里先做个拓展。
1、写时复制
写时复制(Copy-on-Write,简称COW)是一种用于实现并发安全性的策略。在写时复制中,当有多个并发操作要修改某个共享资源时,不直接进行原地修改,而是先创建该资源的一个副本(拷贝),然后在副本上进行修改操作。这样可以避免多个操作之间的冲突和竞争条件,从而提高并发性能和保证数据的一致性。
很明显,写时复制机制非常适合读多写少的并发场景。Redis使用RDB写快照备份的时候,就用到了Linux底层的Copy-On-Write技术,会fork出子进程并且与父进程共享内存空间,当父、子进程中有内存写入操作时,会复制一份内存页单独处理。
从JDK1.5开始Java并发包里也提供了两个使用COW机制实现的并发容器,它们就是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWriteArraySet,其底层是利用 CopyOnWriteArrayList 实现。
2、读写分离
读写分离是一种数据库架构设计模式,旨在提高系统的性能和可扩展性。它将数据库的读操作和写操作分离到不同的数据库实例或服务器上,从而有效地分摊了数据库的负载。
在传统的单一数据库架构中,读操作和写操作共享同一个数据库实例,当系统的读取请求较多时,会对数据库造成较大的压力,可能导致性能下降。为了解决这个问题,读写分离将读操作和写操作分离,通常将写操作集中在主数据库上,而读操作则分散到多个从数据库(或只读副本)上。主数据库负责处理写操作,并将数据同步到从数据库,而从数据库则负责处理读操作。
读写分离的基本原理是**通过复制数据库的数据到从数据库,使从数据库成为主数据库的只读副本。当有写操作时,只需在主数据库上进行,从数据库会定期或实时地同步主数据库的数据变更。**这样,读操作可以分摊到多个从数据库上,提高系统的并发处理能力和读取性能,同时减轻主数据库的负载压力。
读写分离的优点包括:
- 提高系统的并发能力和读取性能,从而提高系统的吞吐量和响应速度。
- 分摊数据库负载,减轻主数据库的压力,增加系统的可扩展性和稳定性。
- 可以灵活地根据业务需求和流量情况增加或减少从数据库的数量,实现弹性扩展。
然而,读写分离也存在一些问题:
- 数据的一致性:由于主从数据库之间存在数据复制的延迟,可能会导致读操作读取到过期的数据。在某些场景下,可能需要考虑数据的一致性和同步机制。
- 业务逻辑的调整:需要将读操作和写操作进行分离,并根据业务需求将读操作发送到从数据库。这可能需要对应用程序进行调整和优化。
- 数据库同步的性能和延迟:主从数据库之间的数据同步涉及网络传输和数据复制,可能会引入一定的性能损耗和延迟。
解决方案:
- 数据一致性问题:由于主从数据库之间存在数据复制的延迟,可能导致读操作读取到过期的数据。为了解决这个问题,可以采取以下措施:
- 强制读操作访问主数据库:对于对数据一致性要求较高的读操作,可以直接访问主数据库,而不是从数据库。这样可以确保读取到最新的数据,但会增加主数据库的负载。
- 引入数据同步机制:可以使用同步机制,如数据库主从同步延迟监控和数据同步策略调整,以确保从数据库尽快更新最新的数据。可以采用异步复制、半同步复制或同步复制等方式,并根据业务需求和数据一致性的要求进行配置和调整。
- 业务逻辑调整:读写分离需要将读操作和写操作进行分离,并根据业务需求将读操作发送到从数据库。这可能需要对应用程序进行调整和优化。以下是一些解决方法:
- 使用数据库中间件:引入数据库中间件,如MySQL Proxy、MyCAT、Cobar等,可以在应用程序与数据库之间增加一层代理,自动进行读写分离和负载均衡,减少对应用程序的修改。
- 使用ORM框架:使用ORM(对象关系映射)框架,如Hibernate、MyBatis等,可以通过配置和注解来实现读写分离,将读操作和写操作路由到不同的数据库实例。
- 数据库同步性能和延迟问题:主从数据库之间的数据同步涉及网络传输和数据复制,可能会引入一定的性能损耗和延迟。以下是一些解决方法:
- 网络优化:优化主从数据库之间的网络连接,确保网络稳定和高速,减少同步延迟。
- 数据复制方式选择:根据实际需求和性能要求,选择合适的数据复制方式。异步复制具有较低的同步延迟,但可能会有数据丢失的风险;同步复制具有较高的一致性,但会增加响应时间和负载。
- 数据库参数优化:调整数据库的参数配置,如主数据库的binlog格式、从数据库的复制线程数等,以优化数据同步的性能和延迟。
总的来说,读写分离是一种通过将读操作和写操作分离到不同数据库实例来提高系统性能和可扩展性的架构设计模式。它在大型应用系统中被广泛采用,以应对高并发读取请求和大数据量的场景。解决读写分离带来的问题需要根据根据具体的系统需求和实际情况进行选择和配置。
三、源码解析(JDK8)
上面已经说了,CopyOnWriteArrayList 的思想是写时复制,读写分离,它的内部维护着一个使用 volatile 修饰的数组,用来存放元素数据。
1、属性定义
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
// 在 Java 11 中,ReentrantLock 被替换为 synchronized 锁
final transient Object lock = new Object();
// 底层数组
// 疑问 1:为什么 array 要使用 volatile 关键字?
private transient volatile Object[] array;
那么此时我有一些疑问了。
疑问 1:为什么 array 要使用 volatile 关键字?
volatile 变量是 Java 轻量级的线程同步原语。目的是提供一种简单的同步机制,保证多线程间的可见性,避免出现脏读、写入冲突等问题。
原理是volatile 变量的读取和写入操作中会加入内存屏障,能够保证变量写入的内存可见性,保证一个线程的写入能够被另一个线程观察到。
CopyOnWriteArrayList 类中方法很多,这里不会一一介绍,下面会分析其中的几个常用的方法,这几个方法理解后基本就可以掌握 CopyOnWriteArrayList 的实现原理。
2、构造函数
CopyOnWriteArrayList 的构造函数一共有三个,一个是无参构造,直接初始化数组长度为 0;另外两个传入一个集合或者数组作为参数,然后会把集合或者数组中的元素直接提取出来赋值给 CopyOnWriteArrayList 内部维护的数组。
//疑问 2:为什么 CopyOnWriteArrayList 不提供初始化容量的构造器?
// 直接初始化一个长度为 0 的数组
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
// 传入一个集合,提取集合中的元素赋值到 CopyOnWriteArrayList 数组
public CopyOnWriteArrayList(Collection<? extends E> c) {
Object[] es;
if (c.getClass() == CopyOnWriteArrayList.class)
es = ((CopyOnWriteArrayList<?>)c).getArray();
else {
es = c.toArray();
if (c.getClass() != java.util.ArrayList.class)
es = Arrays.copyOf(es, es.length, Object[].class);
}
setArray(es);
}
// 传入一个数组,数组元素提取后赋值到 CopyOnWriteArrayList 数组
public CopyOnWriteArrayList(E[] toCopyIn) {
// 疑问 3:为什么要把 E[] 类型的入参转化为 Object[] 类型
setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}
构造函数是实例创建时调用的,没有线程安全问题,所以构造方法都是简单的赋值操作,没有特殊的逻辑处理。
疑问 2:为什么 CopyOnWriteArrayList 不提供初始化容量的构造器?
这是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。如果提供了带初始化容量的构造器,意味着开发者预期会一个个地写入数据,这不符合 CopyOnWriteArrayList 的正确使用方法。所以,不提供这个构造器才是合理的。
疑问 3:为什么要把 E[] 类型的入参转化为 Object[] 类型?
如果不转化数组类型,那么在 toArray() 方法返回的数组中插入 Object 类型对象时,会抛出 ArrayStoreException
。
3、新增元素
元素新增根据入参的不同有好几个,但是原理都是一样的,所以下面只贴出了 add(E e )
的实现方式,是通过一个 ReentrantLock 锁保证线程安全的。
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); // 拷贝一个数据数组,长度+1
newElements[len] = e; // 加入新元素
setArray(newElements); // 用新数组替换掉老数组
return true;
} finally {
lock.unlock();
}
}
具体步骤:
- 加锁,获取目前的数据数组开始操作(加锁保证了同一时刻只有一个线程进行增加 / 删除 / 修改操作)。
- 拷贝目前的数据数组,且长度增加一。
- 新数组中放入新的元素。
- 用新数组替换掉老的数组。
- finally 释放锁。
由于每次 add 时容量只增加了 1,所以每次增加时都要创建新的数组进行数据复制,操作完成后再替换掉老的数据,这必然会降低数据新增时候的性能。下面通过一个简单的例子测试 CopyOnWriteArrayList 、Vector、ArrayList 的新增和查询性能。
public static void main(String[] args) {
CopyOnWriteArrayList<Object> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
Vector vector = new Vector<>();
ArrayList arrayList = new ArrayList();
add(copyOnWriteArrayList);
add(vector);
add(arrayList);
get(copyOnWriteArrayList);
get(vector);
get(arrayList);
}
public static void add(List list) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list.add(i);
}
long end = System.currentTimeMillis();
System.out.println(list.getClass().getName() + ".size=" + list.size() + ",add耗时:" + (end - start) + "ms");
}
public static void get(List list) {
long start = System.currentTimeMillis();
for (int i = 0; i < list.size(); i++) {
Object object = list.get(i);
}
long end = System.currentTimeMillis();
System.out.println(list.getClass().getName() + ".size=" + list.size() + ",get耗时:" + (end - start) + "ms");
}
测试结果:
java.util.concurrent.CopyOnWriteArrayList.size=100000,add耗时:2756ms
java.util.Vector.size=100000,add耗时:4ms
java.util.ArrayList.size=100000,add耗时:3ms
java.util.concurrent.CopyOnWriteArrayList.size=100000,get耗时:4ms
java.util.Vector.size=100000,get耗时:5ms
java.util.ArrayList.size=100000,get耗时:2ms
从测得的结果中可以看到 CopyOnWriteArrayList 的新增耗时最久,其次是加锁的 Vector(Vector 的扩容默认是两倍)。而在获取时最快的是线程不安全的 ArrayList,其次是 CopyOnWriteArrayList,而 Vector 因为 Get 时加锁,性能最低。
疑问 4:在添加方法中,为什么扩容只增大 1 容量,而 ArrayList 会增大 1.5 倍?
这还是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。ArrayList 额外扩容 1.5 倍是为了避免每次 add 都扩容,而 CopyOnWriteArrayList 并不建议一个个地添加数据,而是建议批量操作写入数据,例如 addAll 方法。所以,CopyOnWriteArrayList 不额外扩容才是合理的。
另外,网上有观点看到 CopyOnWriteArrayList 没有限制数组最大容量,就说 CopyOnWriteArrayList 是无界的,没有容量限制。这显然太表面了。数组的长度限制是被虚拟机固化的,CopyOnWriteArrayList 没有限制的原因是:它没有做额外扩容,而且不适合大数据的场景,所以没有限制的必要。
4、修改元素
修改元素和新增元素的思想是一致的,通过 ReentrantLock 锁保证线程安全性,实现代码也比较简单。
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock(); //加锁
try {
Object[] elements = getArray(); // 获取老数组
E oldValue = get(elements, index); // 获取指定位置元素
if (oldValue != element) { // 新老元素是否相等,不相等
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len); // 复制老数组
newElements[index] = element; // 指定位置赋新值
setArray(newElements); // 替换掉老数组
} else {
// 有意思的地方来了
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
通过源码可以看到在修改元素前会先比较修改前后的值是否相等,而在相等的情况下,依旧 setArray (elements); 这就很奇妙了,到底是为什么呢?想了解其中的原因需要了解下 volatile 的特殊作用,通过下面这个代码例子说明。
int nonVolatileField = 0;
//在Thread 1中,首先更新了nonVolatileField的值为1,然后使用list.set(0, "x")将列表中的第一个元素设置为字符串"x"。
CopyOnWriteArrayList<String> list =
// Thread 1
nonVolatileField = 1; // (1)
list.set(0, "x"); // (2)
//在Thread 2中,使用list.get(0)获取列表中的第一个元素,并将其赋值给字符串变量s。然后通过s == "x"的判断,判断s是否等于"x",如果成立,则进入条件块,并将nonVolatileField的值赋给局部变量localVar。
// Thread 2
String s = list.get(0); // (3)
if (s == "x") {
int localVar = nonVolatileField; // (4)
}
在这个示例中,nonVolatileField
没有使用volatile
关键字进行修饰,因此对于Thread 2来说,读取nonVolatileField
的值可能是旧值,而不是Thread 1中更新后的新值。这是因为没有同步机制来保证nonVolatileField
的可见性,所以Thread 2可能看不到Thread 1对其的修改。
由上就明白了为了确保在多线程环境下的线程安全性和可见性,我们需要使用同步机制volatile
关键字,而setArray(elements)
内部使用了同步机制。
5、删除元素
remove
删除元素方法一共有三个,这里只看 public E remove(int index)
方法,原理都是类似的。
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock(); // 加锁
try {
Object[] elements = getArray(); // 获取数据数组
int len = elements.length;
E oldValue = get(elements, index); // 获取要删除的元素
int numMoved = len - index - 1;
if (numMoved == 0) // 是否末尾
setArray(Arrays.copyOf(elements, len - 1)); // 数据数组减去末尾元素
else {
Object[] newElements = new Object[len - 1]; // 把要删除的数据的前后元素分别拷贝到新数组
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements); // 使用新数组替换老数组
}
return oldValue;
} finally {
lock.unlock(); // 解锁
}
}
代码还是很简单的,使用 ReentrantLock 独占锁保证操作的线程安全性,然后使用删除元素后的剩余数组元素拷贝到新数组,使用新数组替换老数组完成元素删除,最后释放锁返回。
6、获取元素
获取下标为 index 的元素,如果元素不存在,会抛出 IndexOutOfBoundsException
异常。
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
首先看到这里是没有任何的加锁操作的,而获取指定位置的元素又分为了两个步骤:
- getArray () 获取数据数组。
- get (Object [] a, int index) 返回指定位置的元素。
很有可能在第一步执行完成之后,步骤二执行之前,有线程对数组进行了更新操作。通过上面的分析我们知道更新会生成一个新的数组,而我们第一步已经获取了老数组,所以我们在进行 get 时依旧在老数组上进行,也就是说另一个线程的更新结果没有对我们的本次 get 生效。这也是上面提到的弱一致性问题。
7、COWIterator迭代器的弱一致性
List<String> list = new CopyOnWriteArrayList<>();
list.add("www.wbtjc.com");
list.add("如果我是枫");
Iterator<String> iterator = list.iterator();
list.add("java");
while (iterator.hasNext()) {
String next = iterator.next();
System.out.println(next);
}
现在 List 中添加了元素 www.wbtjc.com
和 如果我是枫
,在拿到迭代器对象后,又添加了新元素 java
, 可以看到遍历的结果没有报错也没有输出 java
。也就是说拿到迭代器对象后,元素的更新不可见。
www.wbtjc.com
如果我是枫
这是为什么呢?要先从 CopyOnWriteArrayList 的 iterator () 方法的实现看起。
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}
// 注意看:有 static 关键字,直接引用底层数组
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;
}
......
可以看到在获取迭代器时,先 getArray()
拿到了数据数组 然后传入到 COWIterator 构造器中,接着赋值给了 COWIterator 中的 snapshot 属性,结合上面的分析结果,可以知道每次更新都会产生新的数组,而这里使用的依旧是老数组,所以更新操作不可见,也就是上面多次提到的弱一致性。
为什么是 “弱” 的呢?这是因为 COWIterator
迭代器会持有 CopyOnWriteArrayList “底层数组” 的引用,而 CopyOnWriteArrayList 的写入操作是写入到新数组,因此 COWIterator
是无法感知到的,除非重新创建迭代器。
相较之下,ArrayList 的迭代器是通过持有 “外部类引用” 的方式访问 ArrayList 的底层数组,因此在 ArrayList 上的写入操作会实时被迭代器观察到。
// 注意看:没有 static 关键字,通过外部类引用来访问底层数组
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = modCount;
Itr() {}
...
}
四、新版变化
上面的源码分析都是基于 JDK 8 进行的。现在都JDK17会有哪些改动呢?改动还真的挺大的,主要体现在加锁的方式上,或许是因为 JVM 后来引入了 synchronized 锁升级策略,让 synchronized 性能有了不少提升,所以用了 synchronized 锁替换了老的 ReentrantLock 锁。
新增:
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;
}
}
修改:
public E set(int index, E element) {
synchronized (lock) {
Object[] es = getArray();
E oldValue = elementAt(es, index);
if (oldValue != element) {
es = es.clone();
es[index] = element;
}
setArray(es);
return oldValue;
}
}
在高版本的 JDK 中,synchronized 已经可以根据运行时情况,自动调整锁的粒度
五、总结
十个知识点:
-
构建线程安全的List有三种方法:
-
使用Vector容器。(过时)
-
使用Collections的静态方法synchronizedList(List< T> list)。Collections.synchronizedList。
-
采用CopyOnWriteArrayList容器。
-
-
Vector和Collections.synchronizedList是fail-fast机制,CopyOnWriteArrayList是fail-safe机制
-
CopyOnWriteArrayList 和 ArrayList 都是基于动态数组,封装了操作数组时的搬运和扩容等逻辑;
-
CopyOnWriteArrayList 因为每次写入时都要扩容复制数组,写入性能不佳。
-
CopyOnWriteArrayList 在修改元素时,为了保证 volatile 语义,即使元素没有任何变化也会重新赋值。
setArray(elements)
方法用于在
CopyOnWriteArrayList` 中更新底层数组的值,确保在多线程环境下的线程安全性和可见性。 -
volatile 变量是 Java 轻量级的线程同步原语。目的是提供一种简单的同步机制,保证多线程间的可见性,避免出现脏读、写入冲突等问题。
-
CopyOnWriteArrayList 用了基于加锁的 “读写分离” 和 “写时复制” 的方案解决线程安全问题:
-
思想 1 - 读写分离(Read/Write Splitting): 将对资源的读取和写入操作分离,使得读取和写入没有依赖,在 “读多写少” 的场景中能有效减少资源竞争;
-
思想 2 - 写时复制(CopyOnWrite,COW): 在写入数据时,不直接在原数据上修改,而是复制一份新数据后写入到新数据,最后再替换到原数据的引用上。这个特性各有优缺点:
-
- 优点 1 - 延迟处理: 在没有写入操作时不会复制 / 分配资源,能够避免瞬时的资源消耗。例如操作系统的 fork 操作也是一种写时复制的思想;
- 优点 2 - 降低锁颗粒度: 在写的过程中,读操作不会被影响,读操作也不需要加锁,锁的颗粒度从整个列表降低为写操作;
- 缺点 1 - 弱数据一致性: 在读的过程中,如果数据被其他线程修改,是无法实时感知到最新的数据变化的;
- 缺点 2 - 有内存压力: 在写操作中需要复制原数组,在复制的过程中内存会同时存在两个数组对象(只是引用,数组元素的对象还是只有一份),会带来内存占用和垃圾回收的压力。如果是 “写多读少” 的场景,就不适合。
-
-
CopyOnWriteArrayList 的迭代器是 “弱数据一致性的” 的,迭代器会持有 “底层数组” 的引用,而 CopyOnWriteArrayList 的写入操作是写入到新数组,因此迭代器是无法感知到的;
-
使用 CopyOnWriteArrayList 的场景一定要保证是 “读多写少” 且数据量不大的场景,而且在写入数据的时候,要做到批量操作;
举 2 个例子:
- 例如批量写入一组数据,要使用 addAll 方法 批量写入;
- 例如在做排序时,要先输出为 ArrayList,在 ArrayList 上完成排序后再写回 CopyOnWriteArrayList。
-
在高版 JDK 中,得益于 synchronized 锁升级策略, CopyOnWriteArrayList 的加锁方式采用了 synchronized。
四个问题:
疑问 1:为什么 array 要使用 volatile 关键字?
因为volatile 变量是 Java 轻量级的线程同步原语。目的是提供一种简单的同步机制,保证多线程间的可见性,避免出现脏读、写入冲突等问题。
疑问 2:为什么 CopyOnWriteArrayList 不提供初始化容量的构造器?
这是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。如果提供了带初始化容量的构造器,意味着开发者预期会一个个地写入数据,这不符合 CopyOnWriteArrayList 的正确使用方法。所以,不提供这个构造器才是合理的。
疑问 3:为什么要把 E[] 类型的入参转化为 Object[] 类型?
如果不转化数组类型,那么在 toArray() 方法返回的数组中插入 Object 类型对象时,会抛出 ArrayStoreException
。
疑问 4:在添加方法中,为什么扩容只增大 1 容量,而 ArrayList 会增大 1.5 倍?
这还是因为 CopyOnWriteArrayList 建议我们使用批量操作写入数据。ArrayList 额外扩容 1.5 倍是为了避免每次 add 都扩容,而 CopyOnWriteArrayList 并不建议一个个地添加数据,而是建议批量操作写入数据,例如 addAll 方法。所以,CopyOnWriteArrayList 不额外扩容才是合理的。
优缺点:
优点:
- 线程安全:CopyOnWriteArrayList 是线程安全的,多个线程可以同时读取列表,而无需额外的同步机制。
- 写操作不阻塞读操作:在写操作(例如添加、修改或删除元素)时,CopyOnWriteArrayList 会创建一个新的数组副本,并在副本上执行写操作,而不影响原始数组。这意味着写操作不会阻塞并发的读操作,读操作可以继续访问原始数组,从而提高读取性能。
- 高并发读取:由于读取操作不需要同步,多个线程可以同时读取列表的内容,提供了良好的并发性能。
缺点:
- 内存占用:由于每次写操作都会创建一个新的数组副本,CopyOnWriteArrayList 的内存占用比较高。如果列表中的元素数量很大或写操作频繁,会导致频繁的内存拷贝和额外的内存消耗。
- 写操作性能较低:由于每次写操作都要创建一个新的数组副本,写操作的性能较低。对于频繁的写操作,CopyOnWriteArrayList 的性能可能会受到影响。
- 不适用于实时性要求高的场景:由于写操作的延迟和内存拷贝,CopyOnWriteArrayList 不适用于实时性要求高的场景,例如需要立即反映变化的应用程序。
使用场景:
CopyOnWriteArrayList 适用于读多写少的场景,特别是在迭代操作较多、对实时性要求不高的并发环境下,能够提供高并发的读取性能和线程安全性。