JDK源码——CopyOnWriteArrayList源码

本文深入探讨了Java中的CopyOnWriteArrayList,这是一个线程安全的列表容器,尤其适合读多写少的并发场景。通过写时复制技术,CopyOnWriteArrayList在读操作时无锁,保证高并发下的读性能,而在写操作时通过复制数组来避免数据冲突。文章详细分析了其add、get、remove和set等核心方法的实现,并指出即使元素未改变,也会更新array以确保volatile语义。最后,总结了CopyOnWriteArrayList的特点及其适用场合。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

摘要

提到Java语言中的List容器,最先想到的应该是ArrayList和LinkedList,使用频率最高了。但是很可惜,它俩都不是线程安全的容器类,如果多线程并发操作同一个容器对象,就会出现问题。

一、如何创建一个线程安全的List?

有两种方式,一种是使用Collections.synchronizedList()将线程不安全的List包装成线程安全的。它的原理很简单,使用【装饰者模式】将不安全的方法加上synchronized关键字来保证同步。优点是实现简单,缺点是性能较差。

第二种方式就是我们今天要说的主角CopyOnWriteArrayList,它用到了一种被叫作【写时复制】的技术,使得读操作永远不会加锁,写操作时进行一次自我复制,写入完成后再完成数据的替换。这样只有写写才会冲突等待,读读、读写都不会冲突。

因此,CopyOnWriteArrayList更适合【读多写少】的场景,它的读操作是无锁的,效率非常高,但是写操作会进行数据的复制,如果本身存储的数据量非常的大,复制本身就是一个开销很大的操作,而且会给GC带来额外的压力。使用时,需要确保写操作的频率不会太高,而且可以接受写操作的耗时。

实现了List接口,代表它是一个有序的列表容器,支持迭代遍历等操作。实现了Cloneable接口,代表它支持克隆,使用的是【浅拷贝】模式。实现了RandomAccess接口,代表它支持【快速随机访问】,因为它底层数据结构是数组,支持通过下标快速访问。

二、CopyOnWriteArrayList源码

2.1 成员变量

// 写操作竞争的锁对象
final transient ReentrantLock lock = new ReentrantLock();

// 基于数组实现的,只能通过getArray/setArray来读写
private transient volatile Object[] array;
  1. lock:写操作时竞争的锁对象,使用的是JDK提供的显式锁ReentrantLock。
  2. array:数组对象,底层使用数组来存储元素。

2.2 构造函数

CopyOnWriteArrayList提供了三个构造函数,分别如下:

// 默认构造函数,创建一个空数组
public CopyOnWriteArrayList();

// 给定一个集合容器创建
public CopyOnWriteArrayList(Collection<? extends E> c);

// 给定一个数组创建
public CopyOnWriteArrayList(E[] toCopyIn);

默认的空参构造函数会创建一个长度为0的数组,因为CopyOnWriteArrayList没有扩容的概念,array指向的永远是一个不可变数组。每次add操作都会拷贝一个原数组长度加1的新数组,再把数据拷贝到新数组中。

另外两个构造函数,无非就是将Collection容器或给定数组做一个浅拷贝后赋值给array,这里不做解释。

需要注意的是,Collection类有一个toArray()方法,它可以获取Collection容器内部的数组对象,但是它返回的不一定是Object[],例如Arrays.asList()就可能返回Integer[],这就会出现类型不兼容的问题,因此CopyOnWriteArrayList会基于它创建一个新的Object[]再复制数据。

public CopyOnWriteArrayList(Collection<? extends E> c) {
	Object[] elements;
	if (c.getClass() == CopyOnWriteArrayList.class)
		elements = ((CopyOnWriteArrayList<?>)c).getArray();
	else {
		elements = c.toArray();
		/*
		Collection.toArray()部分子类的实现可能不会返回Object[],例如:Arrays.asList()可能会返回Integer[]
		因此这里必须做处理,如果返回的不是Object[],则通过Arrays.copyOf()拷贝一个Object[]
		 */
		if (elements.getClass() != Object[].class)
			elements = Arrays.copyOf(elements, elements.length, Object[].class);
	}
	setArray(elements);
}

2.3 核心方法

2.3.1 add()

向容器中添加元素时,需要竞争锁,同一时刻最多只有一个线程可以操作。因为是【写时复制】的,写入数据时不应该影响其他线程的读取,因此不会直接在array数组上操作,而是拷贝一个新的数组,元素设置完成后再覆盖旧数组。

public boolean add(E e) {
	final ReentrantLock lock = this.lock;
	lock.lock();
	try {
		Object[] elements = getArray();
		int len = elements.length;
		// 拷贝一个长度+1的数组,将元素放到末尾
		Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 填充要追加的元素e
		newElements[len] = e;
        // 覆盖旧数组
		setArray(newElements);
		return true;
	} finally {
		lock.unlock();
	}
}

2.3.2 get()

通过下标获取元素,代码不能再简单了,直接从array数组中取就行了。因为是【写时复制】的,可能在访问时已经有新的元素加入,或者有元素被删除,这是会存在延迟的,不是实时的,这也算是它的一个缺点。

public E get(int index) {
    // getArray()获取的就是array
    return get(getArray(), index);
}

private E get(Object[] a, int index) {
    return (E) a[index];
}

2.3.2 remove()

remove也是写操作,只有竞争到锁的线程才能执行。它先是取出对应下标的旧元素,然后新建了一个原数组长度减1的新数组,完成数据拷贝后,再写回array,整个过程依然不影响其它线程读。

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)
			// 删除的是最后一个元素,直接拷贝一个长度-1的数组写回array即可
			setArray(Arrays.copyOf(elements, len - 1));
		else {
			// 删除的是中间元素,拷贝一个长度-1的数组
			Object[] newElements = new Object[len - 1];
			// 拷贝前半段元素
			System.arraycopy(elements, 0, newElements, 0, index);
			// 拷贝后半段元素
			System.arraycopy(elements, index + 1, newElements, index,
					numMoved);
			// 写回array
			setArray(newElements);
		}
		return oldValue;
	} finally {
		lock.unlock();
	}
}

2.3.4 size()

获取元素的数量也是非常简单,直接取数组的长度即可。因为CopyOnWriteArrayList的数组是不可变数组,它始终是一个被填充满的数组对象,没有扩容的操作,因此也没有必要像ArrayList一样,额外使用一个int size来记录数量。

public int size() {
    return getArray().length;
}

2.3.5 set()

set方法用来给指定下标设置值,同时会返回旧值。它也是一个写入操作,因此也需要竞争到锁才能执行。为了不影响其它线程读取,它会拷贝一个同样长度的新数组,然后做数据拷贝,在新数组上完成新值的设置,最终再写回array。

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;
			// 拷贝一个一样的数组,替换下标元素,并写入array
			Object[] newElements = Arrays.copyOf(elements, len);
			newElements[index] = element;
			setArray(newElements);
		} else {
			// 即使元素没有变化,也要写入array,确保volatile的写语义
			// Not quite a no-op; ensures volatile write semantics
			setArray(elements);
		}
		return oldValue;
	} finally {
		lock.unlock();
	}
}

细心的同学也许会发现一个很有意思的操作,即使设置的元素没有改变,它还是会调用setArray(elements)给array重新赋值。这个步骤看起来好似没有任何意义,也不影响array本身的可见性,那为何Doug Lea大神还要多此一举呢?

其实,注释里已经说明了这一点。这一步并非没有意义,它是为了确保volatile关键字的语义。什么语义呢?其实和array的【可见性】无关,和【指令重排序】有关,它是为了保证其他非volatile关键字修饰的属性不被重排序,和happens-before原则有关。

三、CopyOnWriteArrayList总结

CopyOnWriteArrayList是基于【写时复制】技术实现的,适用于【读多写少】场景下的线程安全的并发容器。读操作永远不会加锁,读读、读写都不会冲突,只有写写需要等待。写操作时,为了不影响其它线程的读取,它会进行一次自我复制,待数据写入完成后再替换array数组。array数组是被volatile修饰的,它被修改后可以被其他线程立刻发现。

博文参考

CopyOnWriteArrayList源码分析_程序员小潘的博客-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

庄小焱

我将坚持分享更多知识

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值